Build a Factory Quality Pipeline

Deploy a containerized defect API that sends scheduled Discord alerts.

Introduction

30 Second Summary

Factory defects become more expensive when the warning arrives after the problem has spread. A useful quality signal reaches the factory channel while the condition is still active.

In this project, you will use Docker Compose to turn your existing FastAPI backend plus your pandas analysis project into a three-service quality pipeline. A scheduled worker will send station anomalies through a Discord Incoming Webhook.

What You'll Build

Your demo ends with a Discord alert for STATION-A after the analyst detects three critical defects within a 60-minute window.

By the end of this project, you'll have:

  • A health-gated deployment that waits for PostgreSQL readiness before your API starts.
  • A scheduled analyst that runs immediately. It repeats every five minutes while handled failures leave the scheduling loop active.
  • A factory alert in Discord that names the affected station. The message also shows the observed count, threshold, and analysis window.
  • Secret Mission: Add a per-station cooldown that suppresses repeated alerts while allowing new stations to notify the channel.

Are there any prerequisites?

This project assumes working local FastAPI plus pandas projects on a Linux machine with Docker Engine, Docker Compose, and Visual Studio Code. You also need permission to run containers plus a Discord server where you can manage webhooks.

Before We Start

Before the hands-on work begins, commit to integrating your existing FastAPI defect API with your pandas analysis project as the foundation of this delivery pipeline. When three Critical Open defects occur at one production station within 60 minutes, the shared system groups them into one anomaly for the factory channel so operators can respond quickly.

Prepare the Delivery Workspace

Your existing FastAPI defect API already records quality data. Your pandas analysis project already finds patterns in it.

Container delivery needs fixed file paths. It also needs a predictable place for secrets.

A shared workspace provides that integration contract. You will verify Docker Engine with Docker Compose before preparing the workspace in Visual Studio Code.

In this step, get ready to:
  • Confirm the Docker toolchain is available.
  • Prepare a shared workspace for the existing projects.
  • Secure the Discord webhook outside version control.
Verify Docker and open the workspace

Docker Engine runs your services as containers. Docker Compose coordinates those services from one project definition.

Before you check, do you expect both commands to report ready components? The result shows whether your delivery toolchain is available.

  • Verify Docker Engine and Docker Compose by running these commands:
docker version
docker compose version

What do these commands check?

  • The first command displays Docker client and server information.
  • The second command displays the installed Compose version.

✔️ I see Docker and Compose information

Your delivery toolchain is ready. Both Docker components can respond from the Linux terminal.

ⓧ I only see Docker client information

Docker is installed, but the Engine server is unavailable to your terminal.

  • Start Docker Engine with your Linux distribution's service manager.
  • Follow Docker's Linux post-installation guide if your account cannot access Docker.
  • Run both version checks again after Docker Engine starts.

Still missing server information?

Your Linux service configuration may need attention. Help me diagnose why Docker Engine is unavailable.

ⓧ A command is unavailable

One or both Docker components are missing from your Linux environment.

Docker command still unavailable?

Your terminal may not have picked up the new installation path. Help me make Docker available in my Linux terminal.

The workspace gives both existing projects one deployment root. Its folders separate the API, analyst, database contract, and local secret.

  • Create the workspace skeleton on your Desktop and open it in Visual Studio Code by running these commands:
cd ~/Desktop
mkdir -p factory-quality-pipeline/api/app factory-quality-pipeline/analyst factory-quality-pipeline/db factory-quality-pipeline/secrets
cd factory-quality-pipeline
code .

What do these commands create?

  • The first command places the project on your Desktop.
  • The second command creates the delivery folders in one pass.
  • The third command makes factory-quality-pipeline your current directory.
  • The final command opens that directory as the Visual Studio Code workspace.
  • Confirm the Explorer sidebar shows api, analyst, db, and secrets.

You should see app nested inside api. The workspace now has a visible home for every delivery responsibility.

Visual Studio Code did not open?

The Visual Studio Code command may be missing from your terminal path. Open the app through your Linux application launcher, then use Open Folder to select ~/Desktop/factory-quality-pipeline.

If the folder is also missing, rerun the directory command from your Linux terminal. Help me locate my delivery workspace.

Set the integration contract

The API keeps its working Project 1 behavior. This step gives that code the stable import path and database setting required by the combined repository.

  • Copy your existing Project 1 application files into api/app/ with your Linux file manager.
  • Create an empty api/app/__init__.py with the New File control in the Visual Studio Code Explorer if it is missing.
  • Place the module that exposes your FastAPI object at api/app/main.py.
  • Confirm the FastAPI object in api/app/main.py is named app.
  • Confirm api/app/main.py reads DATABASE_URL for its shared PostgreSQL connection.

Why does the API need this contract?

The later container command imports app.main:app. The file path and object name must match that import string.

The DATABASE_URL setting lets the API connect to the shared database without embedding a machine-specific address in the application.

The local environment file centralizes the values shared by Compose and the analysis worker. The example file documents the contract without relying on hidden local state.

  • Create .env.example in factory-quality-pipeline by pasting this content:
POSTGRES_DB=quality
POSTGRES_USER=quality_app
POSTGRES_PASSWORD=quality_local_password
ANALYSIS_INTERVAL_MINUTES=5
DEFECT_WINDOW_MINUTES=60
DEFECT_THRESHOLD=3
ANOMALY_SEVERITY=Critical

What does this configuration define?

  • The PostgreSQL values define the local demonstration database.
  • The analysis interval sets the future worker cadence to five minutes.
  • The defect window limits analysis to the most recent 60 minutes.
  • The threshold and severity define the Critical defect anomaly.
  • Save .env.example.
  • Confirm .env.example appears at the top level of the Explorer sidebar.
  • Create the local .env copy by running this command in the Linux terminal from earlier:
cp .env.example .env

Why keep two environment files?

The .env.example file documents the required settings. The local .env file supplies those values to Docker Compose.

  • Confirm the Explorer sidebar now shows both .env and .env.example.

The next ignore file keeps local configuration and generated Python files outside version control. It also permits the safe webhook example file.

  • Create .gitignore in factory-quality-pipeline by pasting this content:
.env
secrets/*.txt
!secrets/*.txt.example
**/__pycache__/
*.py[cod]
.venv/
.vscode/

What stays out of version control?

The rules exclude the local environment file and every real text secret. The exception keeps files ending in .txt.example available as safe templates.

Python caches, virtual environments, and editor settings also stay local. Your repository remains focused on delivery code.

  • Save .gitignore.
  • Confirm .gitignore appears beside the two environment files in the Explorer sidebar.

Docker build contexts need their own ignore rules. Matching files keep local caches and editor metadata out of both Python images.

  • Create api/.dockerignore and analyst/.dockerignore with this same content:
__pycache__/
*.py[cod]
.venv/
.env
.git/
.vscode/

Why use Docker ignore files?

Each file limits what Docker sends into its image build context. This prevents local caches and development settings from becoming image contents.

  • Save api/.dockerignore.
  • Save analyst/.dockerignore.
  • Confirm each file appears inside its matching folder in the Explorer sidebar.
Secure the Discord webhook

A Discord Incoming Webhook gives the future worker a channel-specific destination for one-way alerts. Its URL can post messages without a bot user.

The webhook URL is sensitive. Keep its contents out of screenshots while you work.

  • Create secrets/discord_webhook_url.txt.example by pasting this safe placeholder:
your-discord-webhook-url-here

Why create an example secret file?

The example records the expected file name without exposing a working webhook. Anyone using the project can see what belongs in the secrets folder.

  • Save secrets/discord_webhook_url.txt.example.
  • Confirm the example file appears inside secrets.
  • Select your factory alert server in the Discord client or browser.
  • Open Server Settings from the server menu.
  • Select Integrations.
  • Click Create Webhook.
  • Choose the factory alert channel from the channel menu.

What did Discord create?

The webhook belongs to the channel you selected. Requests sent to its URL can deliver the quality alert directly to that channel.

  • Click Copy Webhook URL.
  • Create secrets/discord_webhook_url.txt with the New File control in the Visual Studio Code Explorer.
  • Paste the copied webhook URL as the file's only line.
  • Save secrets/discord_webhook_url.txt.

Keep the URL private

The .gitignore rule excludes the real .txt secret while retaining the example. Close the secret file after saving so its value stays off-screen.

Use this comparison to catch a missing line before the final workspace check.

✔️ Awesome, I've got everything!

Great work. Save every open file before moving to the final verification.

ⓧ I'd like to double check the full code

POSTGRES_DB=quality
POSTGRES_USER=quality_app
POSTGRES_PASSWORD=quality_local_password
ANALYSIS_INTERVAL_MINUTES=5
DEFECT_WINDOW_MINUTES=60
DEFECT_THRESHOLD=3
ANOMALY_SEVERITY=Critical
POSTGRES_DB=quality
POSTGRES_USER=quality_app
POSTGRES_PASSWORD=quality_local_password
ANALYSIS_INTERVAL_MINUTES=5
DEFECT_WINDOW_MINUTES=60
DEFECT_THRESHOLD=3
ANOMALY_SEVERITY=Critical
.env
secrets/*.txt
!secrets/*.txt.example
**/__pycache__/
*.py[cod]
.venv/
.vscode/
__pycache__/
*.py[cod]
.venv/
.env
.git/
.vscode/
__pycache__/
*.py[cod]
.venv/
.env
.git/
.vscode/
your-discord-webhook-url-here

The api/app/__init__.py file should be empty.

Your api/app/main.py file should contain the existing Project 1 application. It should expose app and read DATABASE_URL.

Your uncommitted secrets/discord_webhook_url.txt file should contain the real Discord webhook URL as its only line. Keep that value private.

Before the final check, do you expect the terminal to report both Docker components? The workspace result will confirm whether the integration contract is ready.

  • Verify the Docker toolchain again by running these commands from factory-quality-pipeline.
docker version
docker compose version

What should you see?

The terminal should show Docker client and server information. It should also show Docker Compose version information.

These results prove the local toolchain is ready for the service definitions you add next.

  • Expand api, analyst, db, and secrets in the Visual Studio Code Explorer.

You should see the existing application under api/app/. You should also see both ignore files, both environment files, the empty database folder, and the two webhook files.

That is the workspace contract complete. Docker is ready, the existing API has a stable home, and the webhook remains outside version control.

Your delivery workspace is ready. Next, you will package the API with a health-gated PostgreSQL service.

Package the API and Database

Your workspace now holds the existing FastAPI app plus its delivery folders. The next startup can still fail when the API reaches PostgreSQL before the database accepts connections.

This step packages the API in a Docker image. Docker Compose then uses a PostgreSQL health check to control startup order.

In this step, get ready to:
  • Package the existing API with pinned dependencies.
  • Define the shared defects database schema.
  • Start the database-backed API through a health-gated Compose stack.
Package the API service

The API image needs the same dependency versions every time it builds. A pinned requirements file gives the image that repeatable input.

  • Create api/requirements.txt inside api with this content:
fastapi==0.143.0
uvicorn==0.54.0
SQLAlchemy==2.1.4
psycopg[binary]==3.3.6

What do these dependencies provide?

  • FastAPI 0.143.0 supplies the application framework used by your existing API.
  • Uvicorn 0.54.0 serves the FastAPI object exposed as app.
  • SQLAlchemy 2.1.4 provides the database engine used by the application.
  • Psycopg 3.3.6 connects SQLAlchemy to PostgreSQL.
  • Save api/requirements.txt.
  • Confirm requirements.txt appears under api in the VS Code Explorer sidebar.

Dependency file in the wrong folder?

Check that the Explorer path is api/requirements.txt. A file beside the api folder falls outside the image build context.

Help me place the API requirements file in the correct workspace folder.

✔️ Awesome, I've got everything!

Your pinned API dependency file is saved in the correct folder.

ⓧ I'd like to double check the full code

fastapi==0.143.0
uvicorn==0.54.0
SQLAlchemy==2.1.4
psycopg[binary]==3.3.6

How to use this reference

This is the complete dependency file for this step. Each package uses the tested version required by the API image.

The Dockerfile turns that dependency list plus your existing application into one reproducible container image.

  • Create api/Dockerfile inside api with this content:
FROM python:3.12.15-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]

What does this Dockerfile do?

  • The base image supplies Python 3.12.15 in a slim container.
  • The dependency copy happens before the application copy so Docker can reuse the installation layer when only application code changes.
  • The final command starts Uvicorn with the FastAPI object at app.main:app.
  • Port 8000 becomes the API process port inside the container.
  • Save api/Dockerfile.
  • Confirm Dockerfile appears beside requirements.txt under api in the Explorer sidebar.

Dockerfile saved with an extension?

Check that the filename is exactly Dockerfile. Remove any text extension added by the editor.

Help me verify the API Dockerfile name and location.

✔️ Awesome, I've got everything!

The API image definition now has its runtime plus its launch command.

ⓧ I'd like to double check the full code

FROM python:3.12.15-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "app.main:app", "--host=0.0.0.0", "--port=8000"]

How to use this reference

This is the complete API Dockerfile. Its final command matches the required app.main:app import path.

Define the shared database contract

The API needs a fixed database schema that matches the fields in your existing Project 1 model. This contract lets later analysis code query the same records without translation.

  • Create db/init.sql inside db with this content:
CREATE TABLE IF NOT EXISTS defects (
    id BIGSERIAL PRIMARY KEY,
    production_station TEXT NOT NULL,
    severity TEXT NOT NULL CHECK (severity IN ('Low', 'Medium', 'High', 'Critical')),
    status TEXT NOT NULL CHECK (status IN ('Open', 'Contained', 'Closed')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_defects_quality_window
    ON defects (severity, created_at, production_station);

What does this schema define?

  • The defects table stores the production station plus the defect severity.
  • The table also records defect status plus creation time.
  • The check constraints limit severity plus status to the values used by this project.
  • The idx_defects_quality_window index supports later queries across severity plus time plus station.
  • Save db/init.sql.
  • Select api/app/main.py in the Explorer sidebar.
  • Confirm its database mapping matches the defects contract shown above.

Do the field names differ?

Compare the existing Project 1 model with production_station, severity, status, plus created_at. The application mapping must use those names before the stack starts.

Help me compare my existing FastAPI database model with the shared defects schema.

✔️ Awesome, I've got everything!

Your database initialization file now defines the shared defects contract.

ⓧ I'd like to double check the full code

CREATE TABLE IF NOT EXISTS defects (
    id BIGSERIAL PRIMARY KEY,
    production_station TEXT NOT NULL,
    severity TEXT NOT NULL CHECK (severity IN ('Low', 'Medium', 'High', 'Critical')),
    status TEXT NOT NULL CHECK (status IN ('Open', 'Contained', 'Closed')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_defects_quality_window
    ON defects (severity, created_at, production_station);

How to use this reference

This is the complete initialization file. PostgreSQL runs it when the database volume is initialized for the first time.

Coordinate database readiness and API startup

A running PostgreSQL container can still hold a database that is starting. Compose prevents an early API connection by waiting for the database health check to pass.

  • Create compose.yaml in factory-quality-pipeline with this database configuration:
services:
  db:
    image: postgres:18.6
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-quality-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

volumes:
  postgres_data:

How does the database service work?

  • The service uses the PostgreSQL 18.6 image plus the database values from .env.
  • The postgres_data named volume preserves database files across normal container recreation.
  • The initialization file is mounted into the image initialization directory as read-only.
  • The readiness command checks whether the configured database accepts connections.
  • Save compose.yaml.

Before you validate this first Compose state, do you expect a successful check to print configuration details or return quietly?

  • Validate the database service definition by running:
docker compose config --quiet

What does this command check?

Compose parses the project model plus its environment interpolation. A successful quiet validation returns to the terminal prompt without printing a configuration error.

Good, the database service definition now passes Compose validation.

Seeing a configuration error?

Check the YAML indentation around environment, volumes, plus healthcheck. Also confirm that .env remains beside compose.yaml.

Help me diagnose my Docker Compose validation error.

  • Insert this API service immediately above the top-level volumes: line in compose.yaml.
  api:
    build:
      context: ./api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
        restart: true
    ports:
      - "8000:8000"

How does the API service connect?

  • The build context points Docker at the api folder.
  • The DATABASE_URL value targets the Compose service named db.
  • The healthy dependency condition delays API startup until PostgreSQL accepts connections.
  • The port mapping makes the container API reachable through local port 8000.
  • Save compose.yaml.

✔️ Awesome, I've got everything!

The complete Compose file now coordinates the database plus the API.

ⓧ I'd like to double check the full code

services:
  db:
    image: postgres:18.6
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-quality-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  api:
    build:
      context: ./api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
        restart: true
    ports:
      - "8000:8000"

volumes:
  postgres_data:

How to use this reference

This is the complete Compose state for this step. The API service sits under services before the top-level named volume declaration.

Before you run the final checks, which service do you expect to become healthy before the API starts?

  • Validate the complete Compose model by running:
docker compose config --quiet

What does the final validation prove?

A quiet return confirms that Compose can parse both services plus the named volume. It also confirms that the required database variables are available.

You should return to the terminal prompt without a configuration error.

Final validation still failing?

Confirm that api: aligns with db: beneath services:. The top-level volumes: line must align with services:.

Help me compare my complete compose.yaml with the expected service structure.

The first build can take several minutes while Docker downloads the base images plus installs Python packages. Ongoing terminal output means the build is progressing.

  • Build the images plus start both services by running:
docker compose up --build --detach --wait

What does this startup command do?

Compose builds the API image before starting the services in detached mode. It waits for the database health check before completing.

  • The database initializes the defects table in the postgres_data volume.
  • The API starts after the database reaches its healthy state.
  • Visit http://localhost:8000/docs in your browser.

You should see the interactive documentation for your existing FastAPI application. That page proves the API container can start after the shared database becomes ready.

API documentation not loading?

Check the terminal output for a failed database health check. Also confirm that api/app/main.py exposes app plus reads DATABASE_URL.

Help me diagnose why the FastAPI documentation is unavailable after Compose startup.

That is the delivery foundation working: PostgreSQL becomes healthy before your containerized API accepts requests. Next, you will package the anomaly detector against this shared database.

Containerize Anomaly Detection

Your FastAPI service now shares the defects contract with the healthy PostgreSQL database. Docker Compose keeps both services running.

The analysis still runs outside the deployment. A separate container lets you test whether a detected quality signal reaches the people who need it.

In this step, get ready to:
  • Pin the analyst dependencies inside an independent image.
  • Query recent critical defects by production station.
  • Prove the current delivery boundary with a seeded JSON anomaly.
Package the analyst image

The analyst needs an independent runtime for its data workload. Its image uses Python 3.12.15 with fixed dependency versions.

  • Use the file sidebar in the Visual Studio Code window from earlier to create analyst/requirements.txt.
  • Add the analyst dependency pins by pasting this code into analyst/requirements.txt:
pandas==3.0.6
SQLAlchemy==2.1.4
psycopg[binary]==3.3.6
requests==2.34.2
schedule==1.2.2

What do these dependencies provide?

  • pandas groups the defect rows by station.
  • SQLAlchemy creates the database connection.
  • psycopg provides the PostgreSQL driver.
  • Requests provides the HTTP client required by the delivery layer.
  • schedule provides the interval scheduler required by the automated worker.
  • Save analyst/requirements.txt.
  • Confirm the file sidebar lists requirements.txt inside analyst.

Dependency file in the wrong place?

  • Check that requirements.txt appears inside analyst.
  • Move the file into analyst if it appears beside that folder.

Help me check my analyst dependency file location.

✔️ Awesome, I've got everything!

Your analyst dependency file is ready.

ⓧ I'd like to double check the full code

pandas==3.0.6
SQLAlchemy==2.1.4
psycopg[binary]==3.3.6
requests==2.34.2
schedule==1.2.2

How to use this reference

Compare every dependency pin with your saved analyst/requirements.txt file.

The dependency file defines what enters the image. The Dockerfile defines how that image runs the analysis.

  • Use the file sidebar to create analyst/Dockerfile.
  • Define the one-shot analyst image by pasting this code into analyst/Dockerfile:
FROM python:3.12.15-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "analysis.py"]

What does this Dockerfile do?

  • FROM starts with the slim Python 3.12.15 image.
  • WORKDIR places every following image action inside /app.
  • COPY requirements.txt ./ lets Docker install the pinned packages before copying the analyst code.
  • CMD makes the container run analysis.py once before it exits.
  • Save analyst/Dockerfile.
  • Confirm the file sidebar lists Dockerfile inside analyst.

Dockerfile not detected?

  • Check that the filename is exactly Dockerfile.
  • Remove any automatic file extension added by your editor.
  • Confirm the file sits directly inside analyst.

Help me verify my analyst Dockerfile path.

✔️ Awesome, I've got everything!

Your one-shot analyst image definition is complete.

ⓧ I'd like to double check the full code

FROM python:3.12.15-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "analysis.py"]

How to use this reference

Compare the complete image definition with your saved analyst/Dockerfile file.

Build the anomaly detector

The image now has its runtime contract. The analysis.py script turns recent rows from defects into station-level anomaly records.

A parameterized query passes the severity separately from the SQL text. It applies the analysis window through a second query parameter.

  • Use the file sidebar to create analyst/analysis.py.
  • Add the imports and analysis configuration by pasting this first section into analyst/analysis.py:
import json
import os

import pandas as pd
from sqlalchemy import create_engine, text

DATABASE_URL = os.environ["DATABASE_URL"]
DEFECT_WINDOW_MINUTES = int(os.environ.get("DEFECT_WINDOW_MINUTES", "60"))
DEFECT_THRESHOLD = int(os.environ.get("DEFECT_THRESHOLD", "3"))
ANOMALY_SEVERITY = os.environ.get("ANOMALY_SEVERITY", "Critical")

engine = create_engine(DATABASE_URL, pool_pre_ping=True)

recent_defects_query = text(
    """
    SELECT production_station
    FROM defects
    WHERE severity = :severity
      AND created_at >= CURRENT_TIMESTAMP - (:window_minutes * INTERVAL '1 minute')
    """
)

What does this section do?

  • DATABASE_URL identifies the shared PostgreSQL database.
  • DEFECT_WINDOW_MINUTES sets the recent-defect window to 60 minutes by default.
  • DEFECT_THRESHOLD sets the anomaly threshold to 3 defects by default.
  • ANOMALY_SEVERITY limits this analysis to Critical defects by default.
  • pool_pre_ping=True checks a pooled connection before the query uses it.
  • recent_defects_query selects station names inside the configured time window.
  • Save analyst/analysis.py.
  • Confirm the editor displays Python syntax highlighting for the imports and SQL string.

Python highlighting missing?

  • Check that the filename ends with .py.
  • Confirm the file is named exactly analysis.py.
  • Move the file into analyst if it appears elsewhere.

Help me check why analysis.py is not recognized as Python.

The query returns station names for defects that match the configured severity and time window. The first part of find_anomalies() loads those rows into a DataFrame.

  • Place the first part of find_anomalies() directly below recent_defects_query by pasting this code:
def find_anomalies() -> list[dict[str, object]]:
    with engine.connect() as connection:
        defects = pd.read_sql_query(
            recent_defects_query,
            connection,
            params={
                "severity": ANOMALY_SEVERITY,
                "window_minutes": DEFECT_WINDOW_MINUTES,
            },
        )

    if defects.empty:
        return []

How are defects loaded?

  • engine.connect() opens a database connection for this analysis run.
  • pd.read_sql_query() loads the matching station rows into defects.
  • params supplies the configured severity and window values to the query.
  • defects.empty returns an empty anomaly list when no recent rows match.
  • Save analyst/analysis.py.
  • Confirm the file now ends with return [] inside find_anomalies().

Function indentation looks uneven?

  • Keep the database connection block indented beneath find_anomalies().
  • Keep return [] indented beneath the empty-result check.

Help me fix the indentation in find_anomalies().

Matching rows still need to become an operational signal. The remaining logic groups rows by station before keeping counts that reach the threshold.

  • Add the grouping and JSON result logic directly below return [] by pasting this code:
    counts = (
        defects.groupby("production_station", as_index=False)
        .size()
        .rename(columns={"size": "defect_count"})
    )
    flagged = counts[counts["defect_count"] >= DEFECT_THRESHOLD]

    return [
        {
            "production_station": str(row["production_station"]),
            "severity": ANOMALY_SEVERITY,
            "defect_count": int(row["defect_count"]),
            "threshold": DEFECT_THRESHOLD,
            "window_minutes": DEFECT_WINDOW_MINUTES,
        }
        for row in flagged.to_dict(orient="records")
    ]


if __name__ == "__main__":
    print(json.dumps(find_anomalies(), indent=2))

How does the detector flag a station?

  • groupby() creates one group for each production_station value.
  • size() counts the matching defects in every station group.
  • flagged keeps groups whose defect_count reaches the configured threshold.
  • to_dict(orient="records") turns the flagged rows into anomaly dictionaries.
  • json.dumps() prints the anomaly list as readable JSON when the script runs directly.
  • Save analyst/analysis.py.
  • Confirm the editor shows balanced brackets throughout find_anomalies().

Seeing an indentation warning?

  • Keep counts aligned with the earlier if defects.empty line.
  • Keep the final if __name__ == "__main__": line at the far left.
  • Check that the closing bracket for the returned list aligns with return [.

Help me fix the grouping section in analysis.py.

✔️ Awesome, I've got everything!

Your anomaly detector is ready to run inside the analyst image.

ⓧ I'd like to double check the full code

import json
import os

import pandas as pd
from sqlalchemy import create_engine, text

DATABASE_URL = os.environ["DATABASE_URL"]
DEFECT_WINDOW_MINUTES = int(os.environ.get("DEFECT_WINDOW_MINUTES", "60"))
DEFECT_THRESHOLD = int(os.environ.get("DEFECT_THRESHOLD", "3"))
ANOMALY_SEVERITY = os.environ.get("ANOMALY_SEVERITY", "Critical")

engine = create_engine(DATABASE_URL, pool_pre_ping=True)

recent_defects_query = text(
    """
    SELECT production_station
    FROM defects
    WHERE severity = :severity
      AND created_at >= CURRENT_TIMESTAMP - (:window_minutes * INTERVAL '1 minute')
    """
)


def find_anomalies() -> list[dict[str, object]]:
    with engine.connect() as connection:
        defects = pd.read_sql_query(
            recent_defects_query,
            connection,
            params={
                "severity": ANOMALY_SEVERITY,
                "window_minutes": DEFECT_WINDOW_MINUTES,
            },
        )

    if defects.empty:
        return []

    counts = (
        defects.groupby("production_station", as_index=False)
        .size()
        .rename(columns={"size": "defect_count"})
    )
    flagged = counts[counts["defect_count"] >= DEFECT_THRESHOLD]

    return [
        {
            "production_station": str(row["production_station"]),
            "severity": ANOMALY_SEVERITY,
            "defect_count": int(row["defect_count"]),
            "threshold": DEFECT_THRESHOLD,
            "window_minutes": DEFECT_WINDOW_MINUTES,
        }
        for row in flagged.to_dict(orient="records")
    ]


if __name__ == "__main__":
    print(json.dumps(find_anomalies(), indent=2))

How to use this reference

Compare this complete file with your saved analyst/analysis.py file.

Connect and test the analyst service

The analyst service needs the same database URL as the API. Its dependency rule also keeps the analysis from starting before the database becomes healthy.

  • Switch back to compose.yaml in the Visual Studio Code window from earlier.
  • Find the top-level volumes: line below the api service.
  • Insert the analyst service immediately above volumes: by pasting this code:
  analyst:
    build:
      context: ./analyst
    depends_on:
      db:
        condition: service_healthy
        restart: true
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      DEFECT_WINDOW_MINUTES: ${DEFECT_WINDOW_MINUTES:-60}
      DEFECT_THRESHOLD: ${DEFECT_THRESHOLD:-3}
      ANOMALY_SEVERITY: ${ANOMALY_SEVERITY:-Critical}

How is the analyst connected?

  • context: ./analyst gives the analyst its own image build context.
  • condition: service_healthy delays the one-shot analysis until PostgreSQL passes its health check.
  • DATABASE_URL points the analyst at the same quality database used by the API.
  • DEFECT_WINDOW_MINUTES passes the recent-defect window into the container.
  • DEFECT_THRESHOLD passes the minimum defect count into the container.
  • ANOMALY_SEVERITY passes the target severity into the container.
  • Save compose.yaml.

✔️ Awesome, I've got everything!

Your Compose model now includes the one-shot analyst service.

ⓧ I'd like to double check the full code

services:
  db:
    image: postgres:18.6
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-quality-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  api:
    build:
      context: ./api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
        restart: true
    ports:
      - "8000:8000"

  analyst:
    build:
      context: ./analyst
    depends_on:
      db:
        condition: service_healthy
        restart: true
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      DEFECT_WINDOW_MINUTES: ${DEFECT_WINDOW_MINUTES:-60}
      DEFECT_THRESHOLD: ${DEFECT_THRESHOLD:-3}
      ANOMALY_SEVERITY: ${ANOMALY_SEVERITY:-Critical}

volumes:
  postgres_data:

How to use this reference

Compare this complete service model with your saved compose.yaml file.

A detector needs a known database condition before its output means anything. Three recent critical defects from one station create that controlled test.

  • Seed three critical defects for STATION-A by running this command in the terminal from earlier:
docker compose exec db psql -U quality_app -d quality -c "INSERT INTO defects (production_station, severity, status) VALUES ('STATION-A', 'Critical', 'Open'), ('STATION-A', 'Critical', 'Open'), ('STATION-A', 'Critical', 'Open');"

What does this seed create?

  • docker compose exec db runs the database client inside the existing db service.
  • psql connects as quality_app to the quality database.
  • INSERT INTO defects adds three open critical rows for STATION-A.

The terminal confirms that three rows were inserted. The shared database now contains a controlled station anomaly.

The first analyst run can take a little longer while Docker prepares the image and installs its dependencies. A short wait here is expected.

Before you run it, ask yourself whether the anomaly will appear only in the terminal or also in Discord.

  • Run the one-shot analyst container by using this command:
docker compose run --rm analyst

What should the analyst print?

The one-off container waits for the healthy database before running analysis.py. It removes that temporary container after the script exits.

You should see a JSON array containing one anomaly for STATION-A. Its defect_count is 3.

The same anomaly shows a threshold of 3. Its window_minutes value is 60.

  • Return to the Discord channel linked to your incoming webhook.
  • Check the channel for a new station alert.

Your Discord channel remains unchanged. That shortfall is the intended result.

The detector now works against the shared database. Its result currently stops at terminal output.

No STATION-A anomaly?

  • Confirm the seed command completed before you ran the analyst.
  • Check that DEFECT_THRESHOLD is set to 3 in your local .env file.
  • Check that DEFECT_WINDOW_MINUTES is set to 60 in your local .env file.

Help me debug the missing station anomaly.

You have proved that the analyst image can detect a real station anomaly from the shared database. Next, you will turn that one-shot result into a scheduled Discord alert while keeping the worker alive through failures.

Automate Analysis and Discord Alerts

Your analyst now groups the three seeded defects into one station anomaly. The Discord channel still stays silent.

A live quality workflow needs repeated checks plus a reliable delivery path. This step adds both through a continuous worker managed by Docker Compose.

In this step, get ready to:
  • Build a worker that analyzes defects immediately.
  • Schedule later analyses with failure-safe logging.
  • Deliver each detected anomaly through the Discord webhook.
Build the alert worker

The schedule library keeps a lightweight job loop inside the analyst container. Exception boundaries let later runs continue when analysis or delivery fails.

The worker uses an HTTP webhook for one-way alert delivery. It reads the webhook URL from the mounted secret only when a message needs to be sent.

  • In the Visual Studio Code Explorer sidebar, create worker.py inside the analyst folder.
  • Add the worker configuration plus secret reader by pasting this first chunk into worker.py:
import logging
import os
import time

import requests
import schedule

from analysis import find_anomalies

ANALYSIS_INTERVAL_MINUTES = int(
    os.environ.get("ANALYSIS_INTERVAL_MINUTES", "5")
)
DISCORD_WEBHOOK_URL_FILE = os.environ["DISCORD_WEBHOOK_URL_FILE"]

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("quality-signals-worker")


def read_discord_webhook_url() -> str:
    with open(DISCORD_WEBHOOK_URL_FILE, encoding="utf-8") as secret_file:
        return secret_file.read().strip()

How does the worker load its settings?

  • The interval comes from ANALYSIS_INTERVAL_MINUTES, so Compose controls the timing without changing Python code.
  • The named logger gives every worker event a consistent source in the container logs.
  • The secret reader opens the path supplied through DISCORD_WEBHOOK_URL_FILE.
  • Save worker.py.
  • Confirm the last function in the editor is read_discord_webhook_url().

Is the secret reader misaligned?

Keep the return line inside the with block. Python uses indentation to decide which lines belong to the open file context.

Help me check the configuration and secret-reader indentation in my worker.

  • In worker.py, place the alert functions directly below read_discord_webhook_url().
  • Add the Discord message builder plus delivery function by pasting this chunk:
def build_alert_message(anomaly: dict[str, object]) -> str:
    return (
        "FACTORY QUALITY ALERT\n"
        f"Station: {anomaly['production_station']}\n"
        f"Severity: {anomaly['severity']}\n"
        f"Observed defects: {anomaly['defect_count']}\n"
        f"Threshold: {anomaly['threshold']}\n"
        f"Window: {anomaly['window_minutes']} minutes"
    )


def send_discord_alert(anomaly: dict[str, object]) -> None:
    response = requests.post(
        read_discord_webhook_url(),
        params={"wait": "true"},
        json={"content": build_alert_message(anomaly)},
        timeout=(3.05, 10),
    )
    response.raise_for_status()
    logger.info(
        "Discord alert delivered station=%s status=%s",
        anomaly["production_station"],
        response.status_code,
    )

How does alert delivery work?

  • The message builder turns one anomaly record into the station alert shown to the factory team.
  • The Requests call sends that message through the webhook URL.
  • The wait parameter asks Discord to confirm the created message.
  • The status check converts an unsuccessful response into an exception that the worker can log.
  • Save worker.py.
  • Confirm build_alert_message() includes the station plus severity plus count plus threshold plus window.

Does the alert message look incomplete?

Compare every dictionary key with the anomaly returned by find_anomalies(). A mismatched key stops message construction before the webhook request.

Help me compare my Discord alert fields with the anomaly record.

  • In worker.py, place the analysis job directly below send_discord_alert().
  • Add the exception-safe job by pasting this chunk:
def run_analysis_job() -> None:
    logger.info("Analysis run started")

    try:
        anomalies = find_anomalies()
    except Exception:
        logger.exception("Analysis failed; scheduler will continue")
        return

    if not anomalies:
        logger.info("Analysis completed with no anomalies")
        return

    logger.warning("Analysis detected %s station anomalies", len(anomalies))

    for anomaly in anomalies:
        try:
            send_discord_alert(anomaly)
        except Exception:
            logger.exception(
                "Alert delivery failed station=%s; scheduler will continue",
                anomaly["production_station"],
            )

Why are there two exception boundaries?

  • The first boundary captures a failed database analysis before the scheduler regains control.
  • The empty-result path records a successful run with no detected anomalies.
  • The delivery boundary isolates each station alert, so one failed webhook request does not stop later work.
  • Save worker.py.
  • Confirm both exception paths end with log messages that say the scheduler continues.

Could one failure stop every alert?

Keep the delivery try block inside the anomaly loop. That placement isolates each station request.

Help me check the exception boundaries in my analysis job.

  • In worker.py, place the scheduler entrypoint directly below run_analysis_job().
  • Complete the worker by pasting this final chunk:
def main() -> None:
    logger.info(
        "Worker starting interval_minutes=%s",
        ANALYSIS_INTERVAL_MINUTES,
    )
    run_analysis_job()
    schedule.every(ANALYSIS_INTERVAL_MINUTES).minutes.do(run_analysis_job)

    while True:
        schedule.run_pending()
        time.sleep(1)


if __name__ == "__main__":
    main()

How does the scheduling loop behave?

  • The first run_analysis_job() call checks the seeded data as soon as the worker starts.
  • The interval registration schedules later runs using the configured number of minutes.
  • The loop checks for due work once per second.
  • The main guard starts the loop when the container runs worker.py.
  • Save worker.py.
  • Confirm the file ends with a call to main().

Does the scheduler exit immediately?

Check that schedule.run_pending() stays inside the while True loop. Check that main() stays inside the final guard.

Help me check why my scheduled worker exits immediately.

✔️ Awesome, I've got everything!

Great. Double-check that worker.py is saved before you connect it to the container.

ⓧ I'd like to double check the full code

import logging
import os
import time

import requests
import schedule

from analysis import find_anomalies

ANALYSIS_INTERVAL_MINUTES = int(
    os.environ.get("ANALYSIS_INTERVAL_MINUTES", "5")
)
DISCORD_WEBHOOK_URL_FILE = os.environ["DISCORD_WEBHOOK_URL_FILE"]

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("quality-signals-worker")


def read_discord_webhook_url() -> str:
    with open(DISCORD_WEBHOOK_URL_FILE, encoding="utf-8") as secret_file:
        return secret_file.read().strip()


def build_alert_message(anomaly: dict[str, object]) -> str:
    return (
        "FACTORY QUALITY ALERT\n"
        f"Station: {anomaly['production_station']}\n"
        f"Severity: {anomaly['severity']}\n"
        f"Observed defects: {anomaly['defect_count']}\n"
        f"Threshold: {anomaly['threshold']}\n"
        f"Window: {anomaly['window_minutes']} minutes"
    )


def send_discord_alert(anomaly: dict[str, object]) -> None:
    response = requests.post(
        read_discord_webhook_url(),
        params={"wait": "true"},
        json={"content": build_alert_message(anomaly)},
        timeout=(3.05, 10),
    )
    response.raise_for_status()
    logger.info(
        "Discord alert delivered station=%s status=%s",
        anomaly["production_station"],
        response.status_code,
    )


def run_analysis_job() -> None:
    logger.info("Analysis run started")

    try:
        anomalies = find_anomalies()
    except Exception:
        logger.exception("Analysis failed; scheduler will continue")
        return

    if not anomalies:
        logger.info("Analysis completed with no anomalies")
        return

    logger.warning("Analysis detected %s station anomalies", len(anomalies))

    for anomaly in anomalies:
        try:
            send_discord_alert(anomaly)
        except Exception:
            logger.exception(
                "Alert delivery failed station=%s; scheduler will continue",
                anomaly["production_station"],
            )


def main() -> None:
    logger.info(
        "Worker starting interval_minutes=%s",
        ANALYSIS_INTERVAL_MINUTES,
    )
    run_analysis_job()
    schedule.every(ANALYSIS_INTERVAL_MINUTES).minutes.do(run_analysis_job)

    while True:
        schedule.run_pending()
        time.sleep(1)


if __name__ == "__main__":
    main()

What should the complete worker contain?

The complete file contains configuration plus delivery plus exception handling plus the continuous scheduling loop. Use it as a comparison reference for your saved worker.py.

Connect the worker to Compose

The analyst image still launches the one-shot analysis script. Its container also needs the interval setting plus a secret mount that exposes the webhook URL without placing it in the image.

  • In analyst/Dockerfile, find the current command at the bottom of the file.
  • Use this line to identify the command you are replacing:
CMD ["python", "analysis.py"]

What does the current command do?

The current command runs one analysis before the container exits. That behavior produced the JSON anomaly in the previous step.

  • Replace the current command with this worker command:
CMD ["python", "worker.py"]

What changes inside the image?

The image now starts the continuous worker as its main process. Docker can monitor that process through the analyst container.

  • Save analyst/Dockerfile.
  • In compose.yaml, find the existing analyst service block.
  • Replace the existing analyst service with this complete block:
  analyst:
    build:
      context: ./analyst
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      ANALYSIS_INTERVAL_MINUTES: ${ANALYSIS_INTERVAL_MINUTES:-5}
      DEFECT_WINDOW_MINUTES: ${DEFECT_WINDOW_MINUTES:-60}
      DEFECT_THRESHOLD: ${DEFECT_THRESHOLD:-3}
      ANOMALY_SEVERITY: ${ANOMALY_SEVERITY:-Critical}
      DISCORD_WEBHOOK_URL_FILE: /run/secrets/discord_webhook_url
    depends_on:
      db:
        condition: service_healthy
        restart: true
    secrets:
      - discord_webhook_url

What does the analyst service gain?

  • The restart policy keeps the worker eligible for recovery until you stop or remove the service.
  • The interval variable gives worker.py its five-minute schedule.
  • The webhook path points to the file that Compose mounts inside the container.
  • The healthy dependency keeps the worker behind PostgreSQL readiness.
  • Save compose.yaml.
  • At the bottom of compose.yaml, place the top-level secret definition below the volume definition.
  • Add the file-backed Discord secret by pasting this block:
secrets:
  discord_webhook_url:
    file: ./secrets/discord_webhook_url.txt

How does the secret reach the worker?

The top-level definition identifies the local source file. The analyst service grants that named secret to its container.

Inside the container, the file is available at /run/secrets/discord_webhook_url. The worker reads that path through DISCORD_WEBHOOK_URL_FILE.

  • Save compose.yaml.

Before you validate the file, do you expect Compose to accept the new worker configuration?

  • Validate the updated Compose model by running:
docker compose config --quiet

What does this validation check?

This command parses the Compose model without printing the resolved configuration. It catches structural mistakes before any container is rebuilt.

The terminal returns to the prompt without a configuration error. That quiet result confirms the model is valid.

Does Compose report a configuration error?

Check the indentation of analyst under services. Check that the final secrets block starts at the same level as volumes.

Help me fix the structure of my Compose configuration.

✔️ Awesome, I've got everything!

Your image command plus Compose configuration are ready for the final rebuild.

ⓧ I'd like to double check the full code

Compare your saved analyst/Dockerfile with this complete reference.

FROM python:3.12.15-slim

WORKDIR /app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "worker.py"]

What should the image launch?

The final line launches worker.py as the analyst image process. The dependency installation plus copied application files remain unchanged.

Compare your saved compose.yaml with this complete reference.

services:
  db:
    image: postgres:18.6
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql
      - ./db/init.sql:/docker-entrypoint-initdb.d/001-quality-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  api:
    build:
      context: ./api
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
    depends_on:
      db:
        condition: service_healthy
        restart: true
    ports:
      - "8000:8000"

  analyst:
    build:
      context: ./analyst
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      ANALYSIS_INTERVAL_MINUTES: ${ANALYSIS_INTERVAL_MINUTES:-5}
      DEFECT_WINDOW_MINUTES: ${DEFECT_WINDOW_MINUTES:-60}
      DEFECT_THRESHOLD: ${DEFECT_THRESHOLD:-3}
      ANOMALY_SEVERITY: ${ANOMALY_SEVERITY:-Critical}
      DISCORD_WEBHOOK_URL_FILE: /run/secrets/discord_webhook_url
    depends_on:
      db:
        condition: service_healthy
        restart: true
    secrets:
      - discord_webhook_url

volumes:
  postgres_data:

secrets:
  discord_webhook_url:
    file: ./secrets/discord_webhook_url.txt

What should the complete stack define?

The complete model keeps the existing database plus API configuration. It adds a continuous analyst with restart behavior plus interval configuration plus access to the Discord secret.

Launch and verify the complete stack

The final rebuild replaces the one-shot analyst with the worker. Compose waits for the PostgreSQL health check before it treats the full stack as ready.

Before you start the stack, do you expect the seeded STATION-A anomaly to trigger during startup or after the first five-minute interval?

  • Build the images plus start the detached stack by running:
docker compose up --build --detach --wait

What does this startup command do?

Compose rebuilds the Python images before starting all three services in detached mode. It waits for running or healthy service states before returning control to the terminal.

Compose completes without a startup error. The database is healthy while the API plus analyst remain running.

Does the stack fail to become ready?

Confirm secrets/discord_webhook_url.txt exists. Confirm it contains only the webhook URL.

Help me diagnose why my three-service Compose stack does not become ready.

Before you follow the worker, what startup event should appear before the successful Discord delivery?

  • Follow the analyst log stream by running:
docker compose logs --follow analyst

What does log following show?

This command streams new log entries from the analyst service. It stays attached to your terminal until you stop following the stream.

You first see Worker starting interval_minutes=5. A following entry shows Analysis run started.

The seeded defects produce one detected station anomaly. A later log entry confirms that the Discord alert was delivered for STATION-A.

  • Return to the selected Discord alert channel.
  • Confirm the alert lists STATION-A plus Critical plus 3 observed defects plus threshold 3 plus a 60 minute window.
  • Switch back to the analyst log stream.
  • Watch the stream until another Analysis run started entry appears after five minutes.
  • Press Ctrl+C to leave the log stream.

Five minutes gives you a natural pause while the scheduler remains active. The detached containers continue running after you leave the log stream.

How does the worker survive failures?

A database or analysis exception produces Analysis failed; scheduler will continue. The job returns control to the scheduling loop.

A failed webhook request produces Alert delivery failed station=%s; scheduler will continue. Later scheduled analyses can still begin.

Is the Discord alert missing?

Check the analyst logs for a delivery failure. Confirm the webhook still points to the selected Discord channel without displaying its secret URL.

Help me diagnose a missing Discord alert without exposing my webhook.

You closed the operational gap. The analyst now checks immediately plus every five minutes while Discord receives the factory-floor alert.

Secret mission

Prevent Alert Fatigue

Add a per-station cooldown that stops a persistent anomaly from flooding the factory channel every five minutes. A different station can still alert while the first station remains inside its cooldown.

Clean Up Your Resources

Clean Up Your Resources

Your Docker Compose pipeline creates no cloud infrastructure or cloud charges. Choose whether to keep it running, stop its local resource use, or delete its local state.

Resources you used:

  • The detached db, api, and analyst service containers plus their Compose network.
  • The postgres_data named volume containing the quality database.
  • The Discord incoming webhook connected to your factory alert channel.
  • The factory-quality-pipeline workspace containing your source files, .env, and secrets/discord_webhook_url.txt.

Keep everything running

No action is needed. Choose this if you plan to keep testing alerts or developing the pipeline.

  • Leave the detached three-service stack running so scheduled analysis continues every five minutes.
  • Apply future code changes by running this command from the factory-quality-pipeline folder:
docker compose up --build --detach --wait

What Does This Command Do?

The command rebuilds changed images. It starts the services in detached mode.

It waits until the services are running or healthy. Your three-service pipeline is then ready to process defects again.

Stack Not Returning to Healthy?

Help me diagnose why my Docker Compose stack does not return to a healthy state.

Pause - I'll come back to this later

Shut down the running processes to free local CPU and memory. Your containers and database volume remain available for your return.

  • Stop all three services by running this command from the factory-quality-pipeline folder:
docker compose stop

What Does Stopping Preserve?

The command stops the service containers without removing them. The postgres_data volume keeps your database.

Your local services are now paused. The analyst cooldown resets when its container starts again because that state lives in memory.

Services Still Running?

Help me check why my Compose services did not stop.

Delete - I don't want to use this again

Remove the active stack and local project files to start fresh. You will also delete the Discord webhook.

This Cleanup Is Permanent

This permanently removes your seeded database history and the webhook alert path. Your Discord channel remains in place.

  • Remove the Compose containers, network, named volume, and orphan containers by running this command from the factory-quality-pipeline folder:
docker compose down --volumes --remove-orphans

What Does This Remove?

The command removes the three service containers and their Compose network. It also deletes the postgres_data named volume.

That completes the irreversible database cleanup. Your source files remain available until the final local deletion.

Stack Cleanup Not Completing?

Help me diagnose why the local Compose stack did not shut down cleanly.

  • Open Server Settings for the Discord server that owns the webhook.
  • Select Integrations.
  • Open the webhook connected to your factory alert channel.
  • Delete the webhook.

Discord can no longer accept factory alerts through that webhook URL.

Check the Folder Path

The next command targets only ~/Desktop/factory-quality-pipeline. Everything else on your Desktop stays in place.

  • Delete the local workspace by running this command:
rm -rf ~/Desktop/factory-quality-pipeline

What Does This Remove?

The command removes the complete factory-quality-pipeline workspace from your Desktop. This includes .env and secrets/discord_webhook_url.txt.

Your local project files and secret file are now removed.

Folder Still Present?

Help me diagnose why the local workspace was not deleted.

Nice Work!

Nice Work!

Outstanding work. Your Docker Compose stack now turns shared PostgreSQL defect data into scheduled Discord alerts.

You've learned how to:

  • Package a FastAPI API plus a pandas analysis workload as separate container images. Gate service startup on PostgreSQL health. Persist defect records in a named volume.
  • Detect station-level anomalies from recent critical defects. Run analysis at startup plus every five minutes. Preserve the scheduling loop with exception boundaries.
  • Deliver factory alerts through an HTTP webhook. Keep the Discord webhook URL outside source control with a Compose secret.
  • Secret Mission: Added an in-memory per-station cooldown that suppresses duplicate alerts. New station anomalies can still notify the factory channel.

Ready to quiz yourself?