Ctrl K
ComunidadGuías…

docs/HEALTH_CHECK.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Health Check Endpoints

## Overview

The health check service provides HTTP endpoints for monitoring the Actual-sync service status. These endpoints enable integration with monitoring systems, load balancers, and container orchestration platforms.

## Features

- **Simple health check**: Basic alive/dead status
- **Detailed metrics**: Sync statistics and success rates
- **Readiness probe**: Kubernetes-compatible readiness endpoint
- **Per-server tracking**: Individual server sync statuses
- **Automatic updates**: Status updated after each sync operation

## Configuration

Health check endpoints are configured in `config/config.json`:

```json
{
  "healthCheck": {
    "port": 3000,
    "host": "0.0.0.0"
  }
}
```

### Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `port` | integer | `3000` | HTTP port for health check endpoints (1024-65535) |
| `host` | string | `"0.0.0.0"` | Host to bind the server (use `"127.0.0.1"` for localhost only) |
| `auth` | object | `{ "type": "none" }` | Credential for `/metrics` and `/metrics/prometheus` (see [Which endpoints are authenticated](#which-endpoints-are-authenticated)) |

The other `healthCheck` keys (`trustProxy`, `hsts`, `dashboard`) are documented in [CONFIG.md](CONFIG.md#healthcheck-optional).

### Which endpoints are authenticated

Two credentials exist, and they are independent: rotating one never breaks the other.

| Endpoint | `healthCheck.auth` | `healthCheck.dashboard.auth` |
|----------|--------------------|------------------------------|
| `GET /health`, `GET /ready` | never | never |
| `GET /metrics`, `GET /metrics/prometheus` | when `type` is `basic` or `token` | no effect |
| `GET /dashboard`, `/api/dashboard/*`, `WS /ws/logs` | no effect | when `type` is `basic` or `token` |

`/health` and `/ready` are liveness and readiness probes. Docker, Kubernetes and uptime checkers send no credentials, so a credential there would only make the container look permanently unhealthy. They return a status word and an uptime, nothing about your servers.

`/metrics` and `/metrics/prometheus` name your configured servers, when each last synced and whether it failed. By default they are open, exactly as before the credential existed, so an upgrade changes nothing. Set `healthCheck.auth` to close them:

```json
"healthCheck": {
  "auth": { "type": "token", "token": "a-long-random-value" }
}
```

`type` is `none` (default), `token` (sent as `Authorization: Bearer <token>`, the recommended choice for a scraper) or `basic` (`username` and `password`, for scrape tooling that only speaks basic auth). With `basic` or `token` the credential must be set, or the service refuses to start and names the missing key. Wrong credentials get `401`, and after 10 failures from one address in 15 minutes the metrics endpoints answer `429` for the rest of the window, on a counter separate from the dashboard login throttle. `healthCheck.dashboard.enabled` has no effect on these four endpoints. The dashboard API (`/api/dashboard/status`, `/api/dashboard/metrics`) returns the same server list, so this credential only closes the data when `healthCheck.dashboard.auth` is also set or the dashboard is disabled.

Once set, Prometheus needs the same credential in its scrape config; see [PROMETHEUS.md](PROMETHEUS.md#2-configure-prometheus). Network controls (binding to `127.0.0.1`, a firewall, a reverse proxy) remain the first line, this credential is a second layer for operators who cannot use them.

## Endpoints

### GET /health

Basic health check endpoint that returns if the service is running.

**Response:**
```json
{
  "status": "UP",
  "timestamp": "2025-12-05T10:30:00.000Z",
  "uptime": 3600,
  "service": "actual-sync"
}
```

**Status Codes:**
- `200 OK`: Service is running

**Use Case:** Simple alive check for load balancers or monitoring tools.

### GET /metrics

Detailed metrics endpoint with sync statistics and per-server status. Open by
default; `healthCheck.auth` can require a credential (see
[Which endpoints are authenticated](#which-endpoints-are-authenticated)).

**Response:**
```json
{
  "status": "HEALTHY",
  "timestamp": "2025-12-05T10:30:00.000Z",
  "uptime": 3600,
  "service": "actual-sync",
  "sync": {
    "lastSyncTime": "2025-12-05T10:25:00.000Z",
    "lastSyncStatus": "success",
    "totalSyncs": 15,
    "successfulSyncs": 14,
    "failedSyncs": 1,
    "successRate": "93.33%"
  },
  "servers": {
    "Main": {
      "lastSync": "2025-12-05T10:25:00.000Z",
      "status": "success"
    },
    "Secondary": {
      "lastSync": "2025-12-05T10:25:00.000Z",
      "status": "success"
    }
  },
  "lastError": null
}
```

`/metrics` is open by default, so after a failed sync `lastError` holds only `timestamp` and
`serverName`, and a server entry never carries the error text at any `healthCheck.auth` type (it can include bank or Actual
messages). Read the failure text on the authenticated dashboard.

**Status Values:**
- `PENDING`: No syncs performed yet
- `HEALTHY`: Service operating normally (>50% success rate and last sync successful)
- `DEGRADED`: Service partially functional (>50% failure rate)
- `UNHEALTHY`: Service experiencing issues (100% failure rate)

**Status Codes:**
- `200 OK`: Always returns 200 (check `status` field for health)

**Use Case:** Detailed monitoring, alerting on degraded performance, tracking sync history.

### GET /ready

Readiness probe endpoint for Kubernetes and container orchestration.

**Response (Ready):**
```json
{
  "status": "READY",
  "timestamp": "2025-12-05T10:30:00.000Z"
}
```

**Response (Not Ready):**
```json
{
  "status": "NOT_READY",
  "timestamp": "2025-12-05T10:30:00.000Z",
  "reason": "No successful syncs yet"
}
```

**Status Codes:**
- `200 OK`: Service is ready to handle requests
- `503 Service Unavailable`: Service not ready yet

**Use Case:** Kubernetes readiness probes, startup checks.

### GET /metrics/prometheus

Prometheus-format metrics (text exposition). This is the endpoint to scrape with
Prometheus — `/metrics` returns JSON for humans/uptime checks, `/metrics/prometheus`
returns the Prometheus text format. Open by default; `healthCheck.auth` can require
a bearer token or basic credentials, which Prometheus then sends with every scrape
(see [Which endpoints are authenticated](#which-endpoints-are-authenticated)). See
[docs/PROMETHEUS.md](PROMETHEUS.md) for the full metric reference and scrape config.

### Other public endpoints

- `GET /icon.png` — the project icon (served locally; used by the dashboard and notifications).
- `GET /vendor/chart.umd.min.js`: the Chart.js bundle from the installed npm package, used by the dashboard charts. Fixed path, no directory listing, cached for one day.

### Internal dashboard API (`/api/dashboard/*`)

These endpoints back the web dashboard UI and are **internal** — all are behind
`dashboardAuth()` and return shapes tailored to the dashboard's JavaScript, not a
stable public API (no compatibility guarantees). They are listed here so the docs
match the code, not as an integration surface:

| Method | Path | Purpose |
|--------|------|---------|
| GET  | `/api/dashboard/status` | Overall status + per-server summary |
| GET  | `/api/dashboard/servers` | Configured servers |
| GET  | `/api/dashboard/orphaned-servers` | History for servers no longer in config |
| GET  | `/api/dashboard/schedules` | Per-server schedules / next run |
| GET  | `/api/dashboard/metrics` | Aggregated metrics for the Analytics tab |
| GET  | `/api/dashboard/history` | Sync history records (`?limit=N`, optional `?serverName=` to filter to one server) |
| GET  | `/api/dashboard/accounts` | Discovered bank accounts |
| GET  | `/api/dashboard/payments` | Missing-payment alert snapshot per server (optional `?server=`), no connection to Actual needed |
| GET  | `/api/dashboard/payment-overrides` | Every stored payment alert override (switch and mute), with an `orphaned` flag |
| PUT  | `/api/dashboard/payments/:server/:alertId/state` | Switch an alert on or off, or mute or unmute it (needs config writes) |
| DELETE | `/api/dashboard/payments/:server/:alertId/state` | Reset an alert's override, including one whose alert or server no longer exists (needs config writes) |
| GET  | `/api/dashboard/payment-schedules/:server` | The stored Actual schedule snapshot (for the rule editor's picker) and the editor `defaults` |
| GET  | `/api/dashboard/payment-rules/:server` | Config and dashboard payment rules of a server, with `source`, `superseded` and `invalid` |
| POST | `/api/dashboard/payment-rules/:server` | Create a dashboard payment rule (needs config writes) |
| PUT  | `/api/dashboard/payment-rules/:server/:alertId` | Replace a dashboard payment rule (needs config writes) |
| DELETE | `/api/dashboard/payment-rules/:server/:alertId` | Delete a dashboard payment rule (needs config writes) |
| POST | `/api/dashboard/payment-preview/:server` | Render a rule's message, optionally send it as a test (needs config writes) |
| GET  | `/api/dashboard/notifications` | Failure-alert rate-limit usage + recent sync outcomes per server |
| GET  | `/api/dashboard/capabilities` | `{configWrites, reason, channels}`: whether dashboard config writes are enabled (`reason` is `disabled`, `auth-none` or null) and which channels (`telegram`, `email`, `slack`, `discord`, `webhook`, `ntfy`) currently have a destination |
| GET  | `/api/dashboard/config-audit` | Configuration change audit trail, newest first (`?limit=N`, 1 to 200, default 50; otherwise 400 `invalid_limit`). 503 `Sync history not available` without history, 500 `Failed to retrieve configuration changes` on a read error |
| POST | `/api/dashboard/sync` | Trigger a manual sync |
| POST | `/api/dashboard/dismiss-error` | Clear a server's last error |
| POST | `/api/dashboard/reset-history` | Reset stored sync history. 404 `server_not_found` for a name that is neither configured nor present in any history table, 400 `invalid_server` for a non-string name, 400 `server_name_too_long` above 200 characters |
| POST | `/api/dashboard/test-notification` | Send a test notification (`email`, `discord`, `slack`, `telegram`, `ntfy`, `generic`) |

Every POST, PUT and DELETE runs the dashboard auth first and then an Origin check: a cross-origin browser request is refused with 403 `{"error":"origin_rejected"}`. Configuration-changing routes additionally return 403 `config_writes_disabled` unless `dashboard.allowConfigWrites` is true and auth is not `none`, 415 `invalid_content_type` for a non-JSON body, 413 `payload_too_large` above 16 KB and 400 `invalid_json` for a malformed body. Any other unreadable body (for example a corrupt gzip body) is 400 `invalid_body`, a 4xx raised by a route keeps its status with `request_error`, and an unexpected server error is 500 `internal_error`; none shows a stack trace.

`GET /api/dashboard/payments` serves the snapshot of the last missing-payment evaluation
(#258) that a sync persists after evaluating (#260), so a dashboard can list every watched schedule without
reaching Actual. Its data is as fresh as `lastRun.at`; a sync that skips or aborts before the evaluation
(including a stuck bank sync, #284) leaves the items and `lastRun` unchanged. With the
default `auth.type: none` the route is open to anyone who can reach the port, so enable dashboard
auth when `scheduleAlerts` is used (it exposes schedule, payee and account names and amounts).

```json
{
  "servers": [
    {
      "server": "Main",
      "configured": true,
      "lastRun": { "at": "2026-01-14T08:01:00Z", "ok": true, "errorCode": null, "lastSuccessAt": "2026-01-14T08:01:00Z", "rulesErrored": 0 },
      "items": [
        {
          "alertId": "parking-a", "scheduleId": "b1c2...", "name": "Rent - Parking A",
          "cadenceText": "Every month on the 5th", "account": "Main", "payee": "Tenant A",
          "expectedAmount": 6000, "nextDate": "2026-02-05", "deadline": "2026-01-11",
          "state": "missing", "lastAlertAt": "2026-01-12T08:01:00Z", "source": "config",
          "ruleState": { "enabled": true, "mutedUntil": null, "reason": null, "lockedByConfig": false, "muteEndDue": false },
          "occurrences": [ { "date": "2026-01-05", "deadline": "2026-01-11", "state": "missing", "receivedDate": null, "receivedAmount": null, "daysLate": null } ]
        }
      ]
    }
  ]
}
```

- `expectedAmount` is integer cents (6000 = 60.00), `null`, or `{ "num1": 5000, "num2": 7000 }`
  (min, max) for a range. `receivedAmount` is cents or `null`.
- `state` is one of `upcoming`, `received`, `late`, `missing`, `wrongAmount`, `cannotCheck`, `skipped`.
- `daysLate` is computed per request: for a `late` occurrence, `receivedDate - deadline` in
  calendar days; `null` for every other state.
- `lastAlertAt` is the latest delivered alert (`delivery = 'sent'`, resolved notices included)
  from the `schedule_alerts` ledger, computed at request time; `null` when none was sent or the
  last one is older than the ledger retention.
- `source` is `"config"` for a rule from `config.json` and `"dashboard"` for a rule created in the dashboard (see [Payment rules](#payment-rules-262)).
- `ruleState` is `{ enabled, mutedUntil, reason, lockedByConfig, muteEndDue }`, computed per request from the
  stored override (#261) and the rule: `enabled` is `false` when the rule has `enabled: false` in
  the config (`lockedByConfig: true`) or the operator switched it off; `mutedUntil` is a
  `YYYY-MM-DD` date (or `null`) and `reason` its reason. `mutedUntil` stays set, in the past, from the
  end of the mute until the summary has been sent. `muteEndDue` is `true` in that period (the mute day
  is over in the server timezone); the dashboard uses it, not the browser date, for the "summary pending" badge.
  Without an override it is
  `{ "enabled": true, "mutedUntil": null, "reason": null, "lockedByConfig": false, "muteEndDue": false }`. The `state` of an
  item is always the real evaluation result: a switch or a mute never changes it.
- `configured: false` (no `scheduleAlerts` block) gives `items: []` and `lastRun: null`; a
  configured server has `lastRun: null` before its first run. `lastRun.at` is the latest attempt,
  `lastSuccessAt` the latest success (`null` until one happens). On a failed run the previous
  `items` are still returned and `lastRun.errorCode` is one of `rules_invalid`,
  `actual_read_failed`, `evaluation_failed`.

| Status | Body | When |
|--------|------|------|
| 400 | `{"error":"Invalid server parameter"}` | `server` is not a single non-empty string (repeated, `server[]=`, `server[a]=`, or empty) |
| 401 | | no or bad credentials |
| 403 | `{"error":"Dashboard is disabled"}` | the dashboard is disabled |
| 404 | `{"error":"Server not found"}` | no configured server with that name |
| 429 | `{"error":"Too many failed authentication attempts, try again later"}` | auth throttle after repeated bad credentials |
| 429 | `Too many requests, please try again later.` | the request limiter |
| 500 | `{"error":"Invalid authentication configuration"}` | `auth.type` is unrecognised |
| 503 | `{"error":"Server list not available"}` | the server list is not wired |
| 503 | `{"error":"Sync history not available"}` | sync history is disabled |
| 500 | `{"error":"Failed to retrieve payments"}` | the snapshot or ledger read threw (logged) |

#### Payment alert state (#261)

`PUT /api/dashboard/payments/:server/:alertId/state` switches an alert off or on, or mutes it until a
date, without editing `config.json` or restarting. It runs behind dashboard auth, the Origin check and
the config-writes gate (see above), so it needs `dashboard.allowConfigWrites: true` and auth other than
`none`. The body is a JSON object with at least one of these keys and no others:

| Key | Rule |
|-----|------|
| `enabled` | boolean |
| `mutedUntil` | `YYYY-MM-DD` from today to today + 365 days (in the configured timezone), or `null` to unmute |
| `reason` | string, 1 to 200 characters after trimming; required when `mutedUntil` is a date; given alone, checked the same way on a muted alert and ignored otherwise; ignored when `mutedUntil` is `null` |

It merges the given keys into the stored override and answers `200 {"ruleState": {...}}` (the shape
above). `{"enabled":true}` when nothing is stored is a no-op: nothing is stored and nothing is audited.
Extending a mute keeps its original start, so the summary covers the whole period. Every accepted
change that alters the stored state is written to the configuration audit trail with the action
`payment_alert_state`; the reason is recorded as `[redacted]`. Rejected and no-op writes are not audited.

| Status | Code | When |
|--------|------|------|
| 400 | `invalid_json`, `invalid_body_encoding` | a malformed body or an unsupported charset or content encoding (the shared body parser) |
| 400 | `invalid_body` | not an object, no known key, an unknown key, or a wrong type |
| 400 | `invalid_muted_until` | not a real date, in the past, or more than 365 days ahead |
| 400 | `reason_required` | a mute date with a missing or blank reason, or a blank reason alone on a muted alert |
| 400 | `invalid_reason` | a reason over 200 characters after trimming |
| 401 | | no or bad credentials (the dashboard auth bodies) |
| 403 | `origin_rejected`, `config_writes_disabled` | the Origin or config-writes gate |
| 404 | `server_not_found`, `alert_not_found` | the server or alert id is not configured |
| 409 | `disabled_in_config` | any change to an alert with `enabled: false` in the config, except one that only unmutes |
| 413, 415 | `payload_too_large`, `invalid_content_type` | a body over 16 KB, or not `application/json` |

The checks run in that order: body shape, then date and reason, then 404, then 409. A top-level JSON
string body is rejected by the strict parser as `invalid_json`.

`DELETE` on the same path removes the override (switch and mute together) and answers
`200 {"deleted": true}`. It checks the stored override first, so the override of a server or alert
that is no longer configured (an orphan) can still be deleted; with no override it is
`404 override_not_found`. Deleting a mute also deletes its held-back ledger rows, with no summary.

`GET /api/dashboard/payment-overrides` returns
`{"overrides": [{ "server", "alertId", "enabled", "mutedFrom", "mutedUntil", "reason", "updatedAt", "orphaned" }]}`
ordered by server and alert id. An orphaned override (its server or alert is no longer in the
configuration) is ignored by delivery and by `ruleState`, and the Settings tab lists it for deletion.
This is also the export of everything stored about overrides.

#### Payment rules (#262)

Rules can also be created in the dashboard and are stored in the sync-history database (table
`payment_alert_rule`); `config.json` is never written. The editor itself is #268; these routes are its
API. A server needs a `scheduleAlerts` block (an empty `{ "alerts": [] }` is enough) before it accepts
dashboard rules. Config rules win: a dashboard rule whose id equals a config rule's id is
`superseded`, kept but not evaluated. `GET` routes need dashboard auth only; `POST`, `PUT` and
`DELETE` also need the Origin check and config writes, like the alert state route.

- `GET /api/dashboard/payment-schedules/:server` answers
  `{ configured, capturedAt, schedules: [{ id, name, cadenceText, amount, amountMin, amountMax, amountOp, account, payee, nextDate, completed }], defaults }`.
  The schedules are captured by the sync's alert step (`capturedAt` is `null` before the first
  capture, so a new install needs a sync first). `account` is `null` for a deleted account.
  `defaults` is always present: `{ graceDays: 3, earlyDays: 2, templates, blockTemplates, variables }`,
  read from the same constants the validator uses; `variables` maps each of the six events to the
  variable list a template may use.
- `GET /api/dashboard/payment-rules/:server` answers
  `{ configured, rules: [{ id, source, superseded, invalid, errors, definition, updatedAt }] }`.
  `definition` is exactly one `scheduleAlerts.alerts[]` entry, so it can be copied into `config.json`.
  `invalid` and `errors` use the validator mode the sync uses, so the list and the sync agree on which
  rules run. A server without a block answers `configured: false` with its stored rows (dormant).
- `POST` takes one rule (`id` required, `enabled` not allowed: use the alert switch) and answers
  `201 { rule }`. `PUT .../:alertId` replaces it (`200 { rule }`); a body identical to the stored rule is a
  no-op with no audit row. `DELETE` answers `200 { deleted: true }`; it also removes the rule's alert
  override, its held-back `muted` rows and its payment status rows, unless a config rule of the same id
  exists (then only the dashboard row goes). At most 200 dashboard rules per server.
- `POST /api/dashboard/payment-preview/:server` takes `{ rule, event, send? }` (`event` is `missing`,
  `wrongAmount`, `resolved`, `cannotCheck`, `ruleUnmatched` or `muteEnded`) and answers
  `{ text, sampleKeys, sent, results }`. Schedule fields come from the stored snapshot; `sampleKeys` lists
  the variables filled with stand-ins. With `send: true` one test message goes to each destination;
  nothing is written to the alert ledger or the payment status. `results[]` is
  `{ channel, target, ok, error? }`, where `target` is the webhook name or an opaque destination key (never
  a URL) and `error` is `send_failed`, `send_threw`, `timeout` or `channel_unavailable`. At most 5 test
  sends per minute per process.
- Writes are audited as `payment_alert_rule_create`, `payment_alert_rule_update` and
  `payment_alert_rule_delete` (target the alert id, `changes` as `{ field: [before, after] }`), in the same
  transaction as the write. The definition values are recorded, so do not put secrets in a rule.

| Status | Code | When |
|--------|------|------|
| 400 | `invalid_rule` + `details: [{path, message}]` | the rule fails the schema, or `id` is missing, or `enabled` is present |
| 400 | `invalid_template` + `template`, `token`, `line`, `reason` | a template does not compile |
| 400 | `no_configured_destination` | the rule's channels resolve to no destination (create, replace, preview) |
| 400 | `id_mismatch` | PUT body `id` differs from `:alertId` |
| 400 | `invalid_event`, `invalid_body` | preview: unknown event, or a missing or mistyped `rule`, `event` or `send` |
| 400 | `invalid_json`, `invalid_body_encoding` | the shared body parser |
| 401 | | no or bad credentials |
| 403 | `origin_rejected`, `config_writes_disabled` | the Origin or config-writes gate |
| 404 | `server_not_found` | unknown server (checked first on every route) |
| 404 | `rule_not_found` | an id that is not a valid alert id, or is neither a dashboard nor a config rule of that server |
| 409 | `alerts_not_configured` | create, replace or preview on a server without a `scheduleAlerts` block |
| 409 | `id_in_config` | create with the id of a config rule (wins over `duplicate_id`) |
| 409 | `duplicate_id` | create with the id of a stored dashboard rule |
| 409 | `managed_in_config` | replace a config rule, or delete a config id that has no dashboard row |
| 409 | `too_many_rules` | create beyond 200 dashboard rules |
| 413, 415 | `payload_too_large`, `invalid_content_type` | a body over 16 KB, or not `application/json` (a DELETE needs no body) |
| 429 | `test_send_rate_limited` | the 6th test send in a minute |
| 500 | `Failed to retrieve payment rules` (reads), `Failed to save payment rule` (writes), `Failed to preview payment rule` | an unexpected error; the text is fixed and the cause is only logged |
| 502 | `send_failed` + `results` | every destination refused the test message |
| 503 | `Sync history not available` | sync history is disabled |

Create checks run in this order: server, block, validation (400), `id_in_config`, `duplicate_id`,
`too_many_rules`.

`GET /api/dashboard/notifications` exposes rate-limit headroom and recent sync outcomes per
server. Rate limiting is otherwise invisible: a server at zero remaining is having its failure
alerts suppressed, and this is the surface that says so. The configured `rateLimit` is echoed
back, because "remaining" means nothing without the ceiling it counts down from.

**`notificationsSentLastHour` counts FAILURE notifications only.** Rate limiting is failure-only
by design — a successful sync never consumes budget — so this is not a count of all
notifications sent. `recentSyncsByServer` reports every outcome (total / successes / failures)
and `consecutiveFailuresByServer` the current streak.

## Integration Examples

### Docker Healthcheck

Add to your `Dockerfile`:

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1
```

### Kubernetes Probes

Add to your Kubernetes deployment:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: actual-sync
spec:
  template:
    spec:
      containers:
      - name: actual-sync
        image: actual-sync:latest
        ports:
        - containerPort: 3000
          name: health
        livenessProbe:
          httpGet:
            path: /health
            port: health
          initialDelaySeconds: 30
          periodSeconds: 30
          timeoutSeconds: 3
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /ready
            port: health
          initialDelaySeconds: 10
          periodSeconds: 10
          timeoutSeconds: 3
          failureThreshold: 3
```

### Prometheus Monitoring

The `/metrics/prometheus` endpoint exposes Prometheus-format metrics for scraping
(`/metrics` returns JSON, not the Prometheus text format). Example scrape config:

```yaml
scrape_configs:
  - job_name: 'actual-sync'
    static_configs:
      - targets: ['actual-sync:3000']
    metrics_path: '/metrics/prometheus'
    scrape_interval: 30s
    # Only when healthCheck.auth is set to type token:
    # authorization:
    #   type: Bearer
    #   credentials: a-long-random-value
```

You'll need to convert the JSON response to Prometheus format using a JSON exporter or custom script.

### Docker Compose

```yaml
version: '3.8'
services:
  actual-sync:
    image: actual-sync:latest
    ports:
      - "3000:3000"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 40s
```

### Uptime Monitoring (Uptime Robot, Pingdom)

Configure HTTP monitoring:
- **URL**: `http://your-server:3000/health`
- **Expected Status**: `200`
- **Expected Content**: `"status":"UP"`
- **Interval**: Every 5 minutes

For detailed monitoring:
- **URL**: `http://your-server:3000/metrics`
- **Expected Status**: `200`
- **Expected Content**: `"status":"HEALTHY"`

### Shell Script Monitoring

```bash
#!/bin/bash
# check_health.sh

RESPONSE=$(curl -s http://localhost:3000/metrics)
STATUS=$(echo "$RESPONSE" | jq -r '.status')

case "$STATUS" in
  "HEALTHY"|"PENDING")
    echo "✓ Service is healthy"
    exit 0
    ;;
  "DEGRADED")
    echo "⚠ Service is degraded"
    exit 1
    ;;
  "UNHEALTHY")
    echo "✗ Service is unhealthy"
    exit 2
    ;;
  *)
    echo "? Unknown status: $STATUS"
    exit 3
    ;;
esac
```

### Alerting Rules

Example alerting logic based on metrics:

```bash
# Alert if service is unhealthy
if [ "$(curl -s http://localhost:3000/metrics | jq -r '.status')" == "UNHEALTHY" ]; then
  send_alert "Actual-sync is UNHEALTHY"
fi

# Alert if success rate drops below 80%
SUCCESS_RATE=$(curl -s http://localhost:3000/metrics | jq -r '.sync.successRate' | sed 's/%//')
if (( $(echo "$SUCCESS_RATE < 80" | bc -l) )); then
  send_alert "Actual-sync success rate is $SUCCESS_RATE%"
fi

# Alert if no syncs in last hour
LAST_SYNC=$(curl -s http://localhost:3000/metrics | jq -r '.sync.lastSyncTime')
if [ -n "$LAST_SYNC" ]; then
  LAST_SYNC_TS=$(date -d "$LAST_SYNC" +%s)
  NOW_TS=$(date +%s)
  DIFF=$(( NOW_TS - LAST_SYNC_TS ))
  if (( DIFF > 3600 )); then
    send_alert "No sync in last hour (last: $LAST_SYNC)"
  fi
fi
```

## Accessing Endpoints

### Using curl

```bash
# Basic health check
curl http://localhost:3000/health

# Detailed metrics
curl http://localhost:3000/metrics

# Pretty-print with jq
curl -s http://localhost:3000/metrics | jq

# Check specific status
curl -s http://localhost:3000/metrics | jq -r '.status'

# Check success rate
curl -s http://localhost:3000/metrics | jq -r '.sync.successRate'

# Readiness check
curl http://localhost:3000/ready
```

### Using httpie

```bash
# Basic health check
http :3000/health

# Detailed metrics
http :3000/metrics

# Readiness check
http :3000/ready
```

### Using wget

```bash
wget -qO- http://localhost:3000/health
wget -qO- http://localhost:3000/metrics
```

## Security Considerations

### Network Exposure

By default, the health check service binds to `0.0.0.0` (all interfaces). Consider these options:

1. **Internal Only**: Set `host` to `"127.0.0.1"` if only local access is needed. In a container keep `0.0.0.0` (the address inside the container) and restrict with the port mapping instead: the shipped `docker-compose.yml` publishes `127.0.0.1:3000:3000`, which only the Docker host can reach ([Container hardening](DOCKER_DEPLOYMENT.md#container-hardening-253))
2. **Firewall**: Restrict access to health check port (3000) to monitoring systems only
3. **Reverse Proxy**: Put behind nginx/Apache with authentication if exposed to internet, and set `healthCheck.trustProxy` (usually `1`) so rate limiting and auth-failure logs see the real client IP ([CONFIG.md](CONFIG.md#healthcheck-optional))

### Sensitive Information

The `/metrics` endpoint exposes:
- Server names (from your configuration)
- Sync timing information
- Error messages (which might contain server details)

**Recommendations:**
- Don't expose health check endpoints to the public internet
- Use firewall rules to restrict access to monitoring systems
- Set `healthCheck.auth` when the metrics endpoints must be reachable beyond your monitoring network (see [Which endpoints are authenticated](#which-endpoints-are-authenticated)). It is off by default so existing Prometheus setups keep working after an upgrade; that trade is deliberate, since the service normally sits behind a firewall.

### Docker Example with Restricted Access

This is the default in the shipped `docker-compose.yml` since v1.22.0:

```yaml
services:
  actual-sync:
    image: actual-sync:latest
    # Only expose health check to localhost
    ports:
      - "127.0.0.1:3000:3000"
```

## Troubleshooting

### Port Already in Use

```
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
```

**Solution:** Change the port in `config/config.json`:
```json
{
  "healthCheck": {
    "port": 3001
  }
}
```

### Cannot Connect

```
curl: (7) Failed to connect to localhost port 3000: Connection refused
```

**Possible Causes:**
1. Service not started
2. Health check service failed to start (check logs)
3. Firewall blocking the port
4. Wrong host binding (check `host` setting)

**Debug:**
```bash
# Check if service is listening
netstat -tulpn | grep 3000

# Check logs for errors
tail -f logs/app-2025-12-05.log
```

### 404 Not Found

```
{"error":"Not Found","availableEndpoints":["/health","/metrics","/ready"]}
```

**Solution:** Use one of the available endpoints: `/health`, `/metrics`, or `/ready`

## Best Practices

1. **Regular Monitoring**: Check `/metrics` every 30-60 seconds
2. **Alert Thresholds**: Alert when status is `DEGRADED` or `UNHEALTHY`
3. **Grace Period**: Allow 60 seconds after startup before alerting
4. **Multiple Checks**: Monitor both `/health` (liveness) and `/ready` (readiness)
5. **Historical Tracking**: Log metrics over time to identify patterns
6. **Container Readiness**: Use `/ready` for container orchestration readiness probes
7. **Load Balancer Health**: Use `/health` for simple load balancer health checks

## API Reference

### Health Response Object

```typescript
interface HealthResponse {
  status: "UP";
  timestamp: string;        // ISO 8601 timestamp
  uptime: number;          // Seconds since service start
  service: "actual-sync";
}
```

### Metrics Response Object

```typescript
interface MetricsResponse {
  status: "PENDING" | "HEALTHY" | "DEGRADED" | "UNHEALTHY";
  timestamp: string;
  uptime: number;
  service: "actual-sync";
  sync: {
    lastSyncTime: string | null;
    lastSyncStatus: "pending" | "success" | "failure";
    totalSyncs: number;
    successfulSyncs: number;
    failedSyncs: number;
    successRate: string;    // e.g., "93.33%" or "N/A"
  };
  servers: {
    [serverName: string]: {
      lastSync: string;
      status: "success" | "failure";
    };
  };
  lastError: {
    // no message: the error text stays on the authenticated dashboard
    timestamp: string;
    serverName: string;
  } | null;
}
```

### Ready Response Object

```typescript
interface ReadyResponse {
  status: "READY" | "NOT_READY";
  timestamp: string;
  reason?: string;          // Present when NOT_READY
}
```

---

## WebSocket Real-Time Logs

The health check service provides WebSocket endpoint for streaming real-time logs to dashboard clients.

### WS /ws/logs

Real-time log streaming via WebSocket connection. **Not public**: the handshake is checked before the socket opens, so a refused client never receives a log line.

| Check | Applies | Refused with |
|---|---|---|
| `dashboard.enabled` is false | always | 403 |
| `Origin` is neither same-origin nor in `dashboard.allowedOrigins` | every auth type; skipped when there is no `Origin` header | 403 |
| more than 10 open log streams from one address | always | 429 |
| no valid ticket (an `Authorization` header is not accepted here) | `dashboard.auth.type` basic or token | 401 |

The per-address cap and the `handshake refused` log lines use the client address Express resolves: the TCP peer address by default, or the `X-Forwarded-For` client when `healthCheck.trustProxy` is set. Without `trustProxy`, behind a reverse proxy the cap counts every stream through that proxy together. The ticket is spent before any other check, so a handshake refused for its Origin, the cap or a disabled dashboard cannot be retried with the same ticket. The same Origin rule guards the state-changing dashboard POSTs (403 `origin_rejected`). With `auth.type: none` the Origin check does not stop DNS rebinding; see [DASHBOARD.md](DASHBOARD.md).

**Authenticating from a browser.** A browser cannot put an `Authorization` header on a WebSocket, so the dashboard first calls `GET /api/dashboard/ws-ticket` (behind the normal dashboard auth) and gets a ticket that is single use and valid for 30 seconds:

```json
{ "ticket": "9f2c...64 hex chars", "expiresInSeconds": 30 }
```

It then connects to `/ws/logs?ticket=<ticket>`. Tickets live in memory, so after a restart the dashboard simply asks for a new one.

**Authenticating from a script.** Do the same: call `GET /api/dashboard/ws-ticket` with the `Authorization` header the HTTP dashboard takes (`Basic ...` or `Bearer ...`), then connect with the ticket. The handshake itself does not accept the header, because WebSocket upgrades bypass the HTTP rate limiter and would otherwise allow unlimited password guessing.

With `auth.type: none`, no ticket is needed; the `Origin` check and the connection cap still apply.

**Connection:**
```javascript
const { ticket } = await (await fetch('/api/dashboard/ws-ticket')).json();
const ws = new WebSocket(`ws://localhost:3000/ws/logs?ticket=${ticket}`);
```

**Message Format (Received):**
```json
{
  "level": "INFO",
  "message": "Starting sync for server: Main Budget",
  "metadata": {
    "server": "Main Budget",
    "correlationId": "abc123-def456"
  },
  "timestamp": "2025-12-09T10:30:00.000Z"
}
```

**Keep-Alive (Client → Server):**
```json
{ "type": "ping" }
```

**Keep-Alive Response (Server → Client):**
```json
{
  "type": "pong",
  "timestamp": "2025-12-09T10:30:00.000Z"
}
```

### WebSocket Features

- **Infinite Reconnection**: Automatically reconnects with exponential backoff (1s to 30s cap)
- **Keep-Alive**: Ping/pong every 30 seconds to detect dead connections
- **Pause on Hidden**: Stops streaming when browser tab is hidden (saves resources)
- **Memory Efficient**: Ring buffer stores up to 500 logs, displays last 200
- **Broadcast to All**: All connected clients receive the same log stream

### Connection Behavior

1. **Initial Connection**: Client connects and receives welcome message
2. **Log Streaming**: All logs are broadcast to connected clients in real-time
3. **Keep-Alive**: Client sends ping every 30s, server responds with pong
4. **Timeout Detection**: If no pong received within 60s, client reconnects
5. **Auto-Reconnect**: On disconnect, client retries with exponential backoff
6. **Tab Visibility**: Streaming pauses when tab is hidden, resumes when visible

### Long-Running Stability

The WebSocket implementation is designed for 24/7 operation:

- ✅ **No memory leaks**: Ring buffer prevents unbounded memory growth
- ✅ **Infinite retry**: Never stops trying to reconnect
- ✅ **Connection monitoring**: Keep-alive detects silent failures
- ✅ **Resource efficiency**: Pauses when not needed
- ✅ **Batch DOM updates**: Reduces CPU usage and browser lag

### Example Usage

```javascript
// Node.js 18+ with the ws package, token auth: fetch a ticket, then connect with it.
const WebSocket = require('ws');
const auth = { Authorization: 'Bearer <dashboard token>' };

async function connect() {
  const res = await fetch('http://localhost:3000/api/dashboard/ws-ticket', { headers: auth });
  const { ticket } = await res.json();
  const ws = new WebSocket(`ws://localhost:3000/ws/logs?ticket=${ticket}`);
  let keepAlive;

  ws.on('open', () => {
    console.log('Connected to log stream');
    keepAlive = setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000);
  });

  ws.on('message', (raw) => {
    const data = JSON.parse(raw);
    if (data.type === 'pong') return;
    console.log(`[${data.level}] ${data.message}`);
  });

  ws.on('close', () => {
    clearInterval(keepAlive);
    console.log('Disconnected, reconnecting...');
    setTimeout(connect, 3000);
  });
}

connect();
```