Files
hold-slayer/Dockerfile
Robert Helewka 1644999bcb feat(logging): structured JSON logs, uvicorn access log included
Hold Slayer's logs are shipped to Loki by the host's Alloy agent, which
reads container stdout. Text lines arrive there as an opaque blob:
filtering on a status code meant regex over a formatted string. This adds
LOG_FORMAT=json (default "text", so local dev stays readable) rendering
one JSON object per line.

Two parts were less obvious than a format= argument would suggest, and
both are why this is a module rather than a basicConfig tweak:

Uvicorn attaches its own handlers to `uvicorn` and `uvicorn.access` with
propagate=False, so configuring only the root logger would have left the
access log — the highest-volume, most useful stream — as colourised text
next to our JSON. configure_logging clears those handlers and re-enables
propagation, and is called both at import (for startup config checks) and
in lifespan (uvicorn configures itself after importing the app). The
__main__ path passes log_config=None so uvicorn never applies its own.

The access record's payload lives in record.args as a 5-tuple, not in the
message. Formatting it would throw the structure away and force Loki to
parse it back out, so the tuple is unpacked into real fields and
status_code is emitted as a number for range filtering.

Also drops uvicorn's `color_message` extra, an ANSI-coloured duplicate of
the message that generic extra-promotion would otherwise copy into every
startup line — the same unreadable-in-Grafana problem recently fixed for
the lab's Asterisk logs.

Verified against a real uvicorn server: 39/39 lines valid JSON, zero ANSI
escapes, no duplicates, access lines structured with correct status codes;
text mode unchanged. Thread name is included off the main thread, since
"which execution context logged this" is the first question when debugging
across the asyncio/Sippy/PJSUA2 boundary. SecretStr extras stay masked.

README Phase 4 item ticked; LOG_FORMAT and the previously-undocumented
LOG_LEVEL added to the config table and .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:18:06 -04:00

59 lines
2.7 KiB
Docker

# Hold Slayer — single image: FastAPI process that also serves the built
# SvelteKit dashboard at "/". One container, four surfaces (REST/WS/MCP/dash).
#
# pjsua2 is deliberately NOT built here — it is not pip-installable (compiled
# from pjproject) and the media pipeline degrades to documented stub mode
# without it. That is correct for a mock-SIP dev deploy (USE_MOCK_SIP=true);
# /health honestly reports the mock engine as "degraded". Building real media
# is a separate, deliberate piece of work.
# Stage 1: build the SvelteKit dashboard → dashboard/build/ (SPA, static).
# dashboard/build and dashboard/node_modules are gitignored, so build fresh
# here rather than copying a stale working-tree artifact.
FROM node:22-alpine AS dashboard
WORKDIR /dashboard
COPY dashboard/package.json dashboard/package-lock.json ./
RUN npm ci
COPY dashboard/ ./
RUN npm run build
# Stage 2: runtime. The app runs FROM SOURCE at /app (not purely from
# site-packages): db/database.py locates alembic.ini via
# Path(__file__).parent.parent, and main.py/config.py are loose top-level
# modules — both require the source tree layout under the working dir. An
# editable install puts the deps + entry points in place while keeping /app/db,
# /app/config.py, /app/alembic.ini resolving to the real files.
FROM python:3.12-slim
WORKDIR /app
# build-essential: some deps compile from source (no manylinux wheel).
# curl: required for the compose healthcheck (GET /health).
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies first (better layer caching) using just the manifest,
# then the source. -e keeps the package importable from /app so alembic.ini
# and the loose modules resolve correctly at runtime.
COPY pyproject.toml README.md ./
COPY . .
# Bring in the freshly built dashboard (overwrites any stale gitignored copy).
COPY --from=dashboard /dashboard/build ./dashboard/build
RUN pip install --no-cache-dir -e . \
&& apt-get purge -y build-essential && apt-get autoremove -y
EXPOSE 21081
# Structured logs by default in the container: the host's Alloy agent reads
# stdout and ships it to Loki, where text lines arrive as an unqueryable blob.
# Overridable (LOG_FORMAT=text) for interactive `docker run` debugging.
ENV LOG_FORMAT=json
# Migrations run in the app's own init_db() on boot (db/database.py), so no
# separate `alembic upgrade` here. Bind host/port from the same env vars
# pydantic-settings reads (HOST/PORT) so configured values and the actual bind
# cannot drift. Deploy sets PORT=21081 (image default 8000 collides with other
# host-net services on triton).
CMD ["sh", "-c", "uvicorn main:app --host ${HOST:-0.0.0.0} --port ${PORT:-21081}"]