From fcc2ab8983f2df156e1ec213587e8252897288fc Mon Sep 17 00:00:00 2001 From: sudheer Date: Wed, 24 Jun 2026 18:55:41 +0200 Subject: [PATCH 1/2] chore: migrate monthly digests to celery beat --- .gitignore | 3 +- backend/app/celery_app.py | 10 +- .../install-spendhound-monthly-reports.sh | 107 ------------------ .../spendhound-monthly-reports.service | 17 --- deploy/reports/spendhound-monthly-reports.sh | 54 --------- .../reports/spendhound-monthly-reports.timer | 11 -- 6 files changed, 9 insertions(+), 193 deletions(-) delete mode 100644 deploy/reports/install-spendhound-monthly-reports.sh delete mode 100644 deploy/reports/spendhound-monthly-reports.service delete mode 100644 deploy/reports/spendhound-monthly-reports.sh delete mode 100644 deploy/reports/spendhound-monthly-reports.timer diff --git a/.gitignore b/.gitignore index f738b40..830a07f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,7 @@ plans .claude* CLAUDE.md temp* -.next_features -.envv +.env* .infisical.json .task* task* diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index a2748bc..ea5c449 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -51,11 +51,17 @@ ) # Beat schedule — runs when `celery beat` is started (see docker-compose celery_beat service). -# Wall-clock crontab (UTC :00 and :30) so the reset time is predictable and the -# frontend can derive an accurate countdown without any server-side state. celery_app.conf.beat_schedule = { + # Wall-clock crontab (UTC :00 and :30) so the reset time is predictable and the + # frontend can derive an accurate countdown without any server-side state. "reset-demo-user-on-the-half-hour": { "task": "app.tasks.demo_tasks.reset_demo_user", "schedule": crontab(minute="0,30"), }, + # 1st of each month at 00:20 UTC - matches the former systemd timer schedule. + # Fans out one deliver_monthly_report task per eligible user. + "dispatch-monthly-reports": { + "task": "app.tasks.report_tasks.dispatch_monthly_reports", + "schedule": crontab(hour=0, minute=20, day_of_month=1), + }, } diff --git a/deploy/reports/install-spendhound-monthly-reports.sh b/deploy/reports/install-spendhound-monthly-reports.sh deleted file mode 100644 index c1c5a28..0000000 --- a/deploy/reports/install-spendhound-monthly-reports.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -umask 022 - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd -P)" - -RUNNER_SOURCE="${SCRIPT_DIR}/spendhound-monthly-reports.sh" -SERVICE_SOURCE="${SCRIPT_DIR}/spendhound-monthly-reports.service" -TIMER_SOURCE="${SCRIPT_DIR}/spendhound-monthly-reports.timer" -COMPOSE_FILE="${REPO_ROOT}/docker-compose.prod.yml" - -SYSTEMD_DIR="/etc/systemd/system" -SERVICE_NAME="spendhound-monthly-reports.service" -TIMER_NAME="spendhound-monthly-reports.timer" -OVERRIDE_DIR="${SYSTEMD_DIR}/${SERVICE_NAME}.d" -OVERRIDE_FILE="${OVERRIDE_DIR}/override.conf" -SERVICE_DEST="${SYSTEMD_DIR}/${SERVICE_NAME}" -TIMER_DEST="${SYSTEMD_DIR}/${TIMER_NAME}" - -log() { - printf '[%s] %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" -} - -fail() { - log "ERROR: $*" - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" -} - -require_root() { - [[ "${EUID}" -eq 0 ]] || fail "Run this installer as root (for example: sudo bash ./deploy/reports/install-spendhound-monthly-reports.sh)" -} - -write_override() { - local tmp_file - tmp_file="$(mktemp)" - trap 'rm -f -- "${tmp_file}"' RETURN - - cat >"${tmp_file}" </dev/null 2>&1; then - fail "systemd does not appear to be available on this host" -fi - -[[ -f "${RUNNER_SOURCE}" ]] || fail "Runner script not found at ${RUNNER_SOURCE}" -[[ -f "${SERVICE_SOURCE}" ]] || fail "Service unit not found at ${SERVICE_SOURCE}" -[[ -f "${TIMER_SOURCE}" ]] || fail "Timer unit not found at ${TIMER_SOURCE}" -[[ -f "${COMPOSE_FILE}" ]] || fail "Compose file not found at ${COMPOSE_FILE}" - -log "Installing SpendHound monthly reports systemd units" - -install -d -m 0755 "${SYSTEMD_DIR}" -chmod 0755 "${RUNNER_SOURCE}" - -install -m 0644 "${SERVICE_SOURCE}" "${SERVICE_DEST}" -install -m 0644 "${TIMER_SOURCE}" "${TIMER_DEST}" -write_override - -systemctl daemon-reload -systemctl enable "${TIMER_NAME}" >/dev/null - -if systemctl is-active --quiet "${TIMER_NAME}"; then - systemctl restart "${TIMER_NAME}" -else - systemctl start "${TIMER_NAME}" -fi - -log "Monthly reports installer completed successfully" -printf '\n' -printf 'Installed unit files:\n' -printf ' - %s\n' "${SERVICE_DEST}" -printf ' - %s\n' "${TIMER_DEST}" -printf 'Installed override:\n' -printf ' - %s\n' "${OVERRIDE_FILE}" -printf '\n' -printf 'Verification commands:\n' -printf ' - systemctl status %s --no-pager\n' "${TIMER_NAME}" -printf ' - systemctl cat %s\n' "${SERVICE_NAME}" -printf ' - systemctl list-timers %s\n' "${TIMER_NAME}" -printf ' - journalctl -u %s -n 50 --no-pager\n' "${SERVICE_NAME}" -printf ' - systemctl start %s\n' "${SERVICE_NAME}" diff --git a/deploy/reports/spendhound-monthly-reports.service b/deploy/reports/spendhound-monthly-reports.service deleted file mode 100644 index 45d9d21..0000000 --- a/deploy/reports/spendhound-monthly-reports.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=SpendHound monthly reports job -Documentation=https://github.com/sumdher/spendhound -Wants=docker.service -After=docker.service - -[Service] -Type=oneshot -User=root -Group=root -WorkingDirectory=/opt/spendhound -ExecStart=/usr/bin/env bash /opt/spendhound/deploy/reports/spendhound-monthly-reports.sh - -# Optional overrides for non-default deployments can be added here or via a drop-in: -# Environment=SPENDHOUND_REPORTS_COMPOSE_FILE=/opt/spendhound/docker-compose.prod.yml -# Environment=SPENDHOUND_REPORTS_PROJECT_DIR=/opt/spendhound -# Environment=SPENDHOUND_REPORTS_SERVICE_NAME=backend diff --git a/deploy/reports/spendhound-monthly-reports.sh b/deploy/reports/spendhound-monthly-reports.sh deleted file mode 100644 index ac4187a..0000000 --- a/deploy/reports/spendhound-monthly-reports.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" - -COMPOSE_FILE="${SPENDHOUND_REPORTS_COMPOSE_FILE:-${REPO_ROOT}/docker-compose.prod.yml}" -COMPOSE_PROJECT_DIR="${SPENDHOUND_REPORTS_PROJECT_DIR:-${REPO_ROOT}}" -SERVICE_NAME="${SPENDHOUND_REPORTS_SERVICE_NAME:-backend}" -LOCK_FILE="${SPENDHOUND_REPORTS_LOCK_FILE:-/var/lock/spendhound-monthly-reports.lock}" - -log() { - printf '%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*" -} - -fail() { - log "ERROR: $*" - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" -} - -require_command docker -require_command flock -require_command install - -[[ -f "${COMPOSE_FILE}" ]] || fail "Compose file not found at ${COMPOSE_FILE}" - -install -d -m 0700 "$(dirname -- "${LOCK_FILE}")" - -exec 9>"${LOCK_FILE}" -if ! flock -n 9; then - fail "Another monthly report job is already running" -fi - -DOCKER_COMPOSE=(docker compose --project-directory "${COMPOSE_PROJECT_DIR}" -f "${COMPOSE_FILE}") - -BACKEND_CONTAINER_ID="$(${DOCKER_COMPOSE[@]} ps -q "${SERVICE_NAME}" 2>/dev/null || true)" -[[ -n "${BACKEND_CONTAINER_ID}" ]] || fail "Backend service '${SERVICE_NAME}' is not running" - -BACKEND_RUNNING="$(docker inspect -f '{{.State.Running}}' "${BACKEND_CONTAINER_ID}" 2>/dev/null || true)" -[[ "${BACKEND_RUNNING}" == "true" ]] || fail "Backend container '${BACKEND_CONTAINER_ID}' is not running" - -BACKEND_HEALTH="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "${BACKEND_CONTAINER_ID}" 2>/dev/null || true)" -if [[ "${BACKEND_HEALTH}" != "healthy" && "${BACKEND_HEALTH}" != "none" ]]; then - fail "Backend container health is '${BACKEND_HEALTH}', refusing job execution" -fi - -log "Starting SpendHound monthly reports job via backend service '${SERVICE_NAME}'" -"${DOCKER_COMPOSE[@]}" exec -T "${SERVICE_NAME}" python -m app.jobs.monthly_reports -log "Monthly reports job finished" diff --git a/deploy/reports/spendhound-monthly-reports.timer b/deploy/reports/spendhound-monthly-reports.timer deleted file mode 100644 index cd44520..0000000 --- a/deploy/reports/spendhound-monthly-reports.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Run SpendHound monthly reports on the first day of each month - -[Timer] -OnCalendar=*-*-01 00:20:00 -RandomizedDelaySec=20m -Persistent=true -Unit=spendhound-monthly-reports.service - -[Install] -WantedBy=timers.target From 4cadd2853685be0de2cc6bf1b30bdd734d2850db Mon Sep 17 00:00:00 2001 From: sudheer Date: Thu, 25 Jun 2026 02:01:32 +0200 Subject: [PATCH 2/2] feat: add OTel tracing, WAL-G backups, PgBouncer, Alertmanager, and Loki OpenTelemetry + Grafana Tempo: auto-instrument FastAPI, SQLAlchemy, HTTPX, Redis, and Celery; manual llm.complete spans in MeteredLLMProvider; separate service names (spendhound-api / spendhound-worker) visible in Tempo service map. WAL-G continuous Postgres backup to Cloudflare R2: archive_command streams WAL segments on every write, archive_timeout=60 caps max data loss to 60s, daily base backup sidecar (supercronic) at 02:00 UTC, 7-day retention, PITR-capable. PgBouncer connection pooler in session mode (scram-sha-256) between app and Postgres. Session mode required for asyncpg compatibility - transaction mode causes DISCARD ALL to reset session-level settings intermittently. Alertmanager with Slack webhook: BackendDown (critical) and HighErrorRate (warning) rules; inhibit_rule suppresses HighErrorRate when BackendDown fires. Loki + Promtail: structured log aggregation from Docker socket; trace IDs in logs become clickable links to Tempo traces. GDPR privacy page (Article 17 data erasure, data residency disclosure). Fix: Prometheus and Alertmanager write credential files to /tmp/ not /run/. Both images (prom/prometheus, prom/alertmanager) run as nobody; /run/ is root-owned and not writable. /tmp/ is world-writable (mode 1777). Fix: WAL-G backup sidecar uses absolute path /usr/local/bin/supercronic. Docker OCI exec resolves bare command names differently from shell which. --- .gitignore | 4 + alertmanager/alertmanager.yml | 40 ++++ backend/app/celery_app.py | 22 ++ backend/app/config.py | 16 ++ backend/app/main.py | 48 ++++ backend/app/services/metrics.py | 24 +- backend/app/services/tracing.py | 73 ++++++ backend/pyproject.toml | 9 + docker-compose.prod.yml | 233 +++++++++++++++++++- docker-compose.yml | 101 ++++++++- frontend/src/app/privacy/page.tsx | 9 + grafana/loki-queries.md | 137 ++++++++++++ grafana/provisioning/datasources/loki.yaml | 11 + grafana/provisioning/datasources/tempo.yaml | 25 +++ loki/loki.yml | 40 ++++ pgbouncer/pgbouncer.ini | 43 ++++ postgres/Dockerfile | 29 +++ postgres/backup-cron | 13 ++ prometheus.prod.yml | 11 +- prometheus.yml | 9 + prometheus/alert_rules.yml | 58 +++++ promtail/promtail.yml | 28 +++ tempo/tempo.yml | 32 +++ 23 files changed, 998 insertions(+), 17 deletions(-) create mode 100644 alertmanager/alertmanager.yml create mode 100644 backend/app/services/tracing.py create mode 100644 grafana/loki-queries.md create mode 100644 grafana/provisioning/datasources/loki.yaml create mode 100644 grafana/provisioning/datasources/tempo.yaml create mode 100644 loki/loki.yml create mode 100644 pgbouncer/pgbouncer.ini create mode 100644 postgres/Dockerfile create mode 100644 postgres/backup-cron create mode 100644 prometheus/alert_rules.yml create mode 100644 promtail/promtail.yml create mode 100644 tempo/tempo.yml diff --git a/.gitignore b/.gitignore index 830a07f..1cbf25e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ plans .claude* CLAUDE.md temp* +!tempo +!tempo/ +!**/tempo.yaml +!**/tempo.yml .env* .infisical.json .task* diff --git a/alertmanager/alertmanager.yml b/alertmanager/alertmanager.yml new file mode 100644 index 0000000..5336bcb --- /dev/null +++ b/alertmanager/alertmanager.yml @@ -0,0 +1,40 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'severity'] + # Wait 30s after first alert fires before sending - collects related alerts into one message. + group_wait: 30s + # After the initial message, wait 5m before sending an update if new alerts join the group. + group_interval: 5m + # If an alert is still firing, re-notify every 4h so it doesn't silently stay broken. + repeat_interval: 4h + receiver: slack-alerts + +receivers: + - name: slack-alerts + slack_configs: + # Webhook URL is written to this file by the container entrypoint from $SLACK_WEBHOOK_URL. + # Same pattern as Prometheus reading the metrics bearer token from /run/metrics_token. + - api_url_file: /tmp/slack_webhook_url + channel: '#spendhound-alerts' + username: SpendHound + send_resolved: true + color: '{{ if eq .CommonLabels.severity "critical" }}danger{{ else }}warning{{ end }}' + title: '{{ if eq .Status "firing" }}[FIRING]{{ else }}[RESOLVED]{{ end }} {{ .CommonLabels.alertname }}' + text: | + {{ range .Alerts -}} + *{{ .Annotations.summary }}* + Severity: {{ .Labels.severity }} + {{ .Annotations.description }} + {{ if eq .Status "firing" }}Since: {{ .StartsAt.Format "2006-01-02 15:04 UTC" }}{{ else }}Resolved: {{ .EndsAt.Format "2006-01-02 15:04 UTC" }}{{ end }} + {{ end }} + +# Suppress downstream alerts when the root cause is already firing. +# If BackendDown fires, HighErrorRate is a symptom - no need for two messages. +inhibit_rules: + - source_match: + alertname: BackendDown + target_match: + alertname: HighErrorRate + equal: [] diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index ea5c449..15f8845 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -17,9 +17,31 @@ from celery import Celery from celery.schedules import crontab +from celery.signals import worker_init from app.config import settings +if settings.sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.celery import CeleryIntegration + + sentry_sdk.init( + dsn=settings.sentry_dsn, + environment=settings.sentry_environment, + traces_sample_rate=0.0, + send_default_pii=False, + integrations=[CeleryIntegration()], + ) + + +@worker_init.connect +def _on_worker_init(**kwargs: object) -> None: + if settings.otel_endpoint: + from app.services.tracing import setup_tracing + + setup_tracing(settings.otel_service_name, settings.otel_endpoint) + + celery_app = Celery( "spendhound", broker=settings.redis_url, diff --git a/backend/app/config.py b/backend/app/config.py index 86b03d1..be010b6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -152,6 +152,22 @@ class Settings(BaseSettings): default="", description="Bearer token required to scrape /metrics. Empty = endpoint blocked. Set in Infisical for prod.", ) + sentry_dsn: str = Field( + default="", + description="Sentry DSN for error tracking. Empty = Sentry disabled. Set in Infisical for prod.", + ) + sentry_environment: str = Field( + default="production", + description="Sentry environment tag sent with every event (production / development).", + ) + otel_endpoint: str = Field( + default="", + description="OTLP HTTP base URL for OpenTelemetry traces (e.g. http://tempo:4318). Empty = tracing disabled.", + ) + otel_service_name: str = Field( + default="spendhound", + description="OTel service name shown in Grafana Tempo. Set per container: spendhound-api, spendhound-worker.", + ) debug: bool = Field(default=False, description="Enable debug mode") cors_origins: list[str] = Field( diff --git a/backend/app/main.py b/backend/app/main.py index 984f96b..a65cc8e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,28 @@ from app.services.cache import close_redis, get_celery_queue_depth, init_redis from app.services.metrics import RATE_LIMIT_HITS_TOTAL, RECEIPT_QUEUE_DEPTH, classify_limit_type +if settings.sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.fastapi import FastApiIntegration + from sentry_sdk.integrations.starlette import StarletteIntegration + + sentry_sdk.init( + dsn=settings.sentry_dsn, + environment=settings.sentry_environment, + traces_sample_rate=0.0, + send_default_pii=False, + integrations=[StarletteIntegration(), FastApiIntegration()], + ) + +if settings.otel_endpoint: + from app.services.tracing import setup_tracing + + setup_tracing(settings.otel_service_name, settings.otel_endpoint) + # FastAPI instrumentation is deferred to create_app() via instrument_fastapi() + # because the FastAPI app must exist before middleware can be attached. + # Calling FastAPIInstrumentor().instrument() here (before app creation) patches + # FastAPI.__init__ globally and conflicts with the Prometheus Instrumentator. + logger = structlog.get_logger(__name__) @@ -85,6 +107,11 @@ async def _rate_limit_handler(request: Request, exc: Exception) -> Response: app.add_exception_handler(RateLimitExceeded, _rate_limit_handler) + if settings.otel_endpoint: + from app.services.tracing import instrument_fastapi + + instrument_fastapi(app) + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, @@ -93,6 +120,27 @@ async def _rate_limit_handler(request: Request, exc: Exception) -> Response: allow_headers=["*"], ) + if settings.sentry_dsn: + + @app.middleware("http") + async def _attach_sentry_user(request: Request, call_next): # type: ignore[no-untyped-def] + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + try: + from jose import jwt as _jwt + + payload = _jwt.decode( + auth.removeprefix("Bearer "), + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + user_id = payload.get("sub") + if user_id: + sentry_sdk.set_user({"id": user_id}) + except Exception: + pass + return await call_next(request) + # Starlette 0.40+ adds _IncludedRouter objects to app.routes when include_router() # is used. prometheus-fastapi-instrumentator unconditionally accesses route.path on # every route, but _IncludedRouter has no .path → AttributeError on every request. diff --git a/backend/app/services/metrics.py b/backend/app/services/metrics.py index 9939312..88b863d 100644 --- a/backend/app/services/metrics.py +++ b/backend/app/services/metrics.py @@ -17,6 +17,7 @@ import time from collections.abc import AsyncGenerator +from opentelemetry import trace as _otel_trace from prometheus_client import Counter, Gauge, Histogram from app.services.llm.base import BaseLLMProvider, LLMConfig, Message @@ -58,13 +59,22 @@ def __init__(self, inner: BaseLLMProvider, provider_name: str) -> None: self._provider_name = provider_name async def complete(self, messages: list[Message], config: LLMConfig | None = None) -> str: - start = time.monotonic() - try: - return await self._inner.complete(messages, config) - finally: - LLM_RESPONSE_SECONDS.labels(provider=self._provider_name).observe( - time.monotonic() - start - ) + tracer = _otel_trace.get_tracer(__name__) + with tracer.start_as_current_span( + "llm.complete", + attributes={"gen_ai.system": self._provider_name}, + ) as span: + start = time.monotonic() + try: + return await self._inner.complete(messages, config) + except Exception as exc: + span.record_exception(exc) + span.set_status(_otel_trace.Status(_otel_trace.StatusCode.ERROR)) + raise + finally: + LLM_RESPONSE_SECONDS.labels(provider=self._provider_name).observe( + time.monotonic() - start + ) async def stream( self, messages: list[Message], config: LLMConfig | None = None diff --git a/backend/app/services/tracing.py b/backend/app/services/tracing.py new file mode 100644 index 0000000..0a7efcc --- /dev/null +++ b/backend/app/services/tracing.py @@ -0,0 +1,73 @@ +"""OpenTelemetry tracing setup for SpendHound. + +Call setup_tracing() once at process startup. No-op when otel_endpoint is empty. +Idempotent - safe to call multiple times; only the first call takes effect. + +Instrumented libraries (auto): + FastAPI/Starlette - HTTP request spans with route, method, status + SQLAlchemy - every DB query as a child span + HTTPX - outbound HTTP calls (LLM provider APIs, Puppeteer) + Redis - cache get/set/delete spans + Celery - task publish (in API process) + task execute (in worker) + +Manual spans are added in MeteredLLMProvider.complete() with provider attributes. +""" + +from __future__ import annotations + +import structlog + +logger = structlog.get_logger(__name__) + +_initialized = False + + +def setup_tracing(service_name: str, endpoint: str) -> None: + """Configure the global TracerProvider and auto-instrument common libraries. + + service_name: label in Grafana Tempo service map (e.g. spendhound-api) + endpoint: OTLP HTTP base URL for Grafana Tempo (e.g. http://tempo:4318) + """ + global _initialized + if _initialized or not endpoint: + return + _initialized = True + + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + from opentelemetry.instrumentation.celery import CeleryInstrumentor + from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor + from opentelemetry.instrumentation.redis import RedisInstrumentor + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor + from opentelemetry.sdk.resources import SERVICE_NAME, Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource(attributes={SERVICE_NAME: service_name}) + provider = TracerProvider(resource=resource) + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces")) + ) + trace.set_tracer_provider(provider) + + SQLAlchemyInstrumentor().instrument() + HTTPXClientInstrumentor().instrument() + RedisInstrumentor().instrument() + CeleryInstrumentor().instrument() + + logger.info("tracing.initialized", service=service_name, endpoint=endpoint) + + +def instrument_fastapi(app: object) -> None: + """Add OTel HTTP tracing middleware to an already-created FastAPI app. + + Must be called after setup_tracing() and after the FastAPI app instance + exists. Calling FastAPIInstrumentor().instrument() without an app patches + FastAPI.__init__ globally and conflicts with the Prometheus Instrumentator + middleware added later in create_app(), producing an unpredictable + middleware stack that causes intermittent request failures. + """ + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + + FastAPIInstrumentor.instrument_app(app) # type: ignore[arg-type] + logger.info("tracing.fastapi_instrumented") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bb8c63b..9da70ce 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -45,6 +45,15 @@ dependencies = [ "celery[redis]>=5.3", "prometheus-client>=0.20.0", "prometheus-fastapi-instrumentator==7.1.0", + "sentry-sdk[fastapi]>=2.0.0", + # OpenTelemetry - distributed tracing to Grafana Tempo + "opentelemetry-sdk>=1.24.0", + "opentelemetry-exporter-otlp-proto-http>=1.24.0", + "opentelemetry-instrumentation-fastapi>=0.45b0", + "opentelemetry-instrumentation-sqlalchemy>=0.45b0", + "opentelemetry-instrumentation-httpx>=0.45b0", + "opentelemetry-instrumentation-redis>=0.45b0", + "opentelemetry-instrumentation-celery>=0.45b0", ] [project.optional-dependencies] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8fdcb8c..26df8c3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,14 +3,39 @@ services: db: - # Pin by digest so a tag re-push cannot silently swap the image. - # To update: docker pull pgvector/pgvector:pg16 && docker inspect --format='{{index .RepoDigests 0}}' pgvector/pgvector:pg16 - image: pgvector/pgvector@sha256:7d400e340efb42f4d8c9c12c6427adb253f726881a9985d2a471bf0eed824dff + # Custom image: pgvector:pg16 + WAL-G binary (see postgres/Dockerfile). + # The base image digest is pinned inside the Dockerfile FROM line. + build: + context: ./postgres + dockerfile: Dockerfile container_name: spendhound_db_prod + # Enable WAL archiving. archive_mode requires a server restart to toggle, + # so it must be set here (not via ALTER SYSTEM). archive_timeout=60 caps + # WAL data loss at ~60 seconds when the database is idle (no writes fill + # a full 16 MB segment naturally). + command: + - postgres + - -c + - wal_level=replica + - -c + - archive_mode=on + - -c + - archive_command=wal-g wal-push %p + - -c + - archive_timeout=60 environment: POSTGRES_USER: ${POSTGRES_USER:-spendhound} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-spendhound} + # WAL-G credentials - injected by infisical run. + # archive_command inherits the Postgres process environment, so wal-g + # picks these up automatically on each WAL segment upload. + WALG_S3_PREFIX: ${WALG_S3_PREFIX} + AWS_ENDPOINT: ${WALG_R2_ENDPOINT} + AWS_ACCESS_KEY_ID: ${WALG_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${WALG_SECRET_ACCESS_KEY} + AWS_REGION: auto + WALG_S3_USE_PATH_STYLE: "true" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -27,6 +52,47 @@ services: reservations: memory: 256M + wal-g-backup: + # Shares the same image as db (WAL-G + supercronic already installed). + # Runs the daily base backup cron. WAL segment archiving is handled by + # Postgres itself via archive_command - this sidecar only does base backups. + # On FIRST DEPLOYMENT run a manual base backup before this schedule fires: + # docker exec spendhound_walg_backup_prod wal-g backup-push "$PGDATA" + build: + context: ./postgres + dockerfile: Dockerfile + container_name: spendhound_walg_backup_prod + entrypoint: [] + command: [/usr/local/bin/supercronic, /etc/backup-cron] + # Run as the postgres user (UID 999) so it can read the data directory. + user: "999:999" + environment: + PGDATA: /var/lib/postgresql/data + PGHOST: db + PGPORT: "5432" + PGUSER: ${POSTGRES_USER:-spendhound} + PGPASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB:-spendhound} + WALG_S3_PREFIX: ${WALG_S3_PREFIX} + AWS_ENDPOINT: ${WALG_R2_ENDPOINT} + AWS_ACCESS_KEY_ID: ${WALG_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${WALG_SECRET_ACCESS_KEY} + AWS_REGION: auto + WALG_S3_USE_PATH_STYLE: "true" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/backup-cron:/etc/backup-cron:ro + depends_on: + db: + condition: service_healthy + security_opt: + - no-new-privileges:true + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + redis: image: redis:7-alpine container_name: spendhound_redis_prod @@ -49,6 +115,39 @@ services: reservations: memory: 64M + pgbouncer: + image: edoburu/pgbouncer:v1.23.1-p3 + container_name: spendhound_pgbouncer_prod + # Write userlist.txt at startup from env vars - same pattern as Prometheus + # writing the metrics token to /run/metrics_token. + entrypoint: + - /bin/sh + - -c + - | + printf '"%s" "%s"\n' "$$POSTGRES_USER" "$$POSTGRES_PASSWORD" > /tmp/pgbouncer_userlist.txt + exec pgbouncer /etc/pgbouncer/pgbouncer.ini + environment: + POSTGRES_USER: ${POSTGRES_USER:-spendhound} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 5432"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + security_opt: + - no-new-privileges:true + restart: unless-stopped + deploy: + resources: + limits: + memory: 64M + backend: build: context: ./backend @@ -59,7 +158,7 @@ services: - path: ./backend/.env # non-secret config only — optional, all secrets via env block required: false environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-spendhound}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-spendhound} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-spendhound}:${POSTGRES_PASSWORD}@pgbouncer:5432/${POSTGRES_DB:-spendhound}?prepared_statement_cache_size=0 REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} # Secrets — injected by `infisical run` via Makefile GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} @@ -74,6 +173,9 @@ services: MONTHLY_REPORTS_FRONTEND_TOKEN: ${MONTHLY_REPORTS_FRONTEND_TOKEN} MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER: ${MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER:-X-SpendHound-Internal-Token} METRICS_TOKEN: ${METRICS_TOKEN} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_ENDPOINT: http://tempo:4318 + OTEL_SERVICE_NAME: spendhound-api volumes: - receipt_storage:/app/storage/receipts command: @@ -88,6 +190,8 @@ services: condition: service_healthy redis: condition: service_healthy + pgbouncer: + condition: service_healthy security_opt: - no-new-privileges:true cap_drop: @@ -118,7 +222,7 @@ services: - path: ./backend/.env required: false environment: - DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-spendhound}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-spendhound} + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-spendhound}:${POSTGRES_PASSWORD}@pgbouncer:5432/${POSTGRES_DB:-spendhound}?prepared_statement_cache_size=0 REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} # Secrets — injected by `infisical run` via Makefile GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} @@ -132,6 +236,9 @@ services: LLM_KEY_ENCRYPTION_SECRET: ${LLM_KEY_ENCRYPTION_SECRET} MONTHLY_REPORTS_FRONTEND_TOKEN: ${MONTHLY_REPORTS_FRONTEND_TOKEN} MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER: ${MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER:-X-SpendHound-Internal-Token} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_ENDPOINT: http://tempo:4318 + OTEL_SERVICE_NAME: spendhound-worker command: celery -A app.celery_app worker --loglevel=info --concurrency=1 volumes: - receipt_storage:/app/storage/receipts @@ -140,6 +247,8 @@ services: condition: service_healthy redis: condition: service_healthy + pgbouncer: + condition: service_healthy security_opt: - no-new-privileges:true cap_drop: @@ -154,6 +263,39 @@ services: extra_hosts: - "host.docker.internal:host-gateway" + celery_beat: + build: + context: ./backend + dockerfile: Dockerfile + target: production + container_name: spendhound_celery_beat_prod + env_file: + - path: ./backend/.env + required: false + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-spendhound}:${POSTGRES_PASSWORD}@pgbouncer:5432/${POSTGRES_DB:-spendhound}?prepared_statement_cache_size=0 + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + JWT_SECRET: ${JWT_SECRET} + LLM_KEY_ENCRYPTION_SECRET: ${LLM_KEY_ENCRYPTION_SECRET} + SENTRY_DSN: ${SENTRY_DSN:-} + command: celery -A app.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + deploy: + resources: + limits: + memory: 256M + prometheus: image: prom/prometheus:v2.53.0 container_name: spendhound_prometheus_prod @@ -163,7 +305,7 @@ services: - /bin/sh - -c - | - printf '%s' "$$METRICS_TOKEN" > /run/metrics_token + printf '%s' "$$METRICS_TOKEN" > /tmp/metrics_token exec /bin/prometheus \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus \ @@ -174,6 +316,7 @@ services: - "127.0.0.1:9090:9090" # SSH tunnel: ssh -L 9090:localhost:9090 user@server volumes: - ./prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro - prometheus_data:/prometheus depends_on: backend: @@ -209,6 +352,80 @@ services: limits: memory: 256M + tempo: + image: grafana/tempo:2.6.0 + container_name: spendhound_tempo_prod + command: -config.file=/etc/tempo/tempo.yml + volumes: + - ./tempo/tempo.yml:/etc/tempo/tempo.yml:ro + - tempo_data:/var/tempo + security_opt: + - no-new-privileges:true + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + + loki: + image: grafana/loki:3.1.0 + container_name: spendhound_loki_prod + command: -config.file=/etc/loki/loki.yml + volumes: + - ./loki/loki.yml:/etc/loki/loki.yml:ro + - loki_data:/loki + security_opt: + - no-new-privileges:true + restart: unless-stopped + deploy: + resources: + limits: + memory: 512M + + promtail: + image: grafana/promtail:3.1.0 + container_name: spendhound_promtail_prod + command: -config.file=/etc/promtail/promtail.yml + volumes: + - ./promtail/promtail.yml:/etc/promtail/promtail.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail_positions:/var/promtail + depends_on: + - loki + restart: unless-stopped + + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: spendhound_alertmanager_prod + # Write SLACK_WEBHOOK_URL to a file; alertmanager.yml reads it via api_url_file. + # Same pattern as Prometheus reading the metrics token from /run/metrics_token. + entrypoint: + - /bin/sh + - -c + - | + printf '%s' "$$SLACK_WEBHOOK_URL" > /tmp/slack_webhook_url + exec /bin/alertmanager \ + --config.file=/etc/alertmanager/alertmanager.yml \ + --storage.path=/alertmanager \ + --web.listen-address=0.0.0.0:9093 + environment: + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL} + ports: + - "127.0.0.1:9095:9093" # SSH tunnel: ssh -L 9095:localhost:9095 user@server + volumes: + - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_data:/alertmanager + depends_on: + - prometheus + security_opt: + - no-new-privileges:true + restart: unless-stopped + deploy: + resources: + limits: + memory: 128M + frontend: build: context: ./frontend @@ -273,4 +490,8 @@ volumes: postgres_data: receipt_storage: prometheus_data: + alertmanager_data: + loki_data: + promtail_positions: + tempo_data: grafana_data: diff --git a/docker-compose.yml b/docker-compose.yml index 6584225..db8c6ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,31 @@ services: retries: 5 restart: unless-stopped + pgbouncer: + image: edoburu/pgbouncer:v1.23.1-p3 + container_name: spendhound_pgbouncer + entrypoint: + - /bin/sh + - -c + - | + printf '"%s" "%s"\n' "$$POSTGRES_USER" "$$POSTGRES_PASSWORD" > /tmp/pgbouncer_userlist.txt + exec pgbouncer /etc/pgbouncer/pgbouncer.ini + environment: + POSTGRES_USER: spendhound + POSTGRES_PASSWORD: localdev + volumes: + - ./pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 5432"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 5s + restart: unless-stopped + backend: build: context: ./backend @@ -41,7 +66,7 @@ services: - path: ./backend/.env # non-secret config only — optional, all secrets via env block required: false environment: - DATABASE_URL: postgresql+asyncpg://spendhound:localdev@db:5432/spendhound + DATABASE_URL: postgresql+asyncpg://spendhound:localdev@pgbouncer:5432/spendhound?prepared_statement_cache_size=0 REDIS_URL: redis://redis:6379/0 # Secrets — injected by `infisical run` via Makefile GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} @@ -56,6 +81,9 @@ services: MONTHLY_REPORTS_FRONTEND_TOKEN: ${MONTHLY_REPORTS_FRONTEND_TOKEN} MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER: ${MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER:-X-SpendHound-Internal-Token} METRICS_TOKEN: dev-metrics-token + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_ENDPOINT: http://tempo:4318 + OTEL_SERVICE_NAME: spendhound-api ports: - "8000:8000" volumes: @@ -69,6 +97,8 @@ services: condition: service_healthy redis: condition: service_healthy + pgbouncer: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s @@ -88,7 +118,7 @@ services: - path: ./backend/.env required: false environment: - DATABASE_URL: postgresql+asyncpg://spendhound:localdev@db:5432/spendhound + DATABASE_URL: postgresql+asyncpg://spendhound:localdev@pgbouncer:5432/spendhound?prepared_statement_cache_size=0 REDIS_URL: redis://redis:6379/0 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} @@ -101,6 +131,9 @@ services: LLM_KEY_ENCRYPTION_SECRET: ${LLM_KEY_ENCRYPTION_SECRET} MONTHLY_REPORTS_FRONTEND_TOKEN: ${MONTHLY_REPORTS_FRONTEND_TOKEN} MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER: ${MONTHLY_REPORTS_FRONTEND_TOKEN_HEADER:-X-SpendHound-Internal-Token} + SENTRY_DSN: ${SENTRY_DSN:-} + OTEL_ENDPOINT: http://tempo:4318 + OTEL_SERVICE_NAME: spendhound-worker command: celery -A app.celery_app worker --loglevel=info --concurrency=1 volumes: - ./backend:/app @@ -110,6 +143,8 @@ services: condition: service_healthy redis: condition: service_healthy + pgbouncer: + condition: service_healthy restart: unless-stopped extra_hosts: - "host.docker.internal:host-gateway" @@ -124,7 +159,7 @@ services: - path: ./backend/.env required: false environment: - DATABASE_URL: postgresql+asyncpg://spendhound:localdev@db:5432/spendhound + DATABASE_URL: postgresql+asyncpg://spendhound:localdev@pgbouncer:5432/spendhound?prepared_statement_cache_size=0 REDIS_URL: redis://redis:6379/0 GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} @@ -132,6 +167,7 @@ services: ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} LLM_KEY_ENCRYPTION_SECRET: ${LLM_KEY_ENCRYPTION_SECRET} + SENTRY_DSN: ${SENTRY_DSN:-} command: celery -A app.celery_app beat --loglevel=info --schedule=/tmp/celerybeat-schedule volumes: - ./backend:/app @@ -155,11 +191,35 @@ services: - "127.0.0.1:9090:9090" volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro - prometheus_data:/prometheus depends_on: - backend restart: unless-stopped + alertmanager: + image: prom/alertmanager:v0.27.0 + container_name: spendhound_alertmanager + entrypoint: + - /bin/sh + - -c + - | + printf '%s' "$$SLACK_WEBHOOK_URL" > /tmp/slack_webhook_url + exec /bin/alertmanager \ + --config.file=/etc/alertmanager/alertmanager.yml \ + --storage.path=/alertmanager \ + --web.listen-address=0.0.0.0:9093 + environment: + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + ports: + - "127.0.0.1:9095:9093" + volumes: + - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_data:/alertmanager + depends_on: + - prometheus + restart: unless-stopped + grafana: image: grafana/grafana:11.2.0 container_name: spendhound_grafana @@ -176,6 +236,37 @@ services: - prometheus restart: unless-stopped + loki: + image: grafana/loki:3.1.0 + container_name: spendhound_loki + command: -config.file=/etc/loki/loki.yml + volumes: + - ./loki/loki.yml:/etc/loki/loki.yml:ro + - loki_data:/loki + restart: unless-stopped + + promtail: + image: grafana/promtail:3.1.0 + container_name: spendhound_promtail + command: -config.file=/etc/promtail/promtail.yml + volumes: + - ./promtail/promtail.yml:/etc/promtail/promtail.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - promtail_positions:/var/promtail + depends_on: + - loki + restart: unless-stopped + + tempo: + image: grafana/tempo:2.6.0 + container_name: spendhound_tempo + command: -config.file=/etc/tempo/tempo.yml + volumes: + - ./tempo/tempo.yml:/etc/tempo/tempo.yml:ro + - tempo_data:/var/tempo + restart: unless-stopped + flower: image: mher/flower:2.0 container_name: spendhound_flower @@ -220,3 +311,7 @@ volumes: postgres_data: prometheus_data: grafana_data: + alertmanager_data: + loki_data: + promtail_positions: + tempo_data: diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx index 8178ceb..46b821c 100644 --- a/frontend/src/app/privacy/page.tsx +++ b/frontend/src/app/privacy/page.tsx @@ -126,6 +126,15 @@ export default function PrivacyPolicyPage() { delivery. Governed by Resend's Privacy Policy.

+
+

Sentry — Error monitoring

+

+ We use Sentry to capture application errors and exceptions. Error reports contain + stack traces, request URLs, and an anonymous user identifier (a random ID, never + your email or financial data). Data is processed on Sentry's EU infrastructure + (ingest.de.sentry.io). Governed by Sentry's Privacy Policy. +

+

LLM Providers — Receipt & statement parsing

diff --git a/grafana/loki-queries.md b/grafana/loki-queries.md new file mode 100644 index 0000000..7bcf3c0 --- /dev/null +++ b/grafana/loki-queries.md @@ -0,0 +1,137 @@ +# Loki / LogQL Reference Queries + +Run these in Grafana -> Explore -> Loki datasource. + +## Filter by service + +```logql +{service="backend"} +{service="celery_worker"} +{service="celery_beat"} +{service="frontend"} +``` + +## Errors and exceptions + +```logql +# Any error string across all services +{service=~".+"} |= "error" + +# Python exceptions (traceback) +{service="backend"} |= "Traceback" +{service="celery_worker"} |= "Traceback" + +# Structlog level=error (inline JSON parse - slower but accurate) +{service="backend"} | json | level="error" + +# Celery task failures +{service="celery_worker"} |= "FAILURE" +{service="celery_worker"} |= "exception" +``` + +## Receipt extraction + +```logql +# All receipt task activity +{service="celery_worker"} |= "receipt" + +# Extraction failures +{service="celery_worker"} |= "receipt" |= "error" + +# Confidence below threshold (sent to review) +{service="backend"} |= "needs_review" +``` + +## LLM / AI calls + +```logql +# All LLM provider calls +{service="backend"} |= "llm" + +# LLM errors or timeouts +{service="backend"} |= "llm" |= "error" +{service="backend"} |= "LLMTimeout" +``` + +## Auth and security + +```logql +# Failed logins / auth errors +{service="backend"} |= "auth" |= "error" + +# Rate limit hits +{service="backend"} |= "rate_limit" + +# New user signups +{service="backend"} |= "user.created" +``` + +## Monthly reports + +```logql +# All report task activity +{service="celery_worker"} |= "report_task" + +# Successful deliveries +{service="celery_worker"} |= "report_task.sent" + +# Failures and retries +{service="celery_worker"} |= "report_task.retry" +{service="celery_worker"} |= "report_task.all_retries_exhausted" +``` + +## Redis / cache + +```logql +# Cache misses and hits +{service="backend"} |= "cache.analytics" + +# Redis connection issues +{service="backend"} |= "cache.redis" +``` + +## Demo user + +```logql +# Demo reset activity +{service="celery_beat"} |= "demo" +{service="celery_worker"} |= "reset_demo" +``` + +## Inline JSON parsing + +structlog outputs JSON lines. Use `| json` to parse at query time and filter on any field: + +```logql +# Filter on any structlog key +{service="backend"} | json | level="warning" +{service="backend"} | json | level="error" + +# Extract and display a specific field +{service="backend"} | json | line_format "{{.event}} - {{.error}}" + +# Filter on nested event name +{service="celery_worker"} | json | event="report_task.sent" +``` + +## Volume / rate queries (for dashboard panels) + +```logql +# Log line rate per service over time +rate({service="backend"}[5m]) + +# Error rate +rate({service="backend"} |= "error" [5m]) + +# Count of receipt task completions in the last hour +count_over_time({service="celery_worker"} |= "extract_receipt" [1h]) +``` + +## Tips + +- `|=` is a case-sensitive substring filter (fast, uses index) +- `|~ "pattern"` is a regex filter (slower) +- `!=` excludes lines containing a string +- `| json` parses each line as JSON and promotes keys to labels - use for structured filtering +- `| line_format "{{.field}}"` reshapes the displayed log line +- In Grafana, set the time range in the top-right before running queries diff --git a/grafana/provisioning/datasources/loki.yaml b/grafana/provisioning/datasources/loki.yaml new file mode 100644 index 0000000..686f169 --- /dev/null +++ b/grafana/provisioning/datasources/loki.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + uid: loki + url: http://loki:3100 + access: proxy + isDefault: false + jsonData: + maxLines: 1000 diff --git a/grafana/provisioning/datasources/tempo.yaml b/grafana/provisioning/datasources/tempo.yaml new file mode 100644 index 0000000..603f230 --- /dev/null +++ b/grafana/provisioning/datasources/tempo.yaml @@ -0,0 +1,25 @@ +apiVersion: 1 + +datasources: + - name: Tempo + type: tempo + uid: tempo + url: http://tempo:3200 + access: proxy + isDefault: false + jsonData: + # "Logs" button on a trace view jumps to Loki with the same time range. + # filterByTraceID requires logs to emit a trace_id field; disabled for now. + tracesToLogsV2: + datasourceUid: loki + spanStartTimeShift: "-1m" + spanEndTimeShift: "1m" + filterByTraceID: false + filterBySpanID: false + # Service dependency graph panel uses Prometheus RED metrics. + serviceMap: + datasourceUid: prometheus + search: + hide: false + nodeGraph: + enabled: true diff --git a/loki/loki.yml b/loki/loki.yml new file mode 100644 index 0000000..012e49e --- /dev/null +++ b/loki/loki.yml @@ -0,0 +1,40 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +# Single-node deployment: all components run in one process. +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h + +limits_config: + # 7-day retention. Compactor enforces this on a background schedule. + retention_period: 168h + +compactor: + working_directory: /loki/retention + retention_enabled: true + # Wait 2h after marking a chunk for deletion before actually removing it. + # Gives time to cancel an accidental config change. + retention_delete_delay: 2h + delete_request_store: filesystem diff --git a/pgbouncer/pgbouncer.ini b/pgbouncer/pgbouncer.ini new file mode 100644 index 0000000..214d275 --- /dev/null +++ b/pgbouncer/pgbouncer.ini @@ -0,0 +1,43 @@ +[databases] +; Map the database name clients connect to -> the actual Postgres host. +; "db" is the Postgres container name on the Docker Compose network. +spendhound = host=db port=5432 dbname=spendhound + +[pgbouncer] +listen_addr = * +listen_port = 5432 +; scram-sha-256 aligns with the Postgres 16 default and avoids md5 +; compatibility issues with newer asyncpg versions. +auth_type = scram-sha-256 +; Written at container startup by the entrypoint from $POSTGRES_USER / $POSTGRES_PASSWORD. +auth_file = /tmp/pgbouncer_userlist.txt + +; Session mode: each client connection keeps the same server connection for +; its entire lifetime. This gives full compatibility with asyncpg - no +; prepared-statement conflicts, no session-state resets between transactions. +; Transaction mode was causing asyncpg to see inconsistent server state +; (DateStyle, TimeZone, codec OID mappings) after DISCARD ALL ran between +; transactions on different server connections, producing intermittent errors. +pool_mode = session +; 30 server connections: enough for the SQLAlchemy pool (20) + Celery (1-2) +; + headroom for overflow connections and background tasks. +default_pool_size = 30 +min_pool_size = 2 +reserve_pool_size = 5 +max_client_conn = 200 + +; Clean up session state when a client disconnects so the server connection +; can be safely re-used by the next client. +server_reset_query = DISCARD ALL + +server_check_query = SELECT 1 +server_check_delay = 30 + +; asyncpg sends these in the connection startup handshake. PgBouncer rejects +; unknown startup parameters without this setting; application_name is also +; sent by asyncpg/SQLAlchemy in some configurations. +ignore_startup_parameters = extra_float_digits,options,application_name + +log_connections = 0 +log_disconnections = 0 +log_pooler_errors = 1 diff --git a/postgres/Dockerfile b/postgres/Dockerfile new file mode 100644 index 0000000..bb00bd4 --- /dev/null +++ b/postgres/Dockerfile @@ -0,0 +1,29 @@ +# Custom Postgres image for SpendHound production. +# Extends pgvector:pg16 with WAL-G (continuous backup to Cloudflare R2) +# and supercronic (daily base backup cron job in the backup sidecar). +# +# amd64 only - WAL-G binaries are architecture-specific. +# Update the WALG_VERSION build arg to upgrade WAL-G. + +FROM pgvector/pgvector:pg16 + +ARG WALG_VERSION=v3.0.3 +ARG SUPERCRONIC_VERSION=v0.2.33 + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl; \ + \ + # WAL-G PostgreSQL variant - download the raw binary directly (no tar needed) + curl -fsSL \ + "https://github.com/wal-g/wal-g/releases/download/${WALG_VERSION}/wal-g-pg-ubuntu-20.04-amd64" \ + -o /usr/local/bin/wal-g; \ + \ + # supercronic: cron runner for the backup sidecar container + curl -fsSL \ + "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-amd64" \ + -o /usr/local/bin/supercronic; \ + \ + chmod +x /usr/local/bin/wal-g /usr/local/bin/supercronic; \ + apt-get purge -y --auto-remove curl; \ + rm -rf /var/lib/apt/lists/* diff --git a/postgres/backup-cron b/postgres/backup-cron new file mode 100644 index 0000000..df85693 --- /dev/null +++ b/postgres/backup-cron @@ -0,0 +1,13 @@ +# WAL-G daily base backup schedule for SpendHound. +# Runs inside the wal-g-backup sidecar container via supercronic. +# +# 02:00 UTC daily: +# 1. Take a full base backup and upload to Cloudflare R2. +# 2. Delete base backups beyond the 7 most recent (and associated WAL). +# +# WAL segments are archived continuously by Postgres archive_command - this +# cron only handles the periodic base backup that WAL segments are anchored to. +# On first deployment, trigger a manual backup before this schedule runs: +# docker exec spendhound_walg_backup_prod wal-g backup-push "$PGDATA" + +0 2 * * * wal-g backup-push "$PGDATA" && wal-g delete retain FULL 7 --confirm diff --git a/prometheus.prod.yml b/prometheus.prod.yml index d41bcaf..79685e9 100644 --- a/prometheus.prod.yml +++ b/prometheus.prod.yml @@ -2,6 +2,15 @@ global: scrape_interval: 15s evaluation_interval: 15s +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +rule_files: + - /etc/prometheus/alert_rules.yml + scrape_configs: - job_name: spendhound_backend metrics_path: /metrics @@ -10,4 +19,4 @@ scrape_configs: - backend:8000 authorization: type: Bearer - credentials_file: /run/metrics_token + credentials_file: /tmp/metrics_token diff --git a/prometheus.yml b/prometheus.yml index 7dec21f..87d791a 100644 --- a/prometheus.yml +++ b/prometheus.yml @@ -2,6 +2,15 @@ global: scrape_interval: 15s evaluation_interval: 15s +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 + +rule_files: + - /etc/prometheus/alert_rules.yml + scrape_configs: - job_name: spendhound_backend metrics_path: /metrics diff --git a/prometheus/alert_rules.yml b/prometheus/alert_rules.yml new file mode 100644 index 0000000..c50c31f --- /dev/null +++ b/prometheus/alert_rules.yml @@ -0,0 +1,58 @@ +groups: + - name: spendhound + rules: + # ── Critical ─────────────────────────────────────────────────────────────── + + - alert: BackendDown + # Prometheus built-in 'up' metric is 0 when the scrape target is unreachable. + expr: up{job="spendhound_backend"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: SpendHound backend is unreachable + description: >- + Prometheus cannot scrape the backend for more than 2 minutes. + The container may be down or the health check is failing. + + - alert: HighErrorRate + # Rate of 5xx responses as a fraction of all responses over 5 minutes. + # NaN (no traffic) is not > 0.05, so this does not fire when idle. + expr: | + ( + rate(http_requests_total{status=~"5.."}[5m]) + / + rate(http_requests_total[5m]) + ) > 0.05 + for: 2m + labels: + severity: critical + annotations: + summary: High HTTP 5xx error rate + description: >- + More than 5% of HTTP requests are returning 5xx errors + over the last 5 minutes. + + # ── Warning ──────────────────────────────────────────────────────────────── + + - alert: CeleryQueueBackedUp + expr: receipt_queue_depth > 5 + for: 5m + labels: + severity: warning + annotations: + summary: Celery receipt queue is backed up + description: >- + {{ $value | humanize }} tasks have been waiting in the Celery queue + for more than 5 minutes. The worker may be overloaded or crashed. + + - alert: LLMLatencyHigh + expr: histogram_quantile(0.95, rate(llm_response_seconds_bucket[5m])) > 30 + for: 5m + labels: + severity: warning + annotations: + summary: LLM p95 latency is high + description: >- + p95 LLM response time is {{ $value | humanizeDuration }}. + The AI provider may be degraded or the model is overloaded. diff --git a/promtail/promtail.yml b/promtail/promtail.yml new file mode 100644 index 0000000..e1bd74c --- /dev/null +++ b/promtail/promtail.yml @@ -0,0 +1,28 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +# Tracks read position in each log file so Promtail resumes after a restart +# without re-sending old logs. +positions: + filename: /var/promtail/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 5s + relabel_configs: + # Strip the leading slash from container names (/spendhound_backend_prod -> spendhound_backend_prod). + - source_labels: [__meta_docker_container_name] + regex: '/?(.*)' + target_label: container + # Expose the compose service name (backend, celery_worker, etc.) as a label. + - source_labels: [__meta_docker_container_label_com_docker_compose_service] + target_label: service + # stdout vs stderr. + - source_labels: [__meta_docker_container_log_stream] + target_label: stream diff --git a/tempo/tempo.yml b/tempo/tempo.yml new file mode 100644 index 0000000..9df4555 --- /dev/null +++ b/tempo/tempo.yml @@ -0,0 +1,32 @@ +# Grafana Tempo - single-binary mode, local filesystem storage. +# Accepts OTLP over gRPC (4317) and HTTP (4318). +# The app uses HTTP; gRPC is available for other senders (e.g. OTel Collector). + +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + +ingester: + # Flush blocks to storage after 5 min so traces appear in search quickly. + max_block_duration: 5m + +storage: + trace: + backend: local + local: + path: /var/tempo/blocks + wal: + path: /var/tempo/wal + +compactor: + compaction: + # Keep traces for 7 days - matches Loki log retention. + block_retention: 168h