Files
rmyndharis--openwa/docs/10-devops-infrastructure.md
wehub-resource-sync 4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:24:08 +08:00

1185 lines
33 KiB
Markdown

# 10 - DevOps & Infrastructure
> **⚠️ Conceptual reference.** Some examples here predate the shipped runtime and may not
> match it exactly. The **authoritative** sources are the repo's `Dockerfile`, `docker-compose.yml`
> (Docker socket-proxy threat model, gosu non-root drop, loopback-bound datastores, container
> hardening), and `.env.example` (canonical env var names). Where this doc and those disagree,
> the files win. In particular: the API master key env is `API_MASTER_KEY`, datastores have no
> default credentials, and production migrations use `npm run migration:run:prod`.
## 10.1 Infrastructure Overview
```mermaid
flowchart TB
subgraph Development["Development"]
DEV[Local Docker Compose]
end
subgraph Staging["Staging"]
STG[Single Server]
end
subgraph Production["Production"]
LB[Load Balancer]
LB --> APP1[App Instance 1]
LB --> APP2[App Instance 2]
APP1 --> DB[(PostgreSQL)]
APP2 --> DB
APP1 --> REDIS[(Redis)]
APP2 --> REDIS
end
DEV --> |deploy| STG
STG --> |promote| Production
```
## 10.2 Docker Configuration
### Dockerfile
```dockerfile
# Dockerfile (multi-stage build)
# Build stage
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:22-slim
# Install Chrome dependencies (avoid Debian's chromium package due to SIGTRAP in non-root)
RUN apt-get update && apt-get install -y \
curl \
fonts-ipafont-gothic \
fonts-wqy-zenhei \
fonts-thai-tlwg \
fonts-kacst \
fonts-freefont-ttf \
libxss1 \
libnss3 \
libnspr4 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libasound2 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Set Puppeteer skip download (we install it dynamically later)
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
# Create app directory
WORKDIR /app
# Copy package files & install production dependencies
COPY package*.json ./
RUN npm ci --only=production
# NOTE: Chrome for Testing has no linux-arm64 build, so this example targets linux/amd64.
# For arm64, install Debian's `chromium` package and point PUPPETEER_EXECUTABLE_PATH to
# /usr/bin/chromium — see the repo's Dockerfile for the mixed multi-arch build.
# Download Chrome for Testing via Puppeteer and point ENV to it
RUN mkdir -p /opt/puppeteer && \
PUPPETEER_CACHE_DIR=/opt/puppeteer ./node_modules/.bin/puppeteer browsers install 'chrome@146.0.7680.31' && \
chrome_path=$(find /opt/puppeteer/chrome/linux*/chrome-linux64/chrome | head -n 1) && \
test -n "$chrome_path" && \
ln -s "$chrome_path" /usr/local/bin/puppeteer-chrome
ENV PUPPETEER_EXECUTABLE_PATH=/usr/local/bin/puppeteer-chrome
# Copy build output
COPY --from=build /app/dist ./dist
# Create non-root user
RUN groupadd -r openwa && useradd -r -g openwa openwa
RUN chown -R openwa:openwa /app /opt/puppeteer
USER openwa
# Expose port
EXPOSE 2785
# Health check (global API prefix is 'api'; readiness probes both databases)
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s \
CMD curl -f http://localhost:2785/api/health/ready || exit 1
# Start app
CMD ["node", "dist/main.js"]
```
### Docker Compose (Development)
```yaml
# docker-compose.yml
version: '3.8'
services:
app:
build:
context: .
target: build
command: npm run start:dev
ports:
- "2785:2785"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://openwa:openwa@postgres:5432/openwa
- REDIS_URL=redis://redis:6379
# The env var is API_MASTER_KEY (not API_KEY_MASTER); never hardcode a key — set a
# strong secret. Production refuses to boot with a placeholder/default.
- API_MASTER_KEY=
volumes:
- ./:/app
- /app/node_modules
- session-data:/app/.wwebjs_auth
depends_on:
- postgres
- redis
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=openwa
- POSTGRES_PASSWORD=openwa
- POSTGRES_DB=openwa
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
ports:
- "6379:6379"
# No separate dashboard service: the `app` image bundles the dashboard SPA and serves it
# from the same port (2785) via NestJS. Open http://localhost:2785 for the UI.
volumes:
postgres-data:
redis-data:
session-data:
```
### Docker Compose (Production)
```yaml
# docker-compose.prod.yml
version: '3.8'
services:
app:
image: ghcr.io/rmyndharis/openwa:latest
deploy:
replicas: 1
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- API_MASTER_KEY=${API_MASTER_KEY}
volumes:
- session-data:/app/.wwebjs_auth
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:2785/api/health/ready"]
interval: 30s
timeout: 10s
retries: 3
restart: always
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
restart: always
volumes:
session-data:
driver: local
```
> [!IMPORTANT]
> **Keep `replicas: 1`.** OpenWA is a single-process application: live engine state lives in an
> in-memory `Map` in `SessionService` (`src/modules/session/session.service.ts`). Multi-replica is
> **not** a supported topology — running two replicas against a shared `SESSION_DATA_PATH` makes two
> browsers write the same WhatsApp LocalAuth directory and **corrupts the session** (forced logout /
> ban). Shared storage and sticky sessions do **not** make multi-replica safe. See
> [13 - Horizontal Scaling Guide](./13-horizontal-scaling.md) for the `replicas: 1` stance and the
> (unimplemented) session-claim design that would be required first.
## 10.3 CI/CD Pipeline
### GitHub Actions Workflow
```yaml
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm run test:cov
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
build:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to Staging
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
script: |
cd /opt/openwa
docker compose pull
docker compose up -d
docker system prune -f
deploy-production:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to Production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/openwa
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --no-deps app
docker system prune -f
```
## 10.4 Deployment Architecture
### Single Server Deployment
```mermaid
flowchart TB
subgraph Server["Single Server"]
NGINX[Nginx Reverse Proxy]
NGINX --> APP[OpenWA App]
APP --> PG[(PostgreSQL)]
APP --> RD[(Redis)]
APP --> FS[File Storage]
end
Internet --> NGINX
```
### Multi-Server Deployment
> **Design sketch, not a supported topology.** OpenWA is single-process with in-memory engine state,
> so the multi-`OpenWA` fan-out below would corrupt WhatsApp auth across replicas. It is retained only
> as the target architecture once the session-claim design in
> [13 - Horizontal Scaling Guide](./13-horizontal-scaling.md) is implemented. Deploy with `replicas: 1`.
```mermaid
flowchart TB
subgraph External["External"]
CDN[CDN / CloudFlare]
end
subgraph LoadBalancer["Load Balancer"]
LB[HAProxy / Nginx]
end
subgraph AppServers["Application Servers"]
APP1[OpenWA 1]
APP2[OpenWA 2]
APP3[OpenWA N]
end
subgraph DataLayer["Data Layer"]
PG[(PostgreSQL Primary)]
PGR[(PostgreSQL Replica)]
RD[(Redis Cluster)]
S3[(S3 Storage)]
end
CDN --> LB
LB --> APP1 & APP2 & APP3
APP1 & APP2 & APP3 --> PG
APP1 & APP2 & APP3 --> RD
APP1 & APP2 & APP3 --> S3
PG --> PGR
```
## 10.5 Environment Configuration
### Environment Variables
```bash
# .env.example
# ===========================================
# APPLICATION
# ===========================================
NODE_ENV=production
PORT=2785
API_PREFIX=/api
LOG_LEVEL=info
LOG_FORMAT=json
# ===========================================
# DATABASE (choose one)
# ===========================================
# Option 1: SQLite (for minimal deployments)
DATABASE_TYPE=sqlite
DATABASE_SQLITE_PATH=./data/openwa.db
# Option 2: PostgreSQL (for production)
# DATABASE_TYPE=postgres
# DATABASE_URL=postgresql://user:pass@localhost:5432/openwa
# DATABASE_POOL_MAX=20
# DATABASE_SSL=false
# ===========================================
# MEDIA STORAGE (choose one)
# ===========================================
# Option 1: Local filesystem (default)
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./media
STORAGE_LOCAL_BASE_URL=/media
# Option 2: S3
# STORAGE_TYPE=s3
# STORAGE_S3_BUCKET=openwa-media
# STORAGE_S3_REGION=ap-southeast-1
# STORAGE_S3_ACCESS_KEY_ID=your-access-key
# STORAGE_S3_SECRET_ACCESS_KEY=your-secret-key
# Option 3: MinIO (S3-compatible)
# STORAGE_TYPE=minio
# STORAGE_S3_BUCKET=openwa-media
# STORAGE_S3_ENDPOINT=http://minio:9000
# STORAGE_S3_ACCESS_KEY_ID=minioadmin
# STORAGE_S3_SECRET_ACCESS_KEY=minioadmin
# STORAGE_S3_FORCE_PATH_STYLE=true
# ===========================================
# CACHE & QUEUE (choose one)
# ===========================================
# Option 1: In-Memory (for single instance)
CACHE_TYPE=memory
CACHE_TTL=300
CACHE_MAX=1000
# Option 2: Redis (for multi-instance / production)
# CACHE_TYPE=redis
# REDIS_URL=redis://localhost:6379
# ===========================================
# WHATSAPP ENGINE
# ===========================================
ENGINE_TYPE=whatsapp-web.js
# ENGINE_TYPE=baileys
# ENGINE_TYPE=baileys # whatsapp-web.js (default) | baileys; omit to use the dashboard selection
# Session
SESSION_DATA_PATH=./.wwebjs_auth
# Puppeteer (for whatsapp-web.js)
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
PUPPETEER_HEADLESS=true
PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox
# ===========================================
# SECURITY
# ===========================================
# Generate with: openssl rand -base64 32
API_MASTER_KEY=your-master-api-key
# Optional HMAC pepper so a DB leak alone can't precompute key hashes
API_KEY_PEPPER=optional-key-hashing-pepper
# ===========================================
# WEBHOOK
# ===========================================
WEBHOOK_TIMEOUT=30000
WEBHOOK_RETRY_COUNT=3
WEBHOOK_RETRY_DELAY=5000
# ===========================================
# RATE LIMITING
# ===========================================
# Three global per-IP windows (short/medium/long); defaults shown
RATE_LIMIT_MEDIUM_TTL=60000
RATE_LIMIT_MEDIUM_LIMIT=100
```
### Configuration Service
```typescript
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT, 10) || 3000,
database: {
url: process.env.DATABASE_URL,
},
redis: {
url: process.env.REDIS_URL,
},
security: {
masterApiKey: process.env.API_MASTER_KEY,
},
session: {
dataPath: process.env.SESSION_DATA_PATH || './.wwebjs_auth',
},
webhook: {
timeout: parseInt(process.env.WEBHOOK_TIMEOUT, 10) || 30000,
retryCount: parseInt(process.env.WEBHOOK_RETRY_COUNT, 10) || 3,
retryDelay: parseInt(process.env.WEBHOOK_RETRY_DELAY, 10) || 5000,
},
rateLimit: {
shortTtl: parseInt(process.env.RATE_LIMIT_SHORT_TTL, 10) || 1000,
shortLimit: parseInt(process.env.RATE_LIMIT_SHORT_LIMIT, 10) || 10,
mediumTtl: parseInt(process.env.RATE_LIMIT_MEDIUM_TTL, 10) || 60000,
mediumLimit: parseInt(process.env.RATE_LIMIT_MEDIUM_LIMIT, 10) || 100,
longTtl: parseInt(process.env.RATE_LIMIT_LONG_TTL, 10) || 3600000,
longLimit: parseInt(process.env.RATE_LIMIT_LONG_LIMIT, 10) || 1000,
},
puppeteer: {
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: process.env.PUPPETEER_HEADLESS !== 'false',
args: process.env.PUPPETEER_ARGS?.split(',') || [],
},
});
```
## 10.6 Monitoring & Observability
### Monitoring Stack
```mermaid
flowchart LR
subgraph App["Application"]
METRICS[Metrics Endpoint]
LOGS[Structured Logs]
TRACES[Traces]
end
subgraph Collection["Collection"]
PROM[Prometheus]
LOKI[Loki]
TEMPO[Tempo]
end
subgraph Visualization["Visualization"]
GRAF[Grafana]
end
subgraph Alerting["Alerting"]
AM[AlertManager]
SLACK[Slack]
EMAIL[Email]
end
METRICS --> PROM --> GRAF
LOGS --> LOKI --> GRAF
TRACES --> TEMPO --> GRAF
PROM --> AM
AM --> SLACK & EMAIL
```
### Docker Compose Monitoring Stack
```yaml
# docker-compose.monitoring.yml
version: '3.8'
services:
prometheus:
image: prom/prometheus:v2.47.0
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- ./monitoring/alerts.yml:/etc/prometheus/alerts.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
ports:
- "9090:9090"
restart: unless-stopped
grafana:
image: grafana/grafana:10.1.0
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards
- grafana-data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
ports:
- "3001:3000"
depends_on:
- prometheus
- loki
restart: unless-stopped
loki:
image: grafana/loki:2.9.0
volumes:
- ./monitoring/loki.yml:/etc/loki/local-config.yaml
- loki-data:/loki
command: -config.file=/etc/loki/local-config.yaml
ports:
- "3100:3100"
restart: unless-stopped
promtail:
image: grafana/promtail:2.9.0
volumes:
- ./monitoring/promtail.yml:/etc/promtail/config.yml
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.26.0
volumes:
- ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml
ports:
- "9093:9093"
restart: unless-stopped
node-exporter:
image: prom/node-exporter:v1.6.1
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
ports:
- "9100:9100"
restart: unless-stopped
volumes:
prometheus-data:
grafana-data:
loki-data:
```
### Prometheus Configuration
```yaml
# monitoring/prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
rule_files:
- 'alerts.yml'
scrape_configs:
- job_name: 'openwa'
static_configs:
- targets: ['app:2785']
metrics_path: '/api/metrics'
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
```
### Alert Rules
These rules use the metric names OpenWA actually exports (`openwa_*`). The memory rule below uses a
node-exporter metric — an **external** exporter, not the app — and is kept as a host-level example.
```yaml
# monitoring/alerts.yml
groups:
- name: openwa-alerts
rules:
# Service Down — openwa_up disappears (or the scrape fails)
- alert: ServiceDown
expr: up{job="openwa"} == 0 or absent(openwa_up)
for: 1m
labels:
severity: critical
annotations:
summary: "OpenWA service is down"
description: "The OpenWA application is not responding"
# Session(s) disconnected
- alert: SessionDisconnected
expr: openwa_sessions{status="disconnected"} > 0
for: 2m
labels:
severity: warning
annotations:
summary: "WhatsApp session disconnected"
description: "{{ $value }} session(s) in disconnected state"
# Failed messages climbing
- alert: FailedMessagesRising
expr: rate(openwa_messages_failed_total[5m]) > 0
for: 5m
labels:
severity: warning
annotations:
summary: "Messages are failing"
description: "openwa_messages_failed_total is increasing over the last 5 minutes"
# Process memory growth (app-exported RSS; ~2GB example threshold)
- alert: HighProcessMemory
expr: openwa_process_resident_memory_bytes > 2e9
for: 10m
labels:
severity: warning
annotations:
summary: "High OpenWA process memory"
description: "RSS is {{ $value | humanize1024 }}B"
# Host memory pressure — EXTERNAL (node-exporter), not exported by OpenWA
- alert: HighHostMemoryUsage
expr: |
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes)
/ node_memory_MemTotal_bytes > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "High host memory usage"
description: "Host memory usage is {{ $value | humanizePercentage }}"
```
### AlertManager Configuration
```yaml
# monitoring/alertmanager.yml
global:
resolve_timeout: 5m
slack_api_url: '${SLACK_WEBHOOK_URL}'
route:
group_by: ['alertname', 'severity']
group_wait: 10s
group_interval: 10s
repeat_interval: 1h
receiver: 'slack-notifications'
routes:
- match:
severity: critical
receiver: 'slack-critical'
- match:
severity: warning
receiver: 'slack-warnings'
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#openwa-alerts'
send_resolved: true
- name: 'slack-critical'
slack_configs:
- channel: '#openwa-critical'
send_resolved: true
title: '🚨 CRITICAL: {{ .GroupLabels.alertname }}'
text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
- name: 'slack-warnings'
slack_configs:
- channel: '#openwa-alerts'
send_resolved: true
title: '⚠️ WARNING: {{ .GroupLabels.alertname }}'
```
### Health Check Endpoint
All health endpoints are `@Public()` (no API key) and `@SkipThrottle()`, and live under the global
`api` prefix. There is **no** `/health/detailed` endpoint.
| Endpoint | Purpose | Body | Codes |
|----------|---------|------|-------|
| `GET /api/health` | Basic check | `{ status, timestamp, version }` (version from `package.json`) | 200 |
| `GET /api/health/live` | Liveness (deliberately static — a transient dependency outage must not KILL the pod) | `{ status: 'ok' }` | 200 |
| `GET /api/health/ready` | Readiness — probes **both** databases (`main` + `data`, `SELECT 1`, 3s timeout each) and reports 503 while draining (graceful shutdown) | `{ status, details: { mainDatabase, dataDatabase } }` | 200 / 503 |
```typescript
// health/health.controller.ts
@Controller('health')
@Public() // no API key required
@SkipThrottle()
export class HealthController {
@Get()
check(): { status: string; timestamp: string; version: string } {
return { status: 'ok', timestamp: new Date().toISOString(), version: APP_VERSION };
}
@Get('live')
liveness(): { status: string } {
return { status: 'ok' };
}
@Get('ready')
async readiness(): Promise<HealthCheckResult> {
// 503 while draining so the LB stops routing before teardown.
if (this.shutdownService.isShuttingDown()) {
throw new ServiceUnavailableException({ status: 'error', details: { shutdown: { status: 'draining' } } });
}
const [main, data] = await Promise.all([
this.probeDatabase(this.mainDataSource),
this.probeDatabase(this.dataDataSource),
]);
const details = { mainDatabase: { status: main }, dataDatabase: { status: data } };
if (main === 'down' || data === 'down') {
throw new ServiceUnavailableException({ status: 'error', details });
}
return { status: 'ok', details };
}
}
```
### Prometheus Metrics Implementation
The metrics surface is small, so OpenWA emits Prometheus text exposition format (v0.0.4) **by hand**
there is **no `prom-client` dependency** and **no `collectDefaultMetrics`**. `MetricsService` reads an
aggregate overview from `StatsService` plus `process.memoryUsage()`, memoizes the rendered text for a
short TTL (~5s, so back-to-back scrapes don't repeat the DB scan), and exposes it at
`GET /api/metrics`.
Access is **disabled by default**: the endpoint returns **404** unless `METRICS_TOKEN` is set. When
set, scrapers must send `Authorization: Bearer <token>` (compared with `timingSafeEqual`); a missing or
wrong token returns 401. The token is **separate** from the API key — the route is `@Public()` (skips
the API-key guard) and `@SkipThrottle()`.
```typescript
// metrics/metrics.service.ts (dependency-free; emits text v0.0.4 by hand)
@Injectable()
export class MetricsService {
constructor(
private readonly config: ConfigService,
private readonly statsService: StatsService,
) {}
async render(): Promise<string> {
const overview = await this.statsService.getOverview();
const mem = process.memoryUsage();
const lines: string[] = [];
// ... gauge() helper pushes `# HELP` / `# TYPE` / value lines ...
gauge('openwa_up', '...', 1);
gauge('openwa_process_uptime_seconds', '...', Math.round(process.uptime()));
gauge('openwa_process_resident_memory_bytes', '...', mem.rss);
gauge('openwa_process_heap_used_bytes', '...', mem.heapUsed);
gauge('openwa_sessions_total', '...', overview.sessions.total);
gauge('openwa_sessions_active', '...', overview.sessions.active);
// openwa_sessions{status="..."} — one line per status
// openwa_messages_total{direction="outgoing"|"incoming"}
// openwa_messages_failed_total
return lines.join('\n') + '\n';
}
}
```
**Exported metric names** (the complete set — nothing else is emitted):
| Metric | Type | Labels | Meaning |
|--------|------|--------|---------|
| `openwa_up` | gauge | — | Always `1` when scraped |
| `openwa_process_uptime_seconds` | gauge | — | Process uptime |
| `openwa_process_resident_memory_bytes` | gauge | — | RSS |
| `openwa_process_heap_used_bytes` | gauge | — | V8 heap used |
| `openwa_sessions_total` | gauge | — | Configured sessions |
| `openwa_sessions_active` | gauge | — | READY (active) sessions |
| `openwa_sessions` | gauge | `status` | Session count per status |
| `openwa_messages_total` | counter | `direction` (`incoming`/`outgoing`) | Messages by direction |
| `openwa_messages_failed_total` | counter | — | Messages in FAILED state |
### Grafana Dashboard Definition
```json
// monitoring/grafana/dashboards/openwa.json — panels use the openwa_* metrics OpenWA exports
{
"title": "OpenWA Dashboard",
"uid": "openwa-main",
"panels": [
{
"title": "Active Sessions",
"type": "stat",
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "expr": "openwa_sessions_active" }
]
},
{
"title": "Messages Sent (24h)",
"type": "stat",
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "expr": "increase(openwa_messages_total{direction=\"outgoing\"}[24h])" }
]
},
{
"title": "Failed Messages",
"type": "stat",
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "expr": "openwa_messages_failed_total" }
]
},
{
"title": "Sessions by Status",
"type": "timeseries",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"targets": [
{ "expr": "openwa_sessions", "legendFormat": "{{status}}" }
]
},
{
"title": "Message Rate by Direction",
"type": "timeseries",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"targets": [
{ "expr": "rate(openwa_messages_total[5m])", "legendFormat": "{{direction}}" }
]
},
{
"title": "Process Memory",
"type": "timeseries",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"targets": [
{ "expr": "openwa_process_resident_memory_bytes / 1024 / 1024", "legendFormat": "RSS (MB)" },
{ "expr": "openwa_process_heap_used_bytes / 1024 / 1024", "legendFormat": "Heap used (MB)" }
]
},
{
"title": "Uptime",
"type": "stat",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"targets": [
{ "expr": "openwa_process_uptime_seconds" }
]
}
]
}
```
### Structured Logging
```typescript
// common/logging/logger.service.ts
import { Injectable, LoggerService } from '@nestjs/common';
import * as winston from 'winston';
@Injectable()
export class AppLoggerService implements LoggerService {
private logger: winston.Logger;
constructor() {
this.logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
defaultMeta: {
service: 'openwa',
version: process.env.npm_package_version
},
transports: [
new winston.transports.Console(),
// For Loki
new winston.transports.Http({
host: process.env.LOKI_HOST || 'loki',
port: 3100,
path: '/loki/api/v1/push',
}),
],
});
}
log(message: string, context?: object) {
this.logger.info(message, { context });
}
error(message: string, trace?: string, context?: object) {
this.logger.error(message, { trace, context });
}
warn(message: string, context?: object) {
this.logger.warn(message, { context });
}
debug(message: string, context?: object) {
this.logger.debug(message, { context });
}
}
// Usage example
this.logger.log('Message sent', {
sessionId: 'sess_123',
chatId: '628xxx@c.us',
messageType: 'text',
duration: 1.5
});
```
### Key Metrics to Monitor
These are the metrics OpenWA actually exports at `GET /api/metrics`:
| Category | Metric | Description | Alert Idea |
|----------|--------|-------------|------------|
| **Liveness** | `openwa_up` | Always `1` when scraped (absence/scrape-failure = down) | Target down |
| **Sessions** | `openwa_sessions_total` | Configured sessions | Near your expected session count |
| **Sessions** | `openwa_sessions_active` | READY (active) sessions | Drops below expected |
| **Sessions** | `openwa_sessions{status="..."}` | Per-status counts (e.g. `disconnected`, `failed`) | `disconnected`/`failed` > 0 |
| **Messages** | `openwa_messages_total{direction="outgoing"}` | Outgoing messages | Sudden drop |
| **Messages** | `openwa_messages_total{direction="incoming"}` | Incoming messages | Sudden drop |
| **Messages** | `openwa_messages_failed_total` | Messages in FAILED state | Rising rate |
| **System** | `openwa_process_resident_memory_bytes` | RSS | Growth / near limit |
| **System** | `openwa_process_heap_used_bytes` | V8 heap used | Growth |
| **System** | `openwa_process_uptime_seconds` | Process uptime | Frequent restarts (resets) |
> OpenWA does **not** expose request-rate, latency-histogram, webhook, queue, or Node default
> (`nodejs_*`) metrics. For host/container-level signals (CPU, memory pressure, event-loop), scrape
> external exporters: `up` and `container_memory_usage_bytes` come from blackbox/cAdvisor, and
> `node_*` from node-exporter — not from the app.
## 10.7 Backup & Recovery
### Backup Strategy
```mermaid
flowchart TB
subgraph Daily["Daily Backup"]
DB[(Database)] --> DUMP[pg_dump]
DUMP --> COMPRESS[gzip]
COMPRESS --> ENCRYPT[encrypt]
ENCRYPT --> S3[S3 Storage]
end
subgraph Retention["Retention Policy"]
D7[Daily: 7 days]
W4[Weekly: 4 weeks]
M12[Monthly: 12 months]
end
```
### Backup Script
```bash
#!/bin/bash
# scripts/backup.sh
set -e
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backups"
S3_BUCKET="s3://openwa-backups"
# Database backup
echo "Backing up database..."
pg_dump -Fc $DATABASE_URL > $BACKUP_DIR/db_$DATE.dump
gzip $BACKUP_DIR/db_$DATE.dump
# Session data backup
echo "Backing up session data..."
tar -czf $BACKUP_DIR/sessions_$DATE.tar.gz /app/.wwebjs_auth
# Upload to S3
echo "Uploading to S3..."
aws s3 cp $BACKUP_DIR/db_$DATE.dump.gz $S3_BUCKET/database/
aws s3 cp $BACKUP_DIR/sessions_$DATE.tar.gz $S3_BUCKET/sessions/
# Cleanup local files older than 7 days
find $BACKUP_DIR -mtime +7 -delete
echo "Backup completed: $DATE"
```
### Recovery Procedure
```bash
#!/bin/bash
# scripts/restore.sh
set -e
BACKUP_DATE=$1
# Download from S3
aws s3 cp s3://openwa-backups/database/db_$BACKUP_DATE.dump.gz /tmp/
aws s3 cp s3://openwa-backups/sessions/sessions_$BACKUP_DATE.tar.gz /tmp/
# Restore database
gunzip /tmp/db_$BACKUP_DATE.dump.gz
pg_restore -d $DATABASE_URL /tmp/db_$BACKUP_DATE.dump
# Restore sessions
tar -xzf /tmp/sessions_$BACKUP_DATE.tar.gz -C /
echo "Restore completed"
```
## 10.8 Scaling Guidelines
### Vertical Scaling
OpenWA scales **vertically** — add CPU/RAM to a single instance. The table below is **unbenchmarked
starting guidance**, not measured figures; actual usage depends heavily on engine choice
(whatsapp-web.js spawns a Chromium per session; Baileys is far lighter), message volume, and media.
Size up from your own monitoring.
| Sessions | RAM | CPU | Storage |
|----------|-----|-----|---------|
| 1-5 | 2GB | 2 cores | 20GB |
| 5-10 | 4GB | 4 cores | 50GB |
| 10-20 | 8GB | 8 cores | 100GB |
| 20+ | 16GB+ | 16+ cores | 200GB+ |
### Horizontal Scaling
**Not currently supported.** OpenWA is a single-process application with in-memory engine state, so
multiple replicas against a shared session volume corrupt WhatsApp auth. Run exactly **one** API
instance per session-data volume (`replicas: 1`). The DB-backed session registry / node-claim design
that would be required to scale out is documented — as a future design sketch, not a shipped feature —
in [13 - Horizontal Scaling Guide](./13-horizontal-scaling.md).
---
<div align="center">
[← 09 - Testing Strategy](./09-testing-strategy.md) · [Documentation Index](./README.md) · [Next: 11 - Operational Runbooks →](./11-operational-runbooks.md)
</div>