log0

Running Locally

Step-by-step guide to run the full log0 pipeline on your machine - the one-command Docker stack, environment variables, all seven services, health checks, log submission, and Redpanda/PostgreSQL inspection.

Prerequisites

Make sure these are installed before you begin:

ToolVersionNotes
Java25+Six backend services; java -version to verify
Maven3.9.12+Included via mvnw wrapper - no separate install needed
Python3.13+ai-service only; install via uv
Docker DesktopLatestMust be running before Step 1
GitAnyTo clone the repo
Postman (optional)LatestFor the Postman collection in refs/postman/

Two Ways to Run

The fast path is the unified Docker stack: one command builds and runs all seven services plus Redpanda, PostgreSQL, and ClickHouse - the same setup that's deployed.

cd log0-services/docker
docker compose up -d --build
# add the tunnel overlay to expose it publicly (see the Deployment page):
# docker compose -f docker-compose.yml -f docker-compose.tunnel.yml up -d --build

That's all you need to get the platform running - skip to Step 4 - Health Checks. Set the service .env files (Step 2) first if you want Slack notifications or AI summaries.

The stack uses Redpanda (Kafka-API compatible, no Zookeeper) as the broker. Six Java services share one Maven Dockerfile; ai-service builds from its own Python Dockerfile in services/ai-service. Java containers get hostnames via SPRING_APPLICATION_JSON; ai-service uses INCIDENT_SERVICE_URL and its .env file. See Deployment for the full picture.

The manual path below runs each service individually on bare metal - useful when you want to debug a single service in your IDE while the rest run in Docker.


Step 1 - Start Infrastructure Only

If you're taking the manual path, start just the infrastructure (Redpanda, PostgreSQL, ClickHouse) and run the services yourself. From log0-services/docker, bring up only the infra containers. This also auto-creates all required topics via the redpanda-init container.

cd docker
docker-compose up -d
cd docker
docker-compose up -d
cd docker
docker-compose up -d

Verify all containers are running:

docker ps
docker ps
docker ps

You should see log0-redpanda, log0-postgres, and log0-clickhouse in the output. The log0-redpanda-init container runs once to create topics and exits - that's expected. (There is no Zookeeper - Redpanda needs none.)

Verify ClickHouse is up:

curl http://localhost:8123/ping
# Expected: Ok.
Invoke-RestMethod http://localhost:8123/ping
# Expected: Ok.
curl http://localhost:8123/ping
REM Expected: Ok.

Step 2 - Environment Variables

The notification-service requires Slack credentials, the ai-service requires an LLM provider API key, and the auth-service requires a JWT signing secret at startup. The other services need no additional configuration.

Getting Your Slack Credentials

Step 2a - Create a Slack App:

  1. Go to api.slack.com/apps → Create New App → From Scratch
  2. Name it (e.g., log0-local) and select your workspace → Create App

Step 2b - Add Bot Token Scopes:

  1. In your app settings, go to OAuth & Permissions → Bot Token Scopes
  2. Add the scope: chat:write
  3. Click Install to Workspace → Allow
  4. Copy the Bot User OAuth Token - it starts with xoxb-

Step 2c - Get Your Channel ID:

  1. In Slack, open the channel where you want notifications
  2. Click the channel name at the top → scroll down → Copy channel ID (looks like C0XXXXXXXXX)
  3. Invite the bot to the channel: /invite @log0-local

Creating the .env File

Create a .env file inside services/notification-service/:

cd services/notification-service
cp .env.example .env
nano .env   # or vim, code, etc.

Fill in your real values:

SLACK_BOT_TOKEN=xoxb-your-actual-token
SLACK_CHANNEL_ID=C0XXXXXXXXX
cd services\notification-service
Copy-Item .env.example .env
notepad .env

Fill in your real values:

SLACK_BOT_TOKEN=xoxb-your-actual-token
SLACK_CHANNEL_ID=C0XXXXXXXXX
cd services\notification-service
copy .env.example .env
notepad .env

Fill in your real values:

SLACK_BOT_TOKEN=xoxb-your-actual-token
SLACK_CHANNEL_ID=C0XXXXXXXXX

The .env file is gitignored - your credentials will never be committed.

Running without Slack: You can skip this step and run the notification-service without real credentials. It will start but log {"ok":false,"error":"invalid_auth"} for each Slack call. The rest of the pipeline (ingestion → normalization → clustering → incidents) works fully without Slack.

AI Service credentials

ai-service is a Python (FastAPI) app. It uses LangChain chat models with per-purpose env vars (LLM_SUMMARY, LLM_TRIAGE, and so on) in provider:model form. Defaults use Groq (free tier).

cd services/ai-service
cp .env.example .env
nano .env
cd services\ai-service
Copy-Item .env.example .env
notepad .env
cd services\ai-service
copy .env.example .env
notepad .env

Minimum for local summaries (matches .env.example defaults):

GROQ_API_KEY=gsk_your-actual-key-here
INCIDENT_SERVICE_URL=http://localhost:8083

Set API keys for any provider you reference in LLM_* (for example OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, HF_TOKEN). In Docker, compose sets INCIDENT_SERVICE_URL=http://incident-service:8083.

LangSmith (optional): uncomment in .env when you want traces:

LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=lsv2_...
LANGCHAIN_PROJECT=log0-ai-service

The service copies these into os.environ at startup so the LangSmith SDK can export runs.

Auth Service credentials

The auth-service requires a JWT_SECRET - a random string of at least 32 characters used to sign and verify JWT tokens with HS256. Generate one and set it in services/auth-service/.env:

Step 1 - Generate a secret:

openssl rand -base64 48

Or using only built-in tools:

cat /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 48
-join ((65..90) + (97..122) + (48..57) | Get-Random -Count 48 | % {[char]$_})

This picks 48 random characters from A–Z, a–z, 0–9 and joins them into a single string. Copy the output - that's your secret.

CMD does not have a built-in random string generator. Use PowerShell instead (run powershell to open it), or manually type any 48-character random string.

Step 2 - Create the .env file:

cd services/auth-service
cp .env.example .env
nano .env   # or vim, code, etc.

Fill in your generated secret:

JWT_SECRET=your-generated-48-char-secret-here
cd services\auth-service
Copy-Item .env.example .env
notepad .env

Fill in your generated secret:

JWT_SECRET=your-generated-48-char-secret-here
cd services\auth-service
copy .env.example .env
notepad .env

Fill in your generated secret:

JWT_SECRET=your-generated-48-char-secret-here

The .env file is gitignored - your secret will never be committed.

Why at least 32 characters? HS256 (HMAC-SHA256) requires a key of at least 256 bits (32 bytes). Shorter secrets are rejected by the JWT library at startup. 48 characters from the alphanumeric set gives ~285 bits of entropy - comfortably above the minimum.


Step 3 - Start All Services

Open 7 separate terminals - one per service.

Each Java service ships with a run.sh / run.ps1 helper that loads .env (if present) and runs mvnw spring-boot:run. ai-service does not use Maven; start it with uv (see Terminal 6).

cd services/ingestion-gateway   # any Java service
./run.sh
cd services\ingestion-gateway   # any Java service
.\run.ps1

Give each service its own terminal. Services that need credentials (notification, ai, auth) read from their .env (Step 2).

If PowerShell blocks the script with an execution-policy error, run it for the current session with Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass, then re-run .\run.ps1.

The per-terminal commands below are the manual equivalent - use them if you prefer to invoke Maven directly or want to run a service from your IDE.

Terminal 1 - Ingestion Gateway (port 8080)

cd services/ingestion-gateway
./mvnw spring-boot:run
cd services\ingestion-gateway
.\mvnw.cmd spring-boot:run
cd services\ingestion-gateway
mvnw.cmd spring-boot:run

Terminal 2 - Normalization Service (port 8081)

cd services/normalization-service
./mvnw spring-boot:run
cd services\normalization-service
.\mvnw.cmd spring-boot:run
cd services\normalization-service
mvnw.cmd spring-boot:run

Terminal 3 - Clustering Service (port 8082)

cd services/clustering-service
./mvnw spring-boot:run
cd services\clustering-service
.\mvnw.cmd spring-boot:run
cd services\clustering-service
mvnw.cmd spring-boot:run

Terminal 4 - Incident Service (port 8083)

cd services/incident-service
./mvnw spring-boot:run
cd services\incident-service
.\mvnw.cmd spring-boot:run
cd services\incident-service
mvnw.cmd spring-boot:run

Terminal 5 - Notification Service (port 8084)

This service needs Slack env vars loaded before it starts.

cd services/notification-service
export $(cat .env | xargs)
./mvnw spring-boot:run
cd services\notification-service
Get-Content .env | ForEach-Object { $k,$v = $_ -split '=',2; [System.Environment]::SetEnvironmentVariable($k,$v) }
.\mvnw.cmd spring-boot:run
cd services\notification-service
for /f "tokens=1,2 delims==" %i in (.env) do set %i=%j
mvnw.cmd spring-boot:run

Terminal 6 - AI Service (port 8085)

Python service. From services/ai-service (with .env from Step 2):

cd services/ai-service
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8085
cd services\ai-service
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8085
cd services\ai-service
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8085

Run commands from services/ai-service so Python resolves the app package.

Terminal 7 - Auth Service (port 8086)

This service needs the JWT_SECRET loaded before it starts.

cd services/auth-service
export $(cat .env | grep -v '^#' | xargs)
./mvnw spring-boot:run
cd services\auth-service
Get-Content .env | Where-Object { $_ -notmatch '^#' -and $_ -match '=' } | ForEach-Object { $k,$v = $_ -split '=',2; Set-Item "env:$k" $v }
.\mvnw.cmd spring-boot:run
cd services\auth-service
for /f "tokens=1,2 delims==" %i in (.env) do @if not "%i:~0,1%"=="#" set "%i=%j"
mvnw.cmd spring-boot:run

Step 4 - Health Checks

Once all services have printed Started ... in ... seconds, verify they are all up:

curl http://localhost:8080/actuator/health
curl http://localhost:8082/actuator/health
curl http://localhost:8083/actuator/health
curl http://localhost:8084/actuator/health
curl http://localhost:8085/health
curl http://localhost:8086/actuator/health
Invoke-RestMethod http://localhost:8080/actuator/health
Invoke-RestMethod http://localhost:8082/actuator/health
Invoke-RestMethod http://localhost:8083/actuator/health
Invoke-RestMethod http://localhost:8084/actuator/health
Invoke-RestMethod http://localhost:8085/health
Invoke-RestMethod http://localhost:8086/actuator/health
curl http://localhost:8080/actuator/health
curl http://localhost:8082/actuator/health
curl http://localhost:8083/actuator/health
curl http://localhost:8084/actuator/health
curl http://localhost:8085/health
curl http://localhost:8086/actuator/health

Java services return {"status":"UP"} from /actuator/health. ai-service returns {"status":"UP","service_name":"ai-service"} from /health (also exposes /actuator/health for parity).

Note: normalization-service runs on port 8081 but its actuator is not configured by default. If http://localhost:8081/actuator/health returns a connection error, the service is still healthy as long as it's consuming from raw-logs (visible in the terminal logs).

ServicePortStackRole
ingestion-gateway8080JavaReceives logs from clients
normalization-service8081JavaParses, fingerprints, and stores logs to ClickHouse
clustering-service8082JavaCounts occurrences, triggers incidents
incident-service8083JavaStores incidents, exposes REST API (incl. log query)
notification-service8084JavaSends Slack alerts
ai-service8085PythonLLM incident summaries (FastAPI)
auth-service8086JavaTenant registration, JWT auth, API key management

Infrastructure:

ContainerPortRole
log0-redpanda9092 (internal), 19092 (host)Event streaming (Kafka API, no Zookeeper)
log0-postgres5433 → 5432Incident & tenant data
log0-clickhouse8123 (HTTP), 9000 (native)Log event storage

Step 5 - Submit a Log

Send a single test log to confirm the ingestion gateway accepts it. A 202 Accepted means the log was published to the broker (Redpanda).

curl -X POST http://localhost:8080/api/v1/logs \
  -H "Content-Type: application/json" \
  -H "X-SERVICE-NAME: payment-service" \
  -H "X-ENVIRONMENT: production" \
  -H "X-API-KEY: log0_your-raw-key-from-console" \
  -d '{
    "timestamp": "2026-03-28T10:30:00Z",
    "level": "ERROR",
    "message": "Database connection timeout after 30s",
    "trace": "java.sql.SQLException: Connection timeout"
  }'
$body = @{
    timestamp = "2026-03-28T10:30:00Z"
    level     = "ERROR"
    message   = "Database connection timeout after 30s"
    trace     = "java.sql.SQLException: Connection timeout"
} | ConvertTo-Json

Invoke-RestMethod -Uri "http://localhost:8080/api/v1/logs" `
  -Method POST `
  -ContentType "application/json" `
  -Headers @{
      "X-SERVICE-NAME" = "payment-service"
      "X-ENVIRONMENT"  = "production"
      "X-API-KEY"      = "log0_your-raw-key-from-console"
  } `
  -Body $body
curl -X POST http://localhost:8080/api/v1/logs ^
  -H "Content-Type: application/json" ^
  -H "X-SERVICE-NAME: payment-service" ^
  -H "X-ENVIRONMENT: production" ^
  -H "X-API-KEY: log0_your-raw-key-from-console" ^
  -d "{\"timestamp\":\"2026-03-28T10:30:00Z\",\"level\":\"ERROR\",\"message\":\"Database connection timeout after 30s\",\"trace\":\"java.sql.SQLException: Connection timeout\"}"

Use a real API key from the console (Settings → API keys). The gateway validates the key with auth-service and derives the tenant; placeholder values return 401.

Trigger an incident: Send the same request 10+ times with the same message and trace. Clustering uses a threshold of 10 occurrences within a 5-minute window. Postman Collection Runner with 12 iterations works.

End-to-end script: From log0-services/tools:

$env:API_KEY = "log0_...."
.\e2e-ingest-to-summary.ps1

This checks .env files, health endpoints, ingests 12 logs, waits for an incident, and waits for ai_summary. Git Bash: export API_KEY=... then ./e2e-ingest-to-summary.sh. For bulk demo data, see tools/seed-logs.js.

Verify AI summary: After a new incident, check PostgreSQL or the console incident drawer:

SELECT incident_id, service_name, status, ai_summary
FROM incident
ORDER BY created_at DESC
LIMIT 5;

The first incident row should contain a structured summary from the LLM within a few seconds of creation.

Manual AI service test (isolated)

You can also test ai-service independently without going through the full pipeline. With ai-service running, send a direct POST:

curl -X POST http://localhost:8085/api/v1/summaries \
  -H "Content-Type: application/json" \
  -d '{
    "incidentId": "00000000-0000-0000-0000-000000000001",
    "tenantId": "00000000-0000-0000-0000-000000000001",
    "serviceName": "payment-service",
    "environment": "production",
    "severity": "HIGH",
    "occurrenceCount": 42,
    "firstSeenAt": "2024-01-01T10:00:00Z",
    "lastSeenAt": "2024-01-01T11:00:00Z",
    "topMessages": [
      "Connection refused: db-host:5432",
      "Timeout waiting for connection from pool"
    ]
  }'
Invoke-RestMethod -Uri "http://localhost:8085/api/v1/summaries" -Method POST -ContentType "application/json" -Body '{"incidentId":"00000000-0000-0000-0000-000000000001","tenantId":"00000000-0000-0000-0000-000000000001","serviceName":"payment-service","environment":"production","severity":"HIGH","occurrenceCount":42,"firstSeenAt":"2024-01-01T10:00:00Z","lastSeenAt":"2024-01-01T11:00:00Z","topMessages":["Connection refused: db-host:5432","Timeout waiting for connection from pool"]}'
curl -X POST http://localhost:8085/api/v1/summaries -H "Content-Type: application/json" -d "{\"incidentId\":\"00000000-0000-0000-0000-000000000001\",\"tenantId\":\"6b1cd754-a35c-491a-9ee8-0e98dfd7b5a8\",\"serviceName\":\"payment-service\",\"environment\":\"production\",\"severity\":\"HIGH\",\"occurrenceCount\":42,\"firstSeenAt\":\"2024-01-01T10:00:00Z\",\"lastSeenAt\":\"2024-01-01T11:00:00Z\",\"topMessages\":[\"Connection refused: db-host:5432\",\"Timeout waiting for connection from pool\"]}"

Expected response: 202 Accepted. The summary runs in a background task: logs should show an LLM call, then PATCH .../ai-summary to incident-service. Use a real incidentId from Postgres if you want the PATCH to succeed.


Step 6 - Watch the Topics

Open additional terminals to watch messages flow through the pipeline in real time. The broker is Redpanda, so use its rpk CLI inside the log0-redpanda container - the classic kafka-console-consumer tooling is not bundled.

-o start reads a topic from the beginning; -f '%k -> %v\n' prints each message key alongside its value. These docker exec commands are identical on Linux/macOS, PowerShell, and CMD.

raw-logs (ingestion output)

docker exec -it log0-redpanda rpk topic consume raw-logs -o start -f '%k -> %v\n'

normalized-logs (after normalization + fingerprinting)

docker exec -it log0-redpanda rpk topic consume normalized-logs -o start -f '%k -> %v\n'

incident-events (fires after 10 occurrences of the same fingerprint)

docker exec -it log0-redpanda rpk topic consume incident-events -o start -f '%k -> %v\n'

notification-events (triggers Slack messages)

docker exec -it log0-redpanda rpk topic consume notification-events -o start -f '%k -> %v\n'

raw-logs-dlq (dead letter queue - should stay empty)

docker exec -it log0-redpanda rpk topic consume raw-logs-dlq -o start

Check consumer group lag

Zero lag means all services are caught up. Lag > 0 means a service is behind or crashed.

for group in normalization-service clustering-service incident-service notification-service; do
  echo "--- $group ---"
  docker exec -it log0-redpanda rpk group describe $group
done
foreach ($g in 'normalization-service','clustering-service','incident-service','notification-service') {
  Write-Host "--- $g ---"
  docker exec -it log0-redpanda rpk group describe $g
}

Step 7 - Inspect PostgreSQL

PostgreSQL is exposed on port 5433 (the container runs on 5432 internally; Docker maps it to 5433 on your host).

Connect to the database

docker exec -it log0-postgres psql -U log0 -d log0
docker exec -it log0-postgres psql -U log0 -d log0
docker exec -it log0-postgres psql -U log0 -d log0

Useful queries

Once inside psql, run these:

-- List all incidents (newest first)
SELECT incident_id, service_name, severity, status, occurrence_count, created_at
FROM incident
ORDER BY created_at DESC
LIMIT 10;

-- Check state transitions for a specific incident
SELECT from_status, to_status, changed_at
FROM incident_state_history
WHERE incident_id = '<your-incident-id>'
ORDER BY changed_at;

-- Count incidents per tenant
SELECT tenant_id, COUNT(*) AS total
FROM incident
GROUP BY tenant_id;

-- Exit psql
\q

Connect with an external client (pgAdmin, DBeaver, TablePlus)

SettingValue
Hostlocalhost
Port5433
Databaselog0
Usernamelog0
Passwordlog0

Step 8 - Inspect ClickHouse

ClickHouse stores every normalized log event and is queried by the incident-service to return the raw logs behind each incident. It runs on HTTP port 8123.

Query log events

# All log events (newest first)
curl -s "http://localhost:8123/?user=log0&password=log0" \
  --data "SELECT event_id, tenant_id, level, message, fingerprint, timestamp FROM log0.log_events ORDER BY timestamp DESC LIMIT 10"

# Count by fingerprint (shows which errors repeat most)
curl -s "http://localhost:8123/?user=log0&password=log0" \
  --data "SELECT fingerprint, level, count() as hits FROM log0.log_events GROUP BY fingerprint, level ORDER BY hits DESC"

# Logs for a specific incident fingerprint
curl -s "http://localhost:8123/?user=log0&password=log0" \
  --data "SELECT event_id, message, timestamp FROM log0.log_events WHERE fingerprint = '<your-fingerprint>' ORDER BY timestamp DESC LIMIT 20"
# All log events (newest first)
Invoke-WebRequest -Uri "http://localhost:8123/?user=log0&password=log0" -Method POST `
  -Body "SELECT event_id, tenant_id, level, message, fingerprint, timestamp FROM log0.log_events ORDER BY timestamp DESC LIMIT 10" `
  -UseBasicParsing | Select-Object -ExpandProperty Content

# Count by fingerprint (shows which errors repeat most)
Invoke-WebRequest -Uri "http://localhost:8123/?user=log0&password=log0" -Method POST `
  -Body "SELECT fingerprint, level, count() as hits FROM log0.log_events GROUP BY fingerprint, level ORDER BY hits DESC" `
  -UseBasicParsing | Select-Object -ExpandProperty Content

# Logs for a specific incident fingerprint
Invoke-WebRequest -Uri "http://localhost:8123/?user=log0&password=log0" -Method POST `
  -Body "SELECT event_id, message, timestamp FROM log0.log_events WHERE fingerprint = '<your-fingerprint>' ORDER BY timestamp DESC LIMIT 20" `
  -UseBasicParsing | Select-Object -ExpandProperty Content
curl -s "http://localhost:8123/?user=log0&password=log0" --data "SELECT event_id, level, message, fingerprint, timestamp FROM log0.log_events ORDER BY timestamp DESC LIMIT 10"

Query via incident API

Once you have an incident ID, you can retrieve its raw log events directly through the incident-service REST API:

curl "http://localhost:8083/api/v1/incidents/<incident-id>/logs?tenantId=<tenant-id>&page=0&size=50"
Invoke-RestMethod "http://localhost:8083/api/v1/incidents/<incident-id>/logs?tenantId=<tenant-id>&page=0&size=50"
curl "http://localhost:8083/api/v1/incidents/<incident-id>/logs?tenantId=<tenant-id>&page=0&size=50"

Returns a JSON array of log events ordered newest-first. Supports page (0-based) and size (max 200) query params.

Connect with an external client (DBeaver, DataGrip, TablePlus)

SettingValue
Hostlocalhost
Port8123 (HTTP) or 9000 (native TCP)
Databaselog0
Usernamelog0
Passwordlog0
DriverClickHouse

Pipeline at a Glance

POST /api/v1/logs (8080)
  → raw-logs (Kafka)
    → normalization-service (8081)
      ├── → ClickHouse log_events table (log storage)
      └── → normalized-logs (Kafka)
            → clustering-service (8082)
              → incident-events (Kafka)  ← fires at 10 occurrences
                → incident-service (8083) → PostgreSQL (incident table)
                    ├── → POST /api/v1/summaries (8085, async)
                    │       → ai-service (Python) → LLM (Groq default)
                    │           → PATCH /api/v1/incidents/{id}/ai-summary (8083)
                    │               → incident.ai_summary saved to PostgreSQL
                    └── → notification-events (Kafka)
                              → notification-service (8084) → Slack

GET /api/v1/incidents/{id}/logs (8083)
  → ClickHouse log_events (query by tenant_id + fingerprint)

A full end-to-end test (10 logs → Slack message + AI summary) takes about 3–5 seconds under normal load.

How is this guide?

On this page