From a92baeac247497206ae71bed03d1730a46c0ebb7 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 08:47:42 +0300 Subject: [PATCH 1/4] Fix exception handling --- src/application/use_cases/process_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/application/use_cases/process_image.py b/src/application/use_cases/process_image.py index b39fcdf..9dd90f0 100644 --- a/src/application/use_cases/process_image.py +++ b/src/application/use_cases/process_image.py @@ -52,8 +52,8 @@ async def execute(self, image_id: uuid.UUID) -> bool: ) image.mark_completed(thumb_path, metadata) await self._repository.save(image) - except Exception: - logger.exception("Failed to process image %s", image_id) + except Exception as e: + logger.exception("Failed to process image %s: %s", image_id, e) if thumb_path is not None: try: await self._storage.delete(thumb_path) From 00a6c716b098c79e567a1a1f3dcfd637cdbe5d0a Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:01:39 +0300 Subject: [PATCH 2/4] Add structured logging --- CHANGELOG.md | 9 ++ src/main.py | 3 +- src/presentation/api/correlation.py | 27 ++++ src/presentation/api/middleware.py | 22 +++- src/presentation/logging_config.py | 76 +++++++++++ tests/presentation/test_correlation.py | 169 +++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 src/presentation/api/correlation.py create mode 100644 src/presentation/logging_config.py create mode 100644 tests/presentation/test_correlation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e4fd15a..151b151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Structured logging with correlation IDs for distributed tracing. Every request + is assigned a unique correlation ID (or accepts one via `X-Correlation-ID` header), + which is propagated through all log records and returned in the response header. +- `CorrelationIdFilter` logging filter that injects `correlation_id` into every log record. +- `JSONFormatter` for machine-readable JSON log output (opt-in via `configure_logging(json_output=True)`). +- `configure_logging()` helper replacing `logging.basicConfig()` with correlation-aware formatting. + ## [1.2.4] - 2026-03-31 ### Security diff --git a/src/main.py b/src/main.py index bcd3842..e89dd21 100644 --- a/src/main.py +++ b/src/main.py @@ -12,8 +12,9 @@ from src.presentation.api.dependencies import get_settings from src.presentation.api.middleware import RequestLoggingMiddleware from src.presentation.api.routes import health, images, retention +from src.presentation.logging_config import configure_logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +configure_logging(json_output=False) logger = logging.getLogger(__name__) diff --git a/src/presentation/api/correlation.py b/src/presentation/api/correlation.py new file mode 100644 index 0000000..c21b9e3 --- /dev/null +++ b/src/presentation/api/correlation.py @@ -0,0 +1,27 @@ +"""Correlation ID context for distributed tracing. + +Stores a per-request correlation ID in a :class:`contextvars.ContextVar` so +that every log record emitted during request processing can include the same +trace identifier — even across ``await`` boundaries and thread pool executors. +""" + +from __future__ import annotations + +import contextvars +import uuid + +correlation_id_ctx: contextvars.ContextVar[str] = contextvars.ContextVar( + "correlation_id", default="" +) + +HEADER_NAME = "X-Correlation-ID" + + +def new_correlation_id() -> str: + """Generate a new random correlation ID.""" + return uuid.uuid4().hex + + +def get_correlation_id() -> str: + """Return the current correlation ID (empty string if unset).""" + return correlation_id_ctx.get() diff --git a/src/presentation/api/middleware.py b/src/presentation/api/middleware.py index 130b0d6..d798001 100644 --- a/src/presentation/api/middleware.py +++ b/src/presentation/api/middleware.py @@ -1,4 +1,4 @@ -"""Request logging middleware.""" +"""Request logging middleware with correlation-ID propagation.""" from __future__ import annotations @@ -9,19 +9,35 @@ from starlette.requests import Request from starlette.responses import Response +from src.presentation.api.correlation import ( + HEADER_NAME, + correlation_id_ctx, + new_correlation_id, +) + logger = logging.getLogger(__name__) class RequestLoggingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + cid = request.headers.get(HEADER_NAME) or new_correlation_id() + token = correlation_id_ctx.set(cid) + start = time.perf_counter() - response = await call_next(request) + try: + response = await call_next(request) + finally: + correlation_id_ctx.reset(token) elapsed_ms = (time.perf_counter() - start) * 1000 + + response.headers[HEADER_NAME] = cid + logger.info( - "%s %s → %d (%.1fms)", + "%s %s → %d (%.1fms) [cid=%s]", request.method, request.url.path, response.status_code, elapsed_ms, + cid, ) return response diff --git a/src/presentation/logging_config.py b/src/presentation/logging_config.py new file mode 100644 index 0000000..b2d641d --- /dev/null +++ b/src/presentation/logging_config.py @@ -0,0 +1,76 @@ +"""Structured logging configuration with correlation-ID injection. + +Provides a :class:`logging.Filter` that attaches the current correlation ID +to every log record and a JSON formatter for machine-readable output. +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime + +from src.presentation.api.correlation import get_correlation_id + + +class CorrelationIdFilter(logging.Filter): + """Inject ``correlation_id`` into every log record.""" + + def filter(self, record: logging.LogRecord) -> bool: + record.correlation_id = get_correlation_id() # type: ignore[attr-defined] + return True + + +class JSONFormatter(logging.Formatter): + """Emit each log record as a single JSON line.""" + + def format(self, record: logging.LogRecord) -> str: + # Ensure exc_info is resolved to a tuple (LogRecord may store True). + if record.exc_info and not isinstance(record.exc_info, tuple): + import sys + + record.exc_info = sys.exc_info() + + log_entry: dict[str, object] = { + "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "correlation_id": getattr(record, "correlation_id", ""), + } + if record.exc_info and record.exc_info[1] is not None: + log_entry["exception"] = self.formatException(record.exc_info) + return json.dumps(log_entry, default=str) + + +def configure_logging(*, json_output: bool = False, level: int = logging.INFO) -> None: + """Set up root logger with correlation-ID filter. + + Parameters + ---------- + json_output: + When *True*, use :class:`JSONFormatter` for structured JSON lines. + When *False*, use a human-readable format that still includes the + correlation ID. + level: + Root log level. + """ + root = logging.getLogger() + root.setLevel(level) + + # Remove any existing handlers to avoid duplicate output. + root.handlers.clear() + + handler = logging.StreamHandler() + handler.addFilter(CorrelationIdFilter()) + + if json_output: + handler.setFormatter(JSONFormatter()) + else: + handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s %(name)s [cid=%(correlation_id)s]: %(message)s" + ) + ) + + root.addHandler(handler) diff --git a/tests/presentation/test_correlation.py b/tests/presentation/test_correlation.py new file mode 100644 index 0000000..bff661e --- /dev/null +++ b/tests/presentation/test_correlation.py @@ -0,0 +1,169 @@ +"""Tests for correlation-ID middleware and structured logging.""" + +from __future__ import annotations + +import json +import logging +import re +from unittest.mock import patch + +import pytest + +from src.presentation.api.correlation import ( + HEADER_NAME, + correlation_id_ctx, + get_correlation_id, + new_correlation_id, +) +from src.presentation.logging_config import ( + CorrelationIdFilter, + JSONFormatter, + configure_logging, +) + +# ── correlation module ──────────────────────────────────────────────────── + + +class TestCorrelationId: + def test_new_correlation_id_is_hex(self): + cid = new_correlation_id() + assert re.fullmatch(r"[0-9a-f]{32}", cid) + + def test_new_ids_are_unique(self): + assert new_correlation_id() != new_correlation_id() + + def test_get_returns_empty_by_default(self): + assert get_correlation_id() == "" + + def test_context_var_round_trip(self): + token = correlation_id_ctx.set("test-id-123") + try: + assert get_correlation_id() == "test-id-123" + finally: + correlation_id_ctx.reset(token) + assert get_correlation_id() == "" + + def test_header_name(self): + assert HEADER_NAME == "X-Correlation-ID" + + +# ── logging filter & formatter ──────────────────────────────────────────── + + +class TestCorrelationIdFilter: + def test_filter_adds_correlation_id(self): + filt = CorrelationIdFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + token = correlation_id_ctx.set("abc123") + try: + result = filt.filter(record) + finally: + correlation_id_ctx.reset(token) + assert result is True + assert record.correlation_id == "abc123" # type: ignore[attr-defined] + + def test_filter_empty_when_no_context(self): + filt = CorrelationIdFilter() + record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None) + filt.filter(record) + assert record.correlation_id == "" # type: ignore[attr-defined] + + +class TestJSONFormatter: + def test_output_is_valid_json(self): + fmt = JSONFormatter() + record = logging.LogRecord("mylogger", logging.WARNING, "", 0, "hello", (), None) + record.correlation_id = "cid-42" # type: ignore[attr-defined] + line = fmt.format(record) + data = json.loads(line) + assert data["level"] == "WARNING" + assert data["logger"] == "mylogger" + assert data["message"] == "hello" + assert data["correlation_id"] == "cid-42" + assert "timestamp" in data + + def test_exception_included(self): + import sys + + fmt = JSONFormatter() + try: + raise ValueError("boom") + except ValueError: + exc_info = sys.exc_info() + record = logging.LogRecord("x", logging.ERROR, "", 0, "err", (), exc_info=exc_info) + record.correlation_id = "" # type: ignore[attr-defined] + data = json.loads(fmt.format(record)) + assert "exception" in data + assert "boom" in data["exception"] + + +class TestConfigureLogging: + def test_configure_text_format(self): + configure_logging(json_output=False, level=logging.DEBUG) + root = logging.getLogger() + assert root.level == logging.DEBUG + assert len(root.handlers) == 1 + assert any(isinstance(f, CorrelationIdFilter) for f in root.handlers[0].filters) + + def test_configure_json_format(self): + configure_logging(json_output=True, level=logging.INFO) + root = logging.getLogger() + handler = root.handlers[0] + assert isinstance(handler.formatter, JSONFormatter) + + +# ── middleware integration via TestClient ────────────────────────────────── + + +@pytest.fixture +def client(tmp_path): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock + + from fastapi.testclient import TestClient + + from src.application.dto.image_dto import ImageListResponse + from src.application.use_cases.list_images import ListImagesUseCase + from src.config import Settings + from src.main import app + from src.presentation.api.dependencies import get_list_use_case, get_settings + from src.presentation.schemas.image_schemas import ComponentCheck + + @asynccontextmanager + async def _noop_lifespan(app): + yield + + app.router.lifespan_context = _noop_lifespan + + mock_list = AsyncMock(spec=ListImagesUseCase) + mock_list.execute = AsyncMock( + return_value=ImageListResponse(images=[], total=0, offset=0, limit=50) + ) + app.dependency_overrides[get_list_use_case] = lambda: mock_list + + test_settings = Settings(storage_base_dir=str(tmp_path)) + app.dependency_overrides[get_settings] = lambda: test_settings + + async def _ok_db(): + return ComponentCheck(status="ok") + + with patch("src.presentation.api.routes.health._check_database", side_effect=_ok_db): + yield TestClient(app) + app.dependency_overrides.clear() + + +class TestMiddlewareCorrelation: + def test_generates_correlation_id_when_missing(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + cid = resp.headers.get(HEADER_NAME) + assert cid is not None + assert len(cid) == 32 + + def test_echoes_provided_correlation_id(self, client): + resp = client.get("/health", headers={HEADER_NAME: "my-trace-id-999"}) + assert resp.headers[HEADER_NAME] == "my-trace-id-999" + + def test_correlation_id_in_response_for_api_routes(self, client): + resp = client.get("/api/v1/images/") + assert HEADER_NAME in resp.headers From 1513ce26cbe169e77d2a80f8cb5881290ded9a95 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:10:51 +0300 Subject: [PATCH 3/4] Update log coverage --- CHANGELOG.md | 3 +++ src/application/use_cases/get_image.py | 7 +++++++ src/application/use_cases/process_image.py | 9 +++++++++ src/application/use_cases/upload_image.py | 4 ++++ src/infrastructure/cache/cached_image_repository.py | 5 +++++ src/infrastructure/processing/pipeline.py | 2 ++ src/infrastructure/storage/local_image_storage.py | 10 +++++++++- src/presentation/api/routes/images.py | 11 +++++++++++ src/presentation/api/routes/retention.py | 4 ++++ 9 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 151b151..031ea7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `CorrelationIdFilter` logging filter that injects `correlation_id` into every log record. - `JSONFormatter` for machine-readable JSON log output (opt-in via `configure_logging(json_output=True)`). - `configure_logging()` helper replacing `logging.basicConfig()` with correlation-aware formatting. +- Targeted logging across all layers: upload/rejection/404 in routes, image + lifecycle in use cases, batch start/complete in pipeline, file I/O in storage + (DEBUG), and cache hit/miss in repository (DEBUG). ## [1.2.4] - 2026-03-31 diff --git a/src/application/use_cases/get_image.py b/src/application/use_cases/get_image.py index 3bed5b8..2753264 100644 --- a/src/application/use_cases/get_image.py +++ b/src/application/use_cases/get_image.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import uuid from src.application.dto.image_dto import ImageResponse @@ -9,6 +10,8 @@ from src.domain.interfaces.image_repository import ImageRepository from src.domain.interfaces.image_storage import ImageStorage +logger = logging.getLogger(__name__) + class GetImageUseCase: def __init__(self, repository: ImageRepository, storage: ImageStorage) -> None: @@ -18,14 +21,18 @@ def __init__(self, repository: ImageRepository, storage: ImageStorage) -> None: async def execute(self, image_id: uuid.UUID) -> ImageResponse | None: image = await self._repository.get_by_id(image_id) if image is None: + logger.debug("Image not found in repository: %s", image_id) return None return _to_response(image) async def get_file(self, image_id: uuid.UUID, thumbnail: bool = False) -> bytes | None: image = await self._repository.get_by_id(image_id) if image is None: + logger.debug("Image not found for file download: %s", image_id) return None path = image.thumbnail_path if thumbnail else image.original_path if path is None: + kind = "thumbnail" if thumbnail else "original" + logger.debug("No %s path for image %s", kind, image_id) return None return await self._storage.retrieve(path) diff --git a/src/application/use_cases/process_image.py b/src/application/use_cases/process_image.py index 9dd90f0..dcf4820 100644 --- a/src/application/use_cases/process_image.py +++ b/src/application/use_cases/process_image.py @@ -31,8 +31,10 @@ def __init__( async def execute(self, image_id: uuid.UUID) -> bool: image = await self._repository.get_by_id(image_id) if image is None: + logger.warning("Process requested for non-existent image: %s", image_id) return False + logger.info("Processing started: image=%s filename=%s", image_id, image.filename) image.mark_processing() await self._repository.save(image) @@ -52,6 +54,13 @@ async def execute(self, image_id: uuid.UUID) -> bool: ) image.mark_completed(thumb_path, metadata) await self._repository.save(image) + logger.info( + "Processing completed: image=%s width=%d height=%d format=%s", + image_id, + result.width, + result.height, + result.format, + ) except Exception as e: logger.exception("Failed to process image %s: %s", image_id, e) if thumb_path is not None: diff --git a/src/application/use_cases/upload_image.py b/src/application/use_cases/upload_image.py index c87b434..70561f6 100644 --- a/src/application/use_cases/upload_image.py +++ b/src/application/use_cases/upload_image.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from datetime import UTC, datetime, timedelta from src.application.dto.image_dto import ImageResponse @@ -9,6 +10,8 @@ from src.domain.interfaces.image_repository import ImageRepository from src.domain.interfaces.image_storage import ImageStorage +logger = logging.getLogger(__name__) + class UploadImageUseCase: def __init__(self, repository: ImageRepository, storage: ImageStorage) -> None: @@ -36,6 +39,7 @@ async def execute( ) saved = await self._repository.save(image) + logger.info("Image persisted: id=%s filename=%s path=%s", saved.id, filename, storage_path) return _to_response(saved) diff --git a/src/infrastructure/cache/cached_image_repository.py b/src/infrastructure/cache/cached_image_repository.py index 70bfd3f..9118713 100644 --- a/src/infrastructure/cache/cached_image_repository.py +++ b/src/infrastructure/cache/cached_image_repository.py @@ -7,12 +7,15 @@ from __future__ import annotations +import logging import uuid from src.domain.entities.image import Image from src.domain.interfaces.image_repository import ImageRepository from src.infrastructure.cache.in_memory_cache import InMemoryImageCache +logger = logging.getLogger(__name__) + class CachedImageRepository(ImageRepository): """Repository decorator that caches get_by_id results.""" @@ -29,7 +32,9 @@ async def save(self, image: Image) -> Image: async def get_by_id(self, image_id: uuid.UUID) -> Image | None: cached = self._cache.get(image_id) if cached is not None: + logger.debug("Cache hit: image=%s", image_id) return cached + logger.debug("Cache miss: image=%s", image_id) image = await self._inner.get_by_id(image_id) if image is not None: self._cache.set(image) diff --git a/src/infrastructure/processing/pipeline.py b/src/infrastructure/processing/pipeline.py index f1a0e7b..fc65820 100644 --- a/src/infrastructure/processing/pipeline.py +++ b/src/infrastructure/processing/pipeline.py @@ -23,6 +23,7 @@ async def process_batch( semaphore = asyncio.Semaphore(concurrency) success = 0 failed = 0 + logger.info("Batch processing started: count=%d concurrency=%d", len(image_ids), concurrency) async def _process_one(image_id: uuid.UUID) -> bool: async with semaphore: @@ -41,4 +42,5 @@ async def _process_one(image_id: uuid.UUID) -> bool: else: failed += 1 + logger.info("Batch processing completed: success=%d failed=%d", success, failed) return {"success": success, "failed": failed} diff --git a/src/infrastructure/storage/local_image_storage.py b/src/infrastructure/storage/local_image_storage.py index 7b9b287..c0ec84d 100644 --- a/src/infrastructure/storage/local_image_storage.py +++ b/src/infrastructure/storage/local_image_storage.py @@ -8,11 +8,14 @@ import asyncio import hashlib +import logging import os from pathlib import Path from src.domain.interfaces.image_storage import ImageStorage +logger = logging.getLogger(__name__) + class LocalImageStorage(ImageStorage): def __init__(self, base_dir: str) -> None: @@ -29,14 +32,19 @@ async def store(self, filename: str, data: bytes) -> str: if not str(dest).startswith(str(self._base.resolve())): raise ValueError("Path traversal detected in filename") await asyncio.to_thread(dest.write_bytes, data) + logger.debug("Stored file: %s (%d bytes)", dest, len(data)) return str(dest) async def retrieve(self, path: str) -> bytes: - return await asyncio.to_thread(Path(path).read_bytes) + data = await asyncio.to_thread(Path(path).read_bytes) + logger.debug("Retrieved file: %s (%d bytes)", path, len(data)) + return data async def delete(self, path: str) -> bool: try: await asyncio.to_thread(os.remove, path) + logger.debug("Deleted file: %s", path) return True except FileNotFoundError: + logger.debug("Delete skipped, file not found: %s", path) return False diff --git a/src/presentation/api/routes/images.py b/src/presentation/api/routes/images.py index c3561f7..34c44a4 100644 --- a/src/presentation/api/routes/images.py +++ b/src/presentation/api/routes/images.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import uuid from typing import Annotated @@ -31,6 +32,8 @@ ImageOut, ) +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/api/v1/images", tags=["images"], dependencies=[Depends(require_api_key)] ) @@ -49,6 +52,7 @@ async def upload_image( _rate: Annotated[None, Depends(upload_rate_limiter())] = None, ): if file.content_type not in ALLOWED_CONTENT_TYPES: + logger.warning("Upload rejected: unsupported content type %s", file.content_type) raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=f"Content type {file.content_type} not supported", @@ -56,12 +60,14 @@ async def upload_image( if tags is None: tags = [] if len(tags) > MAX_TAGS: + logger.warning("Upload rejected: too many tags (%d)", len(tags)) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"Maximum {MAX_TAGS} tags allowed", ) data = await file.read() if len(data) > MAX_UPLOAD_SIZE: + logger.warning("Upload rejected: file too large (%d bytes)", len(data)) raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 50 MB limit", @@ -69,6 +75,7 @@ async def upload_image( try: validate_image_bytes(data) except InvalidImageError as exc: + logger.warning("Upload rejected: image validation failed: %s", exc) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Image validation failed: {exc}", @@ -80,6 +87,7 @@ async def upload_image( tags=tags, ttl_hours=ttl_hours, ) + logger.info("Image uploaded: id=%s filename=%s size=%d", result.id, safe_filename, len(data)) return result @@ -102,6 +110,7 @@ async def get_image( ): result = await use_case.execute(image_id) if result is None: + logger.info("Image not found: %s", image_id) raise HTTPException(status_code=404, detail="Image not found") return result @@ -115,6 +124,7 @@ async def download_image( ): data = await use_case.get_file(image_id, thumbnail=thumbnail) if data is None: + logger.info("Image file not found: %s (thumbnail=%s)", image_id, thumbnail) raise HTTPException(status_code=404, detail="Image file not found") return Response(content=data, media_type="application/octet-stream") @@ -138,5 +148,6 @@ async def process_single_image( ): ok = await process_uc.execute(image_id) if not ok: + logger.info("Process requested for unknown image: %s", image_id) raise HTTPException(status_code=404, detail="Image not found") return await get_uc.execute(image_id) diff --git a/src/presentation/api/routes/retention.py b/src/presentation/api/routes/retention.py index edd368c..25fa504 100644 --- a/src/presentation/api/routes/retention.py +++ b/src/presentation/api/routes/retention.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Annotated from fastapi import APIRouter, Depends @@ -16,6 +17,8 @@ ) from src.presentation.schemas.image_schemas import RetentionResponse +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/api/v1/retention", tags=["retention"], @@ -29,5 +32,6 @@ async def trigger_retention_sweep( settings: Annotated[Settings, Depends(get_settings)] = None, # type: ignore[assignment] _rate: Annotated[None, Depends(process_rate_limiter())] = None, ): + logger.info("Retention sweep triggered, batch_size=%d", settings.retention_batch_size) result = await use_case.execute(batch_size=settings.retention_batch_size) return RetentionResponse(deleted_count=result.deleted_count, errors=result.errors) From 2539530f0c8760378f0f185ea235991e562cf0f5 Mon Sep 17 00:00:00 2001 From: Vladislav Antonov Date: Tue, 31 Mar 2026 09:14:49 +0300 Subject: [PATCH 4/4] Update version --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 031ea7a..4c3793f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-03-31 + ### Added - Structured logging with correlation IDs for distributed tracing. Every request @@ -19,6 +21,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 lifecycle in use cases, batch start/complete in pipeline, file I/O in storage (DEBUG), and cache hit/miss in repository (DEBUG). +### Fixed + +- Replaced bare `except Exception:` with `except Exception as e:` in + `ProcessImageUseCase` to ensure the exception is captured in log output. + ## [1.2.4] - 2026-03-31 ### Security diff --git a/pyproject.toml b/pyproject.toml index 9f0ea50..f64bacb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "image-processing-service" -version = "1.2.4" +version = "1.3.0" description = "High-performance image processing microservice with Clean Architecture" requires-python = ">=3.11" dependencies = [