AI Log Analyzer with Discord Alerts
Build a containerized API that analyzes logs with Claude AI and alerts Discord.
Introduction
30 Second Summary
When an application crashes at 2am, the last thing anyone wants to do is scroll through hundreds of raw log lines trying to figure out what went wrong. Most teams waste precious minutes reading walls of text before they even understand the severity of the problem.
In this project, you will build an AI-powered incident response API that accepts raw application logs, uses Claude to diagnose what went wrong, and automatically alerts your Discord channel when something critical happens.
What You'll Build
You paste a messy error log into your API, and within seconds it tells you the severity, the root cause, and exactly what to fix. If it is critical, a rich alert lands in your Discord channel before you even finish reading the response.
By the end of this project, you'll have:
- A running API where you submit any error log and get back a severity rating (critical, warning, or info), a root-cause explanation, and a concrete suggested fix.
- A PostgreSQL-backed incident history you can query to see all past analyses stored in your Docker Compose stack.
- Real-time Discord webhook alerts with rich embeds that fire automatically the moment a critical incident is detected.
- A fully containerized platform that spins up with a single command: your FastAPI service and its database, ready to demo.
- Secret Mission: An acknowledgment system with escalating re-notifications for unacknowledged critical incidents.
Are there any prerequisites?
You need Python and Docker installed on your machine, a Claude API key from Anthropic, and a Discord account with a personal server. Step 1 walks through confirming each of these.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Thinking through the end goal now keeps you focused as the project grows from a simple API into a multi-service platform with AI analysis, a database, and real-time alerts.
Set Up the Project
Before you can build the AI-powered incident response API, you need a project skeleton with the right dependencies installed.
In this step, you'll create the project directory, set up a Python virtual environment, and install all the packages your application needs.
In this step, get ready to:
- Create the project directory and Python virtual environment.
- Install all project dependencies.
- Create the application file structure and environment files.
Create the project directory and virtual environment
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir incident-response-platform
cd incident-response-platform
- Create a Python virtual environment by running:
python3 -m venv venv
- Confirm the virtual environment was created by running:
ls
You should see a venv/ folder in the output.
- Activate the virtual environment:
macOS
- Run this command to activate:
source venv/bin/activate
Windows
- Run this command to activate:
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation for your project. Packages you install inside it stay separate from your system Python, preventing version conflicts between projects.
Don't see (venv) in your prompt?
Make sure you ran the activate command for your operating system. On macOS, use source venv/bin/activate. On Windows, use venv\Scripts\activate.
If you see a permissions error on Windows, try running PowerShell as Administrator and executing Set-ExecutionPolicy RemoteSigned first.
Help me troubleshoot this
Install the project dependencies
Your project needs several Python packages. FastAPI handles the web API. The anthropic SDK connects to the Claude AI. httpx makes HTTP requests for Discord notifications.
- Create your requirements.txt file by running:
cat > requirements.txt << 'EOF'
fastapi[standard]==0.140.13
anthropic==0.120.2
httpx==0.28.1
sqlalchemy
psycopg2-binary
EOF
What are these packages?
- fastapi[standard] installs FastAPI with uvicorn (the server that runs your API).
- anthropic is the official Python SDK for calling the Claude API.
- httpx is a modern HTTP client used for sending Discord webhook requests.
- sqlalchemy handles database connections and table definitions.
- psycopg2-binary is the PostgreSQL driver needed when you containerize later.
- Install the dependencies by running:
pip install -r requirements.txt
You'll see pip downloading and installing each package. This takes 30-60 seconds depending on your internet speed.
Seeing installation errors?
If you see an error about psycopg2-binary failing to install, that's OK for now. It requires PostgreSQL libraries that you'll get from Docker later. The other packages should install successfully.
If pip itself is not found, make sure your virtual environment is activated. You should see (venv) in your prompt.
Help me troubleshoot pip install errors
Create the application file structure
Your application code will live in an app/ directory with separate modules for each responsibility: database models, AI analysis, notifications, and the API itself.
- Create the app/ directory and its files by running:
mkdir app
touch app/__init__.py app/main.py app/database.py app/analyzer.py app/notifier.py
- Confirm the files were created by running:
ls app/
You should see __init__.py, analyzer.py, database.py, main.py, and notifier.py listed.
- Create the .env.example file that documents which environment variables the project needs:
cat > .env.example << 'EOF'
ANTHROPIC_API_KEY=your-api-key-here
DISCORD_WEBHOOK_URL=your-discord-webhook-url-here
EOF
What is .env.example for?
The .env.example file shows other developers which environment variables your project expects, without exposing real credentials. You'll create a separate .env file with your actual values next.
- Create your .env file with your real Claude API key by running:
cat > .env << 'EOF'
ANTHROPIC_API_KEY=your-api-key-here
DISCORD_WEBHOOK_URL=
EOF
- Open .env in a text editor and replace your-api-key-here with your actual Anthropic API key.
- Leave DISCORD_WEBHOOK_URL empty for now.
- Save the file.
Keep your .env file private
Never commit .env to version control. It contains your real API key. When you add a .gitignore later, include .env in it.
Verify the installation
Before you move on, confirm that all the key packages are importable in your virtual environment.
- Think about this: if all your packages installed correctly, what should happen when Python tries to import them?
- Run this import check to verify your setup:
python -c "import fastapi; import anthropic; print('Ready')"
You should see Ready printed in your terminal. This confirms both fastapi and anthropic are installed and importable.
Seeing ModuleNotFoundError?
If Python cannot find the modules, your virtual environment may not be active. Check that (venv) appears in your prompt. If not, re-run the activation command for your OS.
If the environment is active but imports still fail, re-run pip install -r requirements.txt and check for errors in the output.
Help me fix ModuleNotFoundError
✔️ Awesome, I've got everything!
Great. Double check you've saved your .env file with your real API key.
ⓧ I'd like to double check the full code
Here is what your requirements.txt should contain:
fastapi[standard]==0.140.13
anthropic==0.120.2
httpx==0.28.1
sqlalchemy
psycopg2-binary
Here is what your .env.example should contain:
ANTHROPIC_API_KEY=your-api-key-here
DISCORD_WEBHOOK_URL=your-discord-webhook-url-here
Your app/ directory should contain five files: __init__.py, main.py, database.py, analyzer.py, and notifier.py. All five should be empty at this point.
Your project skeleton is ready. Next up, you'll build the log ingestion API that accepts raw logs and stores them as incidents.
Build the Log Ingestion API
Your environment is set up with all the dependencies installed and your file structure ready. Now it is time to build the core of your platform: an API that can accept raw application logs and store them as incidents.
Without an API that can receive and store log entries, there is nothing to analyze. This step delivers a working service you can immediately interact with by POSTing logs and querying them back.
In this step, get ready to:
- Define a database model to store incident records.
- Build a FastAPI application with endpoints for creating and retrieving incidents.
- Test the API by submitting sample logs and observing the gap in triage information.
Write the database models and connection logic
Every incident your platform receives needs to be stored somewhere. You will define a database table using SQLAlchemy that holds the raw log text, the service it came from, and fields for the analysis that will come later.
- In VS Code, open app/database.py and paste the following code:
import os
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean
from sqlalchemy.orm import sessionmaker, declarative_base
# Read database URL from environment, default to SQLite for local development
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./incidents.db")
# Create the database engine and session factory
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Define the Incident table schema
class Incident(Base):
__tablename__ = "incidents"
id = Column(Integer, primary_key=True, index=True)
raw_log = Column(Text, nullable=False)
service_name = Column(String(255), default="unknown")
severity = Column(String(50), default="info")
root_cause = Column(Text, default="")
suggested_fix = Column(Text, default="")
acknowledged = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
What does this code do?
DATABASE_URL reads from the environment so you can swap databases later (you will switch to PostgreSQL in a future step). For now, it defaults to a local SQLite file called incidents.db.
The Incident class maps to a database table. Fields like severity, root_cause, and suggested_fix default to empty values because the AI analysis will fill them in later.
- Save app/database.py.
Write the FastAPI application with log ingestion endpoints
With the database model defined, you need an HTTP layer that accepts incoming logs and stores them. You will build this with FastAPI, which gives you automatic request validation and interactive API docs out of the box.
- Open app/main.py and paste the application setup and request models:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app.database import engine, SessionLocal, Base, Incident
# Initialize the FastAPI app
app = FastAPI(title="AI Incident Response Platform", version="1.0.0")
# Create database tables on startup
Base.metadata.create_all(bind=engine)
# Define the shape of incoming log submissions
class LogSubmission(BaseModel):
raw_log: str
service_name: str = "unknown"
# Define the shape of outgoing incident responses
class IncidentResponse(BaseModel):
id: int
timestamp: str
raw_log: str
service_name: str
severity: str
root_cause: str
suggested_fix: str
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "incident-response-platform"}
What does this code do?
LogSubmission uses Pydantic to validate incoming requests. If a caller forgets the raw_log field, FastAPI automatically returns a 422 error with a helpful message.
Base.metadata.create_all(bind=engine) creates the incidents table in your SQLite database the first time the app starts. The /health endpoint lets you quickly confirm the server is running.
- Save app/main.py.
- Start the development server by running this command in your terminal:
uvicorn app.main:app --reload
You should see output that includes Uvicorn running on http://127.0.0.1:8000. The --reload flag means the server will automatically restart whenever you save a file change.
- Open a second terminal window (keep the server running in the first one).
- Verify the health endpoint by running:
curl http://localhost:8000/health
You should see {"status":"healthy","service":"incident-response-platform"} in the response.
Seeing "ModuleNotFoundError"?
Make sure your virtual environment is activated in the terminal where you ran uvicorn. Also confirm you are running the command from inside the incident-response-platform/ directory, not a parent folder.
Help me debug a ModuleNotFoundError when running uvicorn
Now add the POST endpoint that accepts and stores log entries. Add the following code below the health_check function in app/main.py:
@app.post("/incidents", response_model=IncidentResponse)
def create_incident(submission: LogSubmission):
db = SessionLocal()
try:
# Store the log with default severity and empty analysis
incident = Incident(
raw_log=submission.raw_log,
service_name=submission.service_name,
)
db.add(incident)
db.commit()
db.refresh(incident)
return IncidentResponse(
id=incident.id,
timestamp=incident.created_at.isoformat(),
raw_log=incident.raw_log,
service_name=incident.service_name,
severity=incident.severity,
root_cause=incident.root_cause,
suggested_fix=incident.suggested_fix,
)
finally:
db.close()
What does this code do?
The create_incident function receives a JSON body with raw_log and service_name. It creates a new Incident row in the database using only those two fields.
The severity defaults to "info" and the analysis fields stay empty. Right now, every log is treated equally regardless of how critical it is.
- Save app/main.py (the server auto-reloads).
- Test the POST endpoint by submitting a sample log:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "ERROR: Connection to database lost at 2024-01-15 03:42:01", "service_name": "payment-service"}'
You should see a JSON response containing your log entry with "severity": "info" and empty strings for root_cause and suggested_fix.
Getting a 422 Unprocessable Entity?
This means the request body does not match the expected format. Check that your JSON has the raw_log field (it is required) and that the Content-Type header is set to application/json.
Help me fix a 422 error when posting to my FastAPI endpoint
Now add the two GET endpoints so you can retrieve stored incidents. Add this code below the create_incident function in app/main.py:
@app.get("/incidents")
def list_incidents():
db = SessionLocal()
try:
# Retrieve all incidents, newest first
incidents = db.query(Incident).order_by(Incident.created_at.desc()).all()
return [
IncidentResponse(
id=i.id,
timestamp=i.created_at.isoformat(),
raw_log=i.raw_log,
service_name=i.service_name,
severity=i.severity,
root_cause=i.root_cause,
suggested_fix=i.suggested_fix,
)
for i in incidents
]
finally:
db.close()
@app.get("/incidents/{incident_id}", response_model=IncidentResponse)
def get_incident(incident_id: int):
db = SessionLocal()
try:
# Look up a single incident by ID
incident = db.query(Incident).filter(Incident.id == incident_id).first()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
return IncidentResponse(
id=incident.id,
timestamp=incident.created_at.isoformat(),
raw_log=incident.raw_log,
service_name=incident.service_name,
severity=incident.severity,
root_cause=incident.root_cause,
suggested_fix=incident.suggested_fix,
)
finally:
db.close()
What do these endpoints do?
GET /incidents returns every incident in your database, ordered newest first. This is your full incident history view.
GET /incidents/{incident_id} fetches a single incident by its numeric ID. If the ID does not exist, it returns a 404 error with a clear message.
- Save app/main.py.
- Verify the list endpoint returns your previously submitted log:
curl http://localhost:8000/incidents
You should see a JSON array containing the one incident you posted earlier.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/main.py with all four endpoints: /health, POST /incidents, GET /incidents, and GET /incidents/{incident_id}.
ⓧ I'd like to double check the full code
Here is the complete app/database.py:
import os
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean
from sqlalchemy.orm import sessionmaker, declarative_base
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./incidents.db")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
class Incident(Base):
__tablename__ = "incidents"
id = Column(Integer, primary_key=True, index=True)
raw_log = Column(Text, nullable=False)
service_name = Column(String(255), default="unknown")
severity = Column(String(50), default="info")
root_cause = Column(Text, default="")
suggested_fix = Column(Text, default="")
acknowledged = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
Here is the complete app/main.py. Scroll through to confirm each endpoint matches:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from app.database import engine, SessionLocal, Base, Incident
app = FastAPI(title="AI Incident Response Platform", version="1.0.0")
Base.metadata.create_all(bind=engine)
class LogSubmission(BaseModel):
raw_log: str
service_name: str = "unknown"
class IncidentResponse(BaseModel):
id: int
timestamp: str
raw_log: str
service_name: str
severity: str
root_cause: str
suggested_fix: str
@app.get("/health")
def health_check():
return {"status": "healthy", "service": "incident-response-platform"}
@app.post("/incidents", response_model=IncidentResponse)
def create_incident(submission: LogSubmission):
db = SessionLocal()
try:
incident = Incident(
raw_log=submission.raw_log,
service_name=submission.service_name,
)
db.add(incident)
db.commit()
db.refresh(incident)
return IncidentResponse(
id=incident.id,
timestamp=incident.created_at.isoformat(),
raw_log=incident.raw_log,
service_name=incident.service_name,
severity=incident.severity,
root_cause=incident.root_cause,
suggested_fix=incident.suggested_fix,
)
finally:
db.close()
@app.get("/incidents")
def list_incidents():
db = SessionLocal()
try:
incidents = db.query(Incident).order_by(Incident.created_at.desc()).all()
return [
IncidentResponse(
id=i.id,
timestamp=i.created_at.isoformat(),
raw_log=i.raw_log,
service_name=i.service_name,
severity=i.severity,
root_cause=i.root_cause,
suggested_fix=i.suggested_fix,
)
for i in incidents
]
finally:
db.close()
@app.get("/incidents/{incident_id}", response_model=IncidentResponse)
def get_incident(incident_id: int):
db = SessionLocal()
try:
incident = db.query(Incident).filter(Incident.id == incident_id).first()
if not incident:
raise HTTPException(status_code=404, detail="Incident not found")
return IncidentResponse(
id=incident.id,
timestamp=incident.created_at.isoformat(),
raw_log=incident.raw_log,
service_name=incident.service_name,
severity=incident.severity,
root_cause=incident.root_cause,
suggested_fix=incident.suggested_fix,
)
finally:
db.close()
Run and test the API with multiple log types
Your API is running and you have confirmed it stores a single log. Now submit three different types of logs to simulate a realistic incident stream: a critical database error, a network timeout warning, and a routine info message.
- Submit a database connection error:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "FATAL: PostgreSQL connection pool exhausted. 0/20 connections available. Queries backing up in payment-service.", "service_name": "payment-service"}'
- Submit a timeout warning:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "WARNING: Request to inventory-api timed out after 30s. Retrying (attempt 3/5).", "service_name": "order-service"}'
- Submit a routine info message:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "INFO: Daily backup completed successfully. 2.3GB archived to s3://backups/2024-01-15.", "service_name": "backup-service"}'
Before you check the full list, think about this: can you tell which of those three logs needs immediate attention just by looking at the API responses?
- Fetch all stored incidents by running:
curl http://localhost:8000/incidents
Every single incident has "severity": "info" with empty root_cause and suggested_fix fields. You cannot tell which log is a critical emergency and which is routine. That is the gap you will close in the next step by adding AI-powered analysis.
Why is this a problem?
In a real production environment, the database connection pool exhaustion is an emergency that could bring down your entire payment flow. The timeout is a warning worth investigating. The backup message is just noise.
Without severity classification, an on-call engineer would have to read every single log manually to figure out what matters. At scale, that is impossible.
Your API works. It stores every log faithfully, but it cannot distinguish a critical outage from a routine backup message. Next up, you will integrate Claude AI to automatically classify severity and provide root-cause analysis for every incoming log.
Integrate Claude AI for Log Analysis
Your API can receive and store logs, but right now every single entry gets the same "info" severity with no analysis. You cannot tell a critical database crash from a routine health check without reading every log line yourself.
In this step, you will connect the Claude Sonnet 5 model to your API so it can automatically classify severity and explain root causes. By the end, the same POST request that returned blank analysis in Step 2 will return a specific diagnosis.
In this step, get ready to:
- Write the AI analyzer module that calls the Claude API and parses structured responses.
- Integrate the analyzer into your POST /incidents endpoint so every log gets triaged on ingestion.
- Test the AI-powered analysis by submitting the same logs from Step 2 and comparing the results.
Write the AI analyzer client and prompt
The analyzer module needs three things: a connection to the Anthropic API, a system prompt that tells Claude how to respond, and a function that sends logs and parses the result.
- Open app/analyzer.py in VS Code.
- Add the imports, client initialization, and system prompt by pasting this code:
import os
import json
from anthropic import Anthropic
# Initialize the Anthropic client using your API key from .env
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
# Tell Claude exactly how to respond: severity + root cause + fix
SYSTEM_PROMPT = """You are an expert Site Reliability Engineer analyzing application logs.
Given a log entry, provide:
1. severity: one of "critical", "warning", or "info"
2. root_cause: a brief explanation of what is likely causing this issue
3. suggested_fix: a concrete action to resolve or mitigate the issue
Respond ONLY with valid JSON in this exact format:
{"severity": "critical|warning|info", "root_cause": "...", "suggested_fix": "..."}"""
What does this code do?
The Anthropic client handles authentication and HTTP communication with the Claude API. It reads your ANTHROPIC_API_KEY from the environment variable you set in your .env file.
The SYSTEM_PROMPT constrains Claude's output to a strict JSON schema. Without this, Claude might respond in free-form text that your code cannot parse programmatically.
- Below the SYSTEM_PROMPT, add the analyze_log function that calls Claude and returns structured analysis:
def analyze_log(raw_log: str, service_name: str) -> dict:
try:
# Send the log to Claude for analysis
message = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": f"Service: {service_name}\n\nLog entry:\n{raw_log}",
}
],
)
# Parse Claude's JSON response
response_text = message.content[0].text
analysis = json.loads(response_text)
return {
"severity": analysis.get("severity", "info"),
"root_cause": analysis.get("root_cause", "Unable to determine root cause"),
"suggested_fix": analysis.get("suggested_fix", "Manual investigation required"),
}
except Exception as e:
# Return a safe fallback if the API call fails
return {
"severity": "warning",
"root_cause": f"AI analysis failed: {str(e)}",
"suggested_fix": "Check ANTHROPIC_API_KEY and try again",
}
What does this function do?
The analyze_log function sends the raw log text and service name to Claude using client.messages.create(). Claude responds with JSON containing a severity rating, root cause, and suggested fix.
The try/except block ensures your API never crashes if the Claude call fails. Instead it returns a safe fallback with severity "warning" and an error message explaining what went wrong.
- Save app/analyzer.py.
- Confirm the module loads correctly by running this in your terminal:
python -c "from app.analyzer import analyze_log; print('Analyzer module loaded')"
You should see Analyzer module loaded printed with no import errors.
Seeing an ImportError?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Check that from anthropic import Anthropic matches exactly. A typo in the package name causes the import to fail.
- help me debug my analyzer.py import error
✔️ Awesome, I've got everything!
Great. Your analyzer module is ready to classify logs.
ⓧ I'd like to double check the full code
Your app/analyzer.py should contain two sections in this order:
- The imports, client initialization, and SYSTEM_PROMPT (first chunk above).
- The analyze_log function with the try/except block (second chunk above).
Scroll up and confirm both chunks are present in your file, in order, with no extra code between them.
Integrate the analyzer into your POST endpoint
The analyzer module exists, but your API does not call it yet. You need to wire analyze_log into the create_incident function so every incoming log gets triaged before it is stored.
- In app/main.py, find the import block at the top of the file:
from app.database import engine, SessionLocal, Base, Incident
- Add this import directly below it:
from app.analyzer import analyze_log
- Now find the create_incident function. It currently stores the log with default values. Replace the entire function (from the @app.post decorator to the db.close() line) with this version that calls the analyzer first:
@app.post("/incidents", response_model=IncidentResponse)
def create_incident(submission: LogSubmission):
# Call Claude to analyze the log before storing
analysis = analyze_log(submission.raw_log, submission.service_name)
db = SessionLocal()
try:
incident = Incident(
raw_log=submission.raw_log,
service_name=submission.service_name,
severity=analysis["severity"],
root_cause=analysis["root_cause"],
suggested_fix=analysis["suggested_fix"],
)
db.add(incident)
db.commit()
db.refresh(incident)
return IncidentResponse(
id=incident.id,
timestamp=incident.created_at.isoformat(),
raw_log=incident.raw_log,
service_name=incident.service_name,
severity=incident.severity,
root_cause=incident.root_cause,
suggested_fix=incident.suggested_fix,
)
finally:
db.close()
What changed?
Two key differences from the old version:
- The function now calls analyze_log() before creating the database session. This sends the raw log to Claude and gets back a severity, root cause, and suggested fix.
- Instead of relying on the column defaults (severity="info", empty strings), the Incident is now created with the AI-generated values.
- Save app/main.py.
Auto-reload picks up your changes
If you still have uvicorn app.main:app --reload running from Step 2, it detects the file change and restarts automatically. If you stopped it, start it again now.
Seeing a ModuleNotFoundError on startup?
- Check that your import says from app.analyzer import analyze_log with no typos.
- Confirm you are running uvicorn from the incident-response-platform/ directory (one level above app/).
- help me fix my module import error in main.py
Test the AI-powered analysis
Time to see the difference the AI makes. In Step 2, every log came back with severity "info" and empty analysis fields.
- Before you run the next command, predict: what severity do you think Claude will assign to a database connection failure log?
- Submit a database error log by running this in a new terminal:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "ERROR 2024-01-15 03:42:11 - Database connection pool exhausted. Cannot acquire connection after 30s timeout. Active connections: 100/100. Service unable to process requests.", "service_name": "payment-service"}'
You should see a JSON response where severity is "critical", and root_cause and suggested_fix contain specific, actionable explanations from Claude.
Compare with Step 2
In Step 2, this same kind of log returned severity: "info" with empty root_cause and suggested_fix fields. Now Claude reads the log, understands the database pool is exhausted, and tells you exactly what happened and how to fix it.
- Submit a routine info-level log to see that Claude does not over-classify:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "INFO 2024-01-15 10:00:00 - Health check passed. Response time: 45ms.", "service_name": "api-gateway"}'
This response should come back with severity "info" since there is nothing wrong with a passing health check.
- Confirm both incidents are stored with their analysis by running:
curl http://localhost:8000/incidents
You should see both entries in the response, each with a severity level and analysis that matches the content of the log.
Getting severity "warning" with "AI analysis failed"?
- This means the Claude API call failed but the error handling caught it gracefully. Check that your ANTHROPIC_API_KEY in .env is valid and has not expired.
- Make sure the model name is exactly claude-sonnet-5 in your analyzer.py.
- If you recently created your Anthropic account, verify you have API credits available in your dashboard.
- help me debug why my Claude API call is failing
Your API now has intelligence built in. Every log that comes in gets triaged by Claude before it hits the database. Next up, you will containerize this stack with Docker Compose and swap SQLite for PostgreSQL so your data survives restarts.
Containerize with Docker Compose and PostgreSQL
Your API works, but it stores data in SQLite. SQLite writes to a single local file that cannot handle concurrent connections and disappears when a container restarts.
In this step, you will package your application into a Docker container and spin up a PostgreSQL database alongside it using Docker Compose. One command brings up both services, and your incident data persists even when you tear the stack down and rebuild it.
In this step, get ready to:
- Create a Dockerfile that packages the FastAPI app into a portable image.
- Define a two-service Docker Compose stack with PostgreSQL and your API.
- Verify that incident data persists across container restarts.
Create the Dockerfile for the FastAPI application
A Dockerfile tells Docker how to build an image of your application. The image bundles your code, dependencies, and a start command into a single portable artifact.
- If your local dev server is still running (from the previous step), press Ctrl+C in that terminal to stop it.
- Create a new file called Dockerfile in the root of your incident-response-platform folder by running:
touch Dockerfile
- Open Dockerfile in VS Code and paste the following:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- FROM python:3.12-slim starts from a lightweight Python image.
- COPY requirements.txt and RUN pip install install dependencies first so Docker can cache this layer and skip it on rebuilds.
- COPY . . copies your application code into the container.
- CMD starts uvicorn on all interfaces (0.0.0.0) so Docker can forward traffic into the container.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
Make sure you created it in the root of incident-response-platform, not inside the app/ directory. Run pwd to check your current location.
help me figure out why my Dockerfile isn't in the right place
Create the Docker Compose configuration
Docker Compose lets you define multiple services in a single YAML file. Your stack needs two services: a PostgreSQL database and your FastAPI application.
- Create docker-compose.yml in the root of incident-response-platform by running:
touch docker-compose.yml
- Open docker-compose.yml and paste the following:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: incidents
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/incidents
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL}
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
What does this configuration do?
The db service runs PostgreSQL with a health check. The pg_isready command pings the database every 5 seconds until it accepts connections.
The api service builds your app from the Dockerfile. The depends_on with condition: service_healthy tells Compose to wait until PostgreSQL is ready before starting your API.
The ${ANTHROPIC_API_KEY} syntax pulls values from your .env file automatically. The named postgres_data volume stores database files on your host so data survives container restarts.
- Save docker-compose.yml.
Why PostgreSQL instead of SQLite?
SQLite stores data in a single file that lives inside the container. When the container restarts, that file is gone.
PostgreSQL runs as its own service with data stored in a persistent volume. It handles multiple concurrent connections and survives container rebuilds.
Build and verify data persistence
Your app/database.py already reads the DATABASE_URL environment variable and falls back to SQLite when unset. Docker Compose passes DATABASE_URL pointing to the PostgreSQL container, so no code change is needed.
- Build and start the entire stack by running:
docker compose up --build
What should I see?
Docker will pull the postgres:16-alpine image, build your API image, and start both containers. You will see health check logs from the db service followed by uvicorn's startup message once the API is ready.
- Wait until you see a line containing Uvicorn running on http://0.0.0.0:8000 in the output.
- Open a new terminal tab, then POST a sample log to the API by running:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "ERROR: disk full on /dev/sda1, write failed", "service_name": "storage-service"}'
You should see a JSON response with a severity, root cause, and suggested fix populated by Claude.
Seeing a connection error?
If the API container exited before PostgreSQL was healthy, stop everything with Ctrl+C and run docker compose up --build again. The health check retries up to 5 times, so a slow first pull may cause a timeout.
help me troubleshoot my Docker Compose setup for the incident response platform
Now for the real test. Before you run the next commands, do you think your incident data will still be there after stopping and restarting the containers?
- Stop the stack by pressing Ctrl+C in the terminal running Docker Compose.
- Bring the stack back up by running:
docker compose up
- Once the API is running again, query your stored incidents by running:
curl http://localhost:8000/incidents
Your previously stored incident appears in the response. The named volume kept the PostgreSQL data intact across the restart.
Incidents list is empty?
Make sure you used docker compose up (without --build) for the restart, not docker compose down -v which deletes the volume. The -v flag removes named volumes and wipes all stored data.
help me figure out why my data didn't persist after restarting Docker Compose
✔️ Awesome, I've got everything!
Great. Double check you have saved both Dockerfile and docker-compose.yml.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Here is the complete docker-compose.yml:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: incidents
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/incidents
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL}
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:
And the relevant line in app/database.py that makes this work:
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./incidents.db")
Your application now runs as a multi-service Docker stack with persistent storage. Next up, you will wire in Discord webhook notifications so critical incidents actively alert your team.
Add Discord Webhook Notifications
Your incident database now stores AI-analyzed logs with severity classifications and root-cause explanations. But an incident database is only useful if someone is actively watching it.
Critical issues need to actively alert your team the moment they are detected. In this step, you will wire up a Discord webhook so that every critical incident triggers a real-time notification in your channel.
In this step, get ready to:
- Create a Discord webhook and connect it to your project.
- Write the notifier module that sends rich embed alerts.
- Wire notifications into the incident creation flow so critical incidents alert automatically.
Create a Discord webhook
A webhook is an HTTP endpoint tied to a specific Discord channel. When your API POSTs a JSON payload to that endpoint, the message appears in the channel instantly.
- Open your Discord server in the Discord app (desktop or browser).
- Click the server name in the upper-left corner to open the drop-down menu.
- Select Server Settings.
- Click Integrations in the left sidebar.
- Click Create Webhook.
- Name the webhook Incident Alerts.
- Use the channel dropdown to select the text channel where you want alerts to appear.
- Click Copy Webhook URL to copy the URL to your clipboard.
What is a webhook URL?
The URL is your API's key to posting messages into Discord. Anyone with this URL can post to your channel, so treat it like a password and never commit it to version control.
- Switch back to VS Code and open the .env file in your project root.
- Add a new line with your webhook URL:
DISCORD_WEBHOOK_URL=your-discord-webhook-url-here
- Replace your-discord-webhook-url-here with the URL you just copied from Discord.
- Save .env.
Why not a variable placeholder?
The webhook URL is a secret that grants write access to your Discord channel. Paste it directly and never commit this file to Git.
Write the Discord notifier module
The notifier module reads the webhook URL from the environment and POSTs a rich embed payload to Discord when called.
- Open app/notifier.py in VS Code (it is currently empty).
- Add the imports and webhook URL setup by pasting the following code:
import os
import httpx
# Read the webhook URL from environment variables
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
def send_discord_alert(incident) -> bool:
# Skip if no webhook URL is configured
if not DISCORD_WEBHOOK_URL:
print("DISCORD_WEBHOOK_URL not set, skipping notification")
return False
What does this code do?
- os.getenv("DISCORD_WEBHOOK_URL", "") reads the webhook URL from the environment, defaulting to an empty string if missing.
- The guard clause at the top of send_discord_alert() prevents crashes if the URL is not set. It prints a warning and returns False instead.
- Add the payload construction directly below the guard clause (still inside the function):
# Build the Discord embed payload
payload = {
"content": "**CRITICAL INCIDENT** - " + incident.service_name,
"embeds": [
{
"title": "Incident #" + str(incident.id),
"color": 15158332,
"fields": [
{
"name": "Service",
"value": incident.service_name,
"inline": True,
},
{
"name": "Severity",
"value": incident.severity.upper(),
"inline": True,
},
{
"name": "Root Cause",
"value": incident.root_cause[:1024],
"inline": False,
},
{
"name": "Suggested Fix",
"value": incident.suggested_fix[:1024],
"inline": False,
},
],
}
],
}
What does this payload do?
Discord webhooks accept a JSON body with content (a plain text message) and embeds (rich formatted cards).
- color: 15158332 renders as red, signalling a critical alert.
- Each field in the embed displays a labeled piece of information (Service, Severity, Root Cause, Suggested Fix).
- The [:1024] slice ensures long text does not exceed Discord's embed field limit.
- Add the HTTP send logic directly below the payload (still inside the function):
# Send the payload to Discord
try:
response = httpx.post(DISCORD_WEBHOOK_URL, json=payload)
response.raise_for_status()
return True
except Exception as e:
print(f"Failed to send Discord alert: {e}")
return False
What does this code do?
- httpx.post() sends the JSON payload to the Discord webhook endpoint.
- raise_for_status() throws an exception if Discord returns an error (like 400 or 404).
- The try/except ensures a failed notification never crashes the main API. It logs the error and returns False.
- Save app/notifier.py.
- Verify the module loads without syntax errors by running this in your terminal:
python -c "from app.notifier import send_discord_alert; print('Module loaded')"
You should see Module loaded printed with no errors.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see (venv) or similar in your terminal prompt.
- If httpx is not found, run pip install -r requirements.txt again.
Why can't Python find my notifier module?
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/notifier.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/notifier.py file. Compare yours against it:
import os
import httpx
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
def send_discord_alert(incident) -> bool:
if not DISCORD_WEBHOOK_URL:
print("DISCORD_WEBHOOK_URL not set, skipping notification")
return False
payload = {
"content": "**CRITICAL INCIDENT** - " + incident.service_name,
"embeds": [
{
"title": "Incident #" + str(incident.id),
"color": 15158332,
"fields": [
{
"name": "Service",
"value": incident.service_name,
"inline": True,
},
The file continues with the Severity, Root Cause, and Suggested Fix fields, then the try/except block. Scroll up and confirm each section is present in your file, in order.
Wire notifications into the incident creation flow
The notifier module is ready but nothing calls it yet. You need to import it in app/main.py and trigger an alert whenever a critical incident is stored.
- Open app/main.py and find this import line near the top:
from app.analyzer import analyze_log
- Add a new import line directly below it so both lines read:
from app.analyzer import analyze_log
from app.notifier import send_discord_alert
- Next, scroll down to the create_incident function and find this line:
db.refresh(incident)
- Add the following conditional alert directly below db.refresh(incident) and above the return statement:
if analysis["severity"] == "critical":
send_discord_alert(incident)
What does this change do?
After storing a new incident, the code checks whether the AI classified it as critical. If so, it calls send_discord_alert() to fire off a Discord notification before returning the response to the caller.
- Save app/main.py.
- Rebuild and start the Docker Compose stack by running:
docker compose up --build
Wait until you see log output indicating both services are healthy and the API is listening on port 8000.
Before you test, predict: if you POST a log describing a critical database failure, do you think you will see a message in your Discord channel?
- Open a new terminal tab and POST a critical error log by running:
curl -X POST http://localhost:8000/incidents \
-H "Content-Type: application/json" \
-d '{"raw_log": "FATAL: database connection pool exhausted. All 100 connections in use. New requests are being rejected. Service has been unresponsive for 45 seconds.", "service_name": "payment-service"}'
Within a few seconds, you should see a red embed alert appear in your Discord channel with the incident number, service name, severity, root cause, and suggested fix.
No message in Discord?
- Check that DISCORD_WEBHOOK_URL in your .env file is the complete URL you copied from Discord (it starts with https://discord.com/api/webhooks/).
- Look at the Docker logs for a line saying DISCORD_WEBHOOK_URL not set, skipping notification. If you see it, your .env is not being read. Make sure docker-compose.yml references ${DISCORD_WEBHOOK_URL} and your .env file is in the project root.
- If the API response shows severity as something other than critical, the AI classified it differently. Try a more explicitly critical log message.
Why isn't my Discord webhook receiving messages?
Your platform now actively alerts you the moment something critical happens. No more checking dashboards manually. In the Secret Mission, you will take this further with escalating re-notifications for incidents nobody acknowledges.
Secret mission
Add Acknowledgment with Escalating Re-notifications
A single Discord alert is easy to miss. Real incident response systems escalate until a human explicitly acknowledges the problem. In this bonus challenge, you will add an acknowledgment endpoint and a background task that re-alerts after 30 seconds if nobody responds.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker containers, so there are no ongoing cloud costs.
Resources you used:
- Docker containers (api and db services) running locally.
- PostgreSQL named volume (postgres_data) storing your incident data.
- Project directory (incident-response-platform/) on your Desktop with application code and virtual environment.
- Discord webhook (Incident Alerts) configured in your Discord server.
- Claude API usage during development (fractions of a cent per call, total under $0.50).
Keep everything running
No action needed. Choose this if you are still actively building or testing your incident response platform.
- Leave docker compose up running to continue submitting logs and testing alerts.
- Your Docker containers only consume local CPU and memory. There are no cloud charges.
- The Claude API is only called when you POST a new log. No idle charges apply.
- Your Discord webhook remains active and ready to receive alerts.
Pause - I'll come back to this later
Shut down running containers to free up local memory while keeping your code and data intact.
- Stop your Docker containers by running this command from the incident-response-platform/ directory:
- This stops both the api and db containers and removes them from Docker, but your data persists in the postgres_data volume.
docker compose down
- When you are ready to resume, run docker compose up --build from the same directory. Your previously stored incidents will still be there.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
- Stop containers and delete the PostgreSQL data volume by running this from the incident-response-platform/ directory:
docker compose down -v --rmi all
- This stops containers, removes the postgres_data volume, and removes the Docker images built for this project.
- Delete the project directory from your Desktop. On macOS or Linux:
rm -rf ~/Desktop/incident-response-platform
On Windows (PowerShell):
Remove-Item -Recurse -Force ~\Desktop\incident-response-platform
- Delete the Discord webhook from your server. Open your Discord server, go to Server Settings, select Integrations, then Webhooks. Find the Incident Alerts webhook, select it, and click Delete.
Nice Work!
Nice Work!
Nice work! You just built a production-grade incident response platform that turns messy application logs into actionable intelligence.
You've learned how to:
- Build a FastAPI REST API that accepts raw logs and uses the Claude Sonnet 5 API to perform AI-powered root-cause analysis with severity classification.
- Containerize a multi-service stack with Docker Compose, orchestrating a FastAPI service alongside a PostgreSQL database with persistent volumes and health checks.
- Implement real-time Discord webhook notifications that fire automatically when critical incidents are detected.
- Secret Mission: Added an acknowledgment system with escalating re-notifications for unresolved critical incidents.
Ready to quiz yourself?