Skip to content
Merged
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
2 changes: 1 addition & 1 deletion server/gunicorn.config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

bind = f"0.0.0.0:{os.environ.get('PACKIT_INTERFACE_PORT', 8090)}"
worker_class = "uvicorn.workers.UvicornWorker"
workers = 2
workers = os.environ.get("LOG_DETECTIVE_PACKIT_WORKERS", 4)
# timeout set to 600 seconds; with 32 clusters and several runs in parallel, it
# can take even 10 minutes for a query to complete
timeout = 600
Expand Down
64 changes: 48 additions & 16 deletions src/logdetective_packit/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from json import JSONDecodeError
import logging
Expand Down Expand Up @@ -30,20 +31,37 @@
PUBLISH_TIMEOUT = int(os.environ.get("PUBLISH_TIMEOUT", 30))
LD_PACKIT_TOKEN = os.environ.get("LD_PACKIT_TOKEN", "")

LOG = logging.Logger("LogDetectivePackit", level=logging.WARNING)
LOG = logging.getLogger("LogDetectivePackit")

http_bearer = HTTPBearer()

# Set the LD_PACKIT_INTERFACE_SENTRY_DSN env variable beforehand
sentry_sdk.init(
dsn=os.environ.get("LD_PACKIT_INTERFACE_SENTRY_DSN")
dsn=os.environ.get("LD_PACKIT_INTERFACE_SENTRY_DSN"), traces_sample_rate=1.0
)

app = FastAPI(title="LogDetectivePackit", version=version("logdetective-packit"))

# Setup logging for fedora-messaging
conf.setup_logging()

http_client = AsyncClient(timeout=LD_TIMEOUT)

_log_detective_call_tasks: set[asyncio.Task] = set()


@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handler running tasks when the server is being shut down."""
yield
if _log_detective_call_tasks:
await asyncio.gather(*_log_detective_call_tasks, return_exceptions=True)
Comment thread
jpodivin marked this conversation as resolved.


app = FastAPI(
title="LogDetectivePackit",
version=version("logdetective-packit"),
lifespan=lifespan,
)


async def publish_message(message: Message):
try:
Expand All @@ -55,7 +73,7 @@ async def publish_message(message: Message):

def build_error_message(
log_detective_analysis_id: str,
log_detective_analysis_start: str,
log_detective_analysis_start: datetime,
build_info: BuildInfo,
error_msg: str = "",
) -> Message:
Expand All @@ -66,7 +84,7 @@ def build_error_message(
"target_build": build_info.target_build,
"build_system": build_info.build_system,
"log_detective_analysis_id": log_detective_analysis_id,
"log_detective_analysis_start": log_detective_analysis_start,
"log_detective_analysis_start": str(log_detective_analysis_start),
"project_url": build_info.project_url,
"pr_id": build_info.pr_id,
"commit_sha": build_info.commit_sha,
Expand All @@ -79,7 +97,7 @@ def build_error_message(
async def call_log_detective(
build_info: BuildInfo,
log_detective_analysis_id: str,
log_detective_analysis_start: str,
log_detective_analysis_start: datetime,
) -> None:
"""Analyze build artifacts using Log Detective API. Only the first log
is analyzed."""
Expand All @@ -91,12 +109,11 @@ async def call_log_detective(
if LD_TOKEN:
headers["Authorization"] = f"Bearer {LD_TOKEN}"
try:
async with AsyncClient(timeout=LD_TIMEOUT) as client:
response = await client.post(
url=LD_URL,
headers=headers,
json={"url": log_url},
)
response = await http_client.post(
url=LD_URL,
headers=headers,
json={"url": log_url},
)
response.raise_for_status()
except HTTPStatusError as ex:
msg = f"Request to Log Detective API at {LD_URL} failed with HTTP status error: {ex}"
Expand Down Expand Up @@ -142,7 +159,7 @@ async def call_log_detective(
"target_build": build_info.target_build,
"build_system": build_info.build_system,
"log_detective_analysis_id": log_detective_analysis_id,
"log_detective_analysis_start": log_detective_analysis_start,
"log_detective_analysis_start": str(log_detective_analysis_start),
"project_url": build_info.project_url,
"pr_id": build_info.pr_id,
"commit_sha": build_info.commit_sha,
Expand All @@ -151,6 +168,16 @@ async def call_log_detective(
await publish_message(message)


def analysis_task_callback(task: asyncio.Task):
"""Check that task didn't raise exception and was completed successfully."""
try:
if exc := task.exception():
sentry_sdk.capture_exception(exc)
# Check for errors that can be raised from exception() call
except asyncio.CancelledError as cancelled_error:
sentry_sdk.capture_exception(cancelled_error)


@app.post("/analyze", response_model=Response)
async def analyze_build(
build_info: BuildInfo,
Expand All @@ -167,14 +194,19 @@ async def analyze_build(
)

log_detective_analysis_id = str(uuid.uuid4())
log_detective_analysis_start = str(datetime.now(timezone.utc))
asyncio.create_task(
log_detective_analysis_start = datetime.now(timezone.utc)
task = asyncio.create_task(
call_log_detective(
build_info,
log_detective_analysis_id,
log_detective_analysis_start=log_detective_analysis_start,
)
)
_log_detective_call_tasks.add(task)

# Verify that task was completed and remove it from set of running tasks
task.add_done_callback(analysis_task_callback)
task.add_done_callback(_log_detective_call_tasks.discard)

return Response(
log_detective_analysis_id=log_detective_analysis_id,
Expand Down
9 changes: 5 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest

from datetime import datetime
from fedora_messaging.api import Message
from fedora_messaging.exceptions import (
ValidationError,
Expand Down Expand Up @@ -32,7 +33,7 @@
def test_build_error_message_content(mock_env_vars):
"""Test that build_error_message constructs the Message body correctly."""
analysis_id = "test-uuid-123"
start_time = "2024-03-20T12:00:00Z"
start_time = datetime.fromisoformat("2024-03-20T12:00:00Z")
error_text = "Analysis failed due to timeout"
build_info = BuildInfo(**MINIMAL_BUILD_INFO)

Expand All @@ -49,7 +50,7 @@ def test_build_error_message_content(mock_env_vars):
body = message.body
assert body["status"] == LogDetectiveResult.error
assert body["log_detective_analysis_id"] == analysis_id
assert body["log_detective_analysis_start"] == start_time
assert datetime.fromisoformat(body["log_detective_analysis_start"]) == start_time
assert body["error_msg"] == error_text

assert body["target_build"] == build_info.target_build
Expand Down Expand Up @@ -118,7 +119,7 @@ async def test_call_log_detective(
):

log_detective_analysis_id = "8052517e-cf69-11f0-9b27-9a478821d0e2"
log_detective_build_analysis_start = "2025-12-10 10:57:57.341695+00:00"
log_detective_build_analysis_start = datetime.fromisoformat("2025-12-10 10:57:57.341695+00:00")
build_info = BuildInfo(**MINIMAL_BUILD_INFO)
await call_log_detective(
build_info=build_info,
Expand All @@ -143,7 +144,7 @@ async def test_call_log_detective_request_exception(

with pytest.raises(HTTPStatusError):
log_detective_build_analysis_id = "8052517e-cf69-11f0-9b27-9a478821d0e2"
log_detective_build_analysis_start = "2025-12-10 10:57:57.341695+00:00"
log_detective_build_analysis_start = datetime.fromisoformat("2025-12-10 10:57:57.341695+00:00")
await call_log_detective(
build_info=build_info,
log_detective_analysis_id=log_detective_build_analysis_id,
Expand Down
3 changes: 2 additions & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def mock_env_vars(monkeypatch):
monkeypatch.setenv("LD_PACKIT_TOKEN", "secret-123")
monkeypatch.setenv("PUBLISH_TIMEOUT", "10")
monkeypatch.setenv("MESSAGE_EXCHANGE", "test-exchange")
monkeypatch.setenv("LD_URL", "http://this-is-not-log-detective.cs")


@pytest.fixture
Expand All @@ -74,7 +75,7 @@ def mock_external_calls(mocker):
# placed on post
mock_async_client.__aenter__.return_value = mock_async_client

mocker.patch("logdetective_packit.main.AsyncClient", return_value=mock_async_client)
mocker.patch("logdetective_packit.main.http_client", mock_async_client)
mock_publish = mocker.patch("logdetective_packit.main.publish")

return {"mock_publish": mock_publish, "mock_async_client": mock_async_client}
Expand Down
Loading