Ctrl K
ComunidadGuías…

docs/LOGGING.md

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

Archivo original ↗Preguntar a KiBot
# Enhanced Logging System

Comprehensive guide to Actual-sync's structured logging with rotation, centralized logging, performance tracking, and per-server configuration.

---

## 📊 Overview

Actual-sync uses a custom structured logger with enterprise features:
- **Log Levels**: DEBUG, INFO, WARN, ERROR with filtering
- **Separate console/file formats**: pretty for the console, single-line JSON for files (machine-readable and shippable)
- **Secret redaction**: passwords, tokens, and other secrets are masked before anything is written (console, file, syslog, and the dashboard stream)
- **Correlation IDs**: Track related log entries across operations
- **File Logging**: Dated files with daily + size-based rotation and compression
- **Centralized Logging**: Syslog support (UDP, TCP, TLS), redacted and RFC 5424 escaped
- **Performance Tracking**: Automatic operation timing
- **Per-Server Config**: Override log levels per server
- **Context Management**: Child loggers with inherited context
- **Real-time Streaming**: WebSocket broadcasting for dashboard (redacted)
- **Never throws**: a bad metadata value (throwing getter/toJSON, BigInt, circular ref) degrades to a placeholder; a log call can never crash the caller

---

## 🔧 Configuration

### Basic Configuration

Configure global logging in `config/config.json`:

```json
{
  "logging": {
    "level": "INFO",
    "format": "pretty",       // console format (pretty | json)
    "fileFormat": "json",     // file format (json recommended; single line per entry)
    "redact": [],             // extra secret key names to mask (defaults always apply)
    "actualApiVerbose": false, // UNSAFE if true: @actual-app/api prints transactions (payees, amounts, notes)
    "logDir": "./logs",
    "rotation": {
      "enabled": true,      // Default: true (recommended)
      "maxSize": "10M",     // Rotate at 10MB...
      "interval": "1d",     // ...or daily, whichever comes first
      "maxFiles": 30,       // Default: 30 rotated files (with the 1d interval, 30 days)
      "compress": "gzip"    // Default: gzip (~70% space savings)
    },
    "syslog": {
      "enabled": false,
      "host": "localhost",
      "port": 514,
      "protocol": "udp",
      "facility": 16
    },
    "performance": {
      "enabled": true,
      "thresholds": {
        "slow": 1000,
        "verySlow": 5000
      }
    }
  }
}
```

### Per-Server Log Levels

Override log levels for specific servers:

```json
{
  "servers": [
    {
      "name": "Main Budget",
      "url": "https://actual.example.com",
      "logging": {
        "level": "DEBUG"
      }
    },
    {
      "name": "Test Budget",
      "url": "http://localhost:5006",
      "logging": {
        "level": "WARN"
      }
    }
  ]
}
```

Use cases:
- **DEBUG for problematic servers**: Get detailed logs for servers with issues
- **WARN for stable servers**: Reduce noise from well-functioning servers

**Deprecated: per-server `format`.** It is still accepted so existing configs load, but it is ignored (it never took effect). Output format is chosen per sink with `logging.format` (console) and `logging.fileFormat` (file). Remove `format` from your `servers[].logging` blocks.

Reserved fields `timestamp`, `level`, `severity`, `message`, `service`, `version`, `seq` and `schema` are set by the logger and cannot be overwritten by context or metadata; such keys passed in context or metadata are dropped. `event` and `component` are also reserved names, but the caller sets them (a component child logger, an audit event such as `authz_fail`).

### Configuration Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| **Basic** |
| `level` | string | `INFO` | Log level: DEBUG, INFO, WARN, ERROR |
| `format` | string | `pretty` | **Console** format: `pretty` or `json` |
| `fileFormat` | string | `json` | **File** format: `json` (single line, shippable) or `pretty`. Independent of `format`. Global only. |
| `redact` | array | `[]` | Extra metadata key names to mask. Default secret keys are always redacted (see Secret Redaction). |
| `logDir` | string/null | `./logs` | Directory for log files (`null` = console only) |
| **Rotation** |
| `rotation.enabled` | boolean | `true` | Enable log rotation (recommended) |
| `rotation.maxSize` | string | `10M` | Max file size before rotation: positive integer + unit B/K/M/G, optional `B` suffix (e.g. `10M`, `10MB`, `512B`) |
| `rotation.interval` | string | `1d` | Time-based rotation: positive integer + unit s/m/h/d or `M` for months (e.g. `1d`, `12h`, `1M`; m = minutes). Rotation fires on whichever comes first, size or interval. |
| `rotation.maxFiles` | integer | `30` | Rotated files to keep. With the `1d` interval, this is days of retention. |
| `rotation.compress` | string | `gzip` | Compression: `gzip` or `none` |
| **Syslog** |
| `syslog.enabled` | boolean | `false` | Send logs to syslog server |
| `syslog.host` | string | `localhost` | Syslog server hostname |
| `syslog.port` | integer | `514` | Syslog server port |
| `syslog.protocol` | string | `udp` | Transport: `udp`, `tcp` or `tls` |
| `syslog.ca` | string | none | PEM CA file to trust for `tls` (certificate verification is always on) |
| `syslog.facility` | integer | `16` | Syslog facility code (0-23, 16=local0) |
| **Performance** |
| `performance.enabled` | boolean | `false` | Track operation performance |
| `performance.thresholds.slow` | integer | `1000` | Slow operation threshold (ms) |
| `performance.thresholds.verySlow` | integer | `5000` | Very slow operation threshold (ms) |

---

## 📈 Log Levels

### Level Hierarchy

| Level | Numeric | Description | Example Use Cases |
|-------|---------|-------------|-------------------|
| **ERROR** | 0 | Critical failures | Sync errors, connection failures, configuration errors |
| **WARN** | 1 | Recoverable issues | Rate limits, retries, security warnings |
| **INFO** | 2 | Normal operations | Sync start/complete, connections, account processing |
| **DEBUG** | 3 | Detailed diagnostics | API calls, directory operations, internal state |

**Filtering**: Setting a level shows that level and all levels below it.
- `ERROR`: Only ERROR messages
- `WARN`: WARN + ERROR
- `INFO`: INFO + WARN + ERROR (default)
- `DEBUG`: All messages

**Crash records (#439):** `uncaughtException`, `unhandledRejection` and `warning` handlers are installed first in `index.js`. They log a static message with `event: sys_crash` (or `process.warning`) and a classified `error: { type, code, category, safeMessage }`. The upstream message and the rejection object are never logged; frames appear only in a DEBUG record. A crash before the logger exists (while the service is still loading) writes one JSON line with the frames to stderr and exits 1. An already-handled `@actual-app/api` rejection is `process.rejection_benign` at WARN. Before the logger exists, one JSON line goes to stderr.

**Level discipline (keep the error log honest):** `ERROR` is for problems that need a human. A failure the service *recovered* from (a retry, a transient remote 429/5xx, a network blip) is `WARN` or `DEBUG`, never `ERROR`. Logging expected, self-healing events at `ERROR` buries the real failures and inflates alerts. For example, transient Telegram polling failures are logged at `DEBUG`; already-handled `@actual-app/api` rejections are `process.rejection_benign` at `WARN`.

---

## Record contract (convention version 1)

This section is the shared convention with actual-mcp-server. Both repos keep a
copy; each states the version it follows, so drift shows in a diff.

**Convention version: 1.** Actual-sync follows version 1. Design:
`docs/plans/logging-architecture.md`.

### Record schema

Every JSON record validates against [`log-record.schema.json`](log-record.schema.json).
Required: `timestamp` (UTC ISO 8601, milliseconds), `level`, `severity`
(OpenTelemetry SeverityNumber: ERROR 17, WARN 13, INFO 9, DEBUG 5), `service`,
`version` (app version), `message`, `seq` (per-process counter starting at 1,
so gaps and reordering are visible) and `schema` (`1`). Optional: `component`,
`event`, `correlationId`, `server`, `fields`, `error`.

- `message` is a string literal, never interpolated. Data goes in fields.
- `event` is a dotted name from the catalogue below, so alerting does not
  depend on wording.
- The keys `timestamp`, `level`, `severity`, `message`, `service`, `version`,
  `seq` and `schema` are written by the logger and cannot be overwritten by
  context or fields. `event` and `component` are reserved names that callers
  set (child loggers, audit events).
- In JSON records the envelope keys (`event`, `component`, `correlationId`,
  `server`, `error`, `category`, `droppedFieldCount`) sit at the top level and
  every other registered field sits under `fields`. The pretty console format,
  syslog and the dashboard stream keep the flat layout.

### Field allowlist

Every key a caller passes (as metadata or as child-logger context) is checked
against the registry in `src/lib/logFields.js` after redaction. The registry
gives each key a data class, a type and, for objects, the allowed child keys
(maximum depth 3).

| Class | Emitted | Examples |
|---|---|---|
| `op` | always | `durationMs`, `added`, `updated`, `attempt` |
| `id` | always | `accountId`, `serverName`, `syncId` |
| `account-name` | only with `logging.includeAccountNames: true` (default `false`); otherwise left out of the record without counting in `droppedFieldCount` | `accountName`, `succeededAccounts`, `name` inside `failedAccounts` and `skippedAccounts` |
| `personal` | by default; withheld when the logger has `includePersonal: false`. `remoteAddress` is the exception: security (audit) records keep it either way | `remoteAddress`, `dataDir` |
| `upstream` | never | transaction rows, payees, notes, upstream error text |
| `financial` | never | amounts, balances, account numbers |
| `secret` | never | passwords, tokens, webhook URLs, chat ids |

- A key that is not registered is dropped, with its value. So is a registered
  key whose value has the wrong type, or an object with unregistered child
  keys or nesting deeper than 3.
- The record carries `droppedFieldCount` (a number, only when greater than 0).
  It never names the dropped keys, because a key name can be data.
- To log a new field, register it in `src/lib/logFields.js` with its class.
  The test setup fails any test that drops a field, so a missing registration
  shows up as a failing test, not as silently missing data.
- Account names have their own gate, `logging.includeAccountNames` (default
  `false`, #448), separate from `personal` because that class also covers paths
  and addresses, which this setting must not switch on. With the default, a
  sync log carries `accountId`, `succeededAccountIds` and an `id` in each
  `failedAccounts` / `skippedAccounts` entry, and no account names. Server
  names are chosen by the operator and are always logged. Notifications are
  not logs and keep account names. The `sys_startup` record reports the
  effective value as `includeAccountNames`.
- `logging.redact` and the secret masking still run first. With the allowlist
  they are defence in depth: they catch a secret-looking value under a
  registered key.

### Event catalogue

| Event | Level | Meaning |
|---|---|---|
| `sync.account.completed` | INFO | One account finished syncing |
| `third_party.console` | per call | `@actual-app/api` console output through the bridge |
| `process.rejection_benign` | WARN | An allowlisted, already-handled rejection (#105). Same name in actual-mcp-server |
| `security.suppressed` | WARN | The security rate limiter tripped; carries `suppressedCount` |
| `authn_login_success` | INFO | Dashboard login accepted (once per session) |
| `authn_login_fail` | WARN | Dashboard login refused |
| `authz_fail` | WARN | Unauthorized Telegram chat or refused origin |
| `input_validation_fail` | WARN | Rejected API input |
| `sys_startup`, `sys_shutdown` | INFO | Process lifecycle |
| `sys_crash` | ERROR | Uncaught exception or rejection |
| `sys_config_change` | INFO | A setting, payment rule or server changed (our name; OWASP has no exact term) |

Security events use the OWASP Logging Vocabulary names verbatim, carry
`category: "security"`, may carry `remoteAddress`, and never carry request
bodies, message text or user names. New events are added to this table with
the change that emits them.

### Error categories

A logged error is `error: { type, code, httpStatus, category, safeMessage }`.
`category` is one of `timeout`, `auth`, `upstream_unavailable`, `rate_limited`,
`not_found`, `validation`, `conflict`, `cancelled`, `internal`. Raw upstream
error text is never logged, at any level.

One classifier (`src/lib/errorClassifier.js`) feeds the log, `sync_history`,
the dashboard and every notification, so the three never disagree (#438):

- `safeMessage` is our own text for the category, our own text from an error
  we marked (`markOwnError`, for example the phase timeout), or a full-string
  match on a small allowlist (`503 Service Unavailable`, Actual reasons such as
  `network-failure`). A partial match is not a match. Keywords in an upstream
  message may choose the category; the text is never copied.
- The logger classifies every `error`, `originalError` and `err` value (an
  `Error` or a string) and every `Error` nested in a field. It drops
  `errorMessage`, `errorStack` and `errorName`. A `stack` field is written as
  `frames`, with the frames only (no message line). In the allowlist, `error`
  and `originalError` accept only the classified object: a string error is
  dropped and counted.
- Frames are logged at DEBUG only, and only for our own errors (first frame
  not inside `node_modules` or `@actual-app/api`).
- `sync_history.error_message`, the per-account failures in a notification and
  the Telegram error replies carry the classified text. A one-time migration
  (`PRAGMA user_version = 1`) rewrote older rows to their category text.

### Startup record groups

`sys_startup` (#443) is written once, after every sink is attached, through
`logger.security()` (INFO, written whatever the configured level). Fields
(built one by one in `src/lib/startupRecord.js`, never copied from the config):

| Group | Fields |
|---|---|
| versions | `appVersion`, `nodeVersion`, `actualApiVersion` (resolved), `pid`, `platform`, `container` |
| time | `timezone`, `utcOffset` (`+01:00`; cron schedules use this zone) |
| logging | `logLevel`, `actualApiVerbose`, `sinks` (`console`, `file`, `syslog`, `websocket`), `customLogDir`, `customDataDir` (booleans, never paths), `retentionDays` (`null` when file logging is off). `sinks` lists configured sinks, not reached ones: `websocket` is listed when the dashboard is enabled, even before a client connects |
| privacy | `actualApiVerbose`, `includeAccountNames` (the effective value the logger gate uses) |
| sync | `serverCount`, `cronSchedules` (unique cron strings), `timeouts.phaseTimeoutSeconds` |
| channels | `channelsEnabled.{email,telegram,slack,discord,webhook,ntfy}` (booleans) |
| dashboard | `dashboardEnabled`, `dashboardBind` (`host:port`), `dashboardAuth` (`none`, `basic`, `token`), `tls` |
| config | `configSchemaVersion` (an integer in `startupRecord.js`, bumped when `config.schema.json` changes; never a hash of values) |

No URLs, credentials, paths or server names. `tls` is always `false`: the
HTTP server is plain http and TLS comes from a reverse proxy.

### Key mapping with actual-mcp-server

| Concept | Actual-sync | actual-mcp-server |
|---|---|---|
| component | `component` | `module` |
| correlation | `correlationId` | `sessionId`, `requestId` |

---

## 🎨 Output Formats

### Pretty Format (Default)

Human-readable console output:

```
2025-12-09T13:00:00.000Z [INFO] [a1b2c3d4] Starting sync for server: Main {
  "url": "https://example.com",
  "maxRetries": 5
}
```

### JSON Format

Machine-parseable structured logs:

```json
{
  "timestamp": "2025-12-09T13:00:00.000Z",
  "level": "INFO",
  "service": "actual-sync",
  "message": "Starting sync for server: Main",
  "correlationId": "a1b2c3d4",
  "server": "Main"
}
```

### Console vs File

`format` controls the **console** only; `fileFormat` controls **files** and defaults to `json`. So by default you get human-friendly pretty output on the console and single-line JSON in the log files, which is what log shippers and `jq` want. Set `fileFormat: "pretty"` only if you read the raw files by eye and do not ship them.

The file line is always a single line, even when the console is `pretty`.

---

## Actual API console output

`@actual-app/api` prints warnings and errors straight to the console, whatever `actualApiVerbose` says. During each sync session Actual-sync routes that output through its own logger as one static record per call: `event: "third_party.console"`, `component: "actual-api"`, message `Actual API console output`. The upstream arguments are dropped (they can carry transactions or upstream text), so the record has the configured level, format and redaction but no payload. `console.error` maps to ERROR, `warn` to WARN, `log`/`info`/`group` to INFO and `debug` to DEBUG. With `actualApiVerbose: true` a masked `consolePreview` (first 300 characters) is added; these records are never sent to `/ws/logs`.

## Startup output before the logger exists

The startup banner, config validation warnings and config load failures go
through the bootstrap logger (`src/lib/bootstrapLogger.js`, #442), not raw
`console` calls. It needs no config: the built-in secret redaction and control
character escaping apply to every record. Records are buffered and replayed
into the root logger as soon as it is created (so they follow `logging.level`
and `logging.format`). If startup dies first (validation failure, config load
error), the buffered records are written to stderr as JSON lines.

## 🔒 Secret Redaction

Every value is redacted before it is written to **any** destination (console, file, syslog, and the dashboard WebSocket). You never have to remember to scrub metadata.

A property is masked as `[REDACTED]` when its key (case-insensitive, at any depth) contains a secret indicator: `password`, `token`, `secret`, `apikey` / `api_key`, `authorization`, `credential`, `chatid`. This catches variants automatically (`encryptionPassword`, `botToken`, `refreshToken`, `accessToken`, `clientSecret`, ...).

Secret-looking substrings inside string values are also masked: Telegram bot-token URLs, URL userinfo credentials (`https://user:secret@host`), `Bearer <token>`, and `key=value` / `"key":"value"` pairs for secret-named keys.

Add your own keys (the defaults always apply):

```json
{
  "logging": {
    "redact": ["ssn", "accountNumber"]
  }
}
```

**IP addresses**: the client address (`remoteAddress`) is recorded by these log lines, for security monitoring and an audit trail of dashboard actions (GDPR Art. 6(1)(f)):

- WARN: failed dashboard logins, and requests to unknown endpoints.
- INFO: every dashboard action (manual sync, error dismiss, history reset, test notification).
- INFO: live log stream (`/ws/logs`) connections.
- DEBUG: `/health`, `/metrics` and Prometheus scrapes.

Behind a reverse proxy the HTTP lines show the real client IP only when `healthCheck.trustProxy` is set; otherwise they show the proxy's address. The `/ws/logs` lines follow the same rule: the forwarded client with `healthCheck.trustProxy`, otherwise the TCP peer, which behind a proxy is the proxy. Log files follow the file retention (`rotation.maxFiles`, 30 days by default). Console output is kept by whatever collects it: under Docker, stdout is kept until the container is removed unless you set log rotation on the container (for example `logging: { driver: json-file, options: { max-size: 10m, max-file: "5" } }` in Compose).

Notes:
- Non-secret data is preserved, including `Date`, `Buffer`, and `Error` values (an `Error` keeps its `message`, `stack`, `code`, `cause`, and custom fields such as `statusCode`, all redacted).
- Redaction never mutates the object you passed and never throws.

---

## 🔄 Log Rotation

Automatic file rotation, daily or by size (whichever comes first), with compression:

```json
{
  "logging": {
    "rotation": {
      "enabled": true,
      "maxSize": "10M",
      "interval": "1d",
      "maxFiles": 10,
      "compress": "gzip"
    }
  }
}
```

Files: `actual-sync.log`, `actual-sync-2025-12-08.log.gz`, etc. The daily `interval` matters on low-volume deployments: without it, a quiet server never reaches `maxSize`, so a single file grows for months and `maxFiles` retention never applies.

---

## 📡 Centralized Logging (Syslog)

Send logs to syslog server (RFC 5424):

```json
{
  "logging": {
    "syslog": {
      "enabled": true,
      "host": "syslog.example.com",
      "port": 514,
      "protocol": "udp",
      "facility": 16
    }
  }
}
```

**Facility 16** = local0 (recommended for Actual-sync)

**Transports**

- `udp` (default): **plaintext and unreliable.** Datagrams are unencrypted, can be lost or reordered, and anyone on the path can read them. It is not an audit log. Use it only on a trusted network.
- `tcp`: RFC 6587 octet-counting framing (`<len> <msg>`). Reliable, but still plaintext.
- `tls`: TCP framing inside TLS (RFC 5425). The server certificate is always verified; set `syslog.ca` to a PEM file to trust a private CA. There is no option to disable verification.

For `tcp` and `tls` the logger reconnects with exponential backoff (1s up to 30s). While disconnected, messages wait in a bounded in-memory queue of 1000; when full the oldest are dropped. Logging never throws or blocks on a down syslog server.

---

## ⏱️ Performance Tracking

Automatically log operation duration:

```json
{
  "logging": {
    "performance": {
      "enabled": true,
      "thresholds": {
        "slow": 1000,
        "verySlow": 5000
      }
    }
  }
}
```

Output:
```
{"timestamp":"2025-12-09T13:00:05.891Z","level":"INFO","message":"Performance: sync-Main","fields":{"duration":5368,"durationMs":5368,"operation":"sync-Main","slow":true,"verySlow":true}}
```

---

## 🔍 Correlation IDs

Track related log entries with unique IDs:

```
[a1b2c3d4] Starting sync for server: Main
[a1b2c3d4] Connected to Actual server
[a1b2c3d4] Sync completed
```

Search by correlation ID:
```bash
grep "a1b2c3d4" logs/actual-sync-2025-12-09.log
```

Every sync, HTTP request, Telegram update and scheduled job runs in its own
scope (Node `AsyncLocalStorage`). Every record written inside it carries that
scope's `correlationId`, from any logger or child, without passing a logger
around. A sync started by a scheduled job or a Telegram command gets its own id
for its own lines (the id stored in sync history). Child loggers carry
`component` and similar bindings only, never a correlation id.

---

## 👶 Child Loggers & Context

Create loggers with inherited context:

```javascript
const logger = createLogger();
const serverLogger = logger.child({ server: 'Main' });

serverLogger.info('Starting sync'); 
// Logs: {"server": "Main", "message": "Starting sync"}
```

Per-server loggers are automatically created with appropriate context.

---

## 📝 Usage Examples

### Basic Logging

```javascript
const { createLogger } = require('./lib/logger');
const logger = createLogger();

logger.error('Critical error', { errorCode: 500 });
logger.warn('Warning', { remaining: 10 });
logger.info('Info message', { server: 'Main' });
logger.debug('Debug details', { detail: 'value' });
```

### With Error Objects

```javascript
try {
  await riskyOperation();
} catch (error) {
  logger.error(error); // Auto-extracts name, message, stack
}
```

### Performance Tracking

```javascript
const endTimer = logger.startTimer('operation-name');
await doWork();
endTimer({ metadata: 'value' }); // Logs duration automatically
```

---

## 🚨 Alertable Security Events

These are security events (#444, design D7). They are an audit floor: written
whatever `logging.level` is, so `level: error` does not hide a guessing loop.
They carry `category: "security"` and the OWASP Logging Vocabulary name as
`event`, plus `remoteAddress`, and never a body, a user name, a password or a
token.

| `event` | Meaning |
|---|---|
| `authn_login_fail` | One refused dashboard or metrics credential (`reason`: `invalid_credentials`, `invalid_token` or `throttled`; `realm`: `dashboard` or `metrics`). `throttled` means the client already has 10 failed logins in the current 15 minute window and is refused with 429 (#246). A steady stream at the scrape interval on `realm: metrics` is a Prometheus with a stale credential (#247) |
| `authn_login_success` | A good dashboard or metrics credential, logged at INFO at most once per source address per hour (Basic and bearer have no session) |
| `authz_fail` | A refused request origin (`reason: origin_not_allowed`) or a message from an unauthorized Telegram chat |
| `security.suppressed` | The per-source limiter tripped: more than 10 records of one event from one address in a minute. One record per window carries `suppressedEvent`, `remoteAddress` and `suppressedCount`, so a guess loop never looks like it stopped |

`WebSocket handshake refused` (a `/ws/logs` connection refused, with the status
and reason) is still a plain WARN line.

```bash
grep '"category":"security"' logs/actual-sync-*.log
```

## 🔎 Searching Logs

### Pretty Format

```bash
# Find errors
grep "\[ERROR\]" logs/actual-sync-2025-12-09.log

# Find by server
grep '"server":"Main"' logs/actual-sync-2025-12-09.log

# Follow real-time
tail -f logs/actual-sync-2025-12-09.log
```

### JSON Format

```bash
# Parse with jq
cat logs/actual-sync.log | jq '.'

# Filter by level
cat logs/actual-sync.log | jq 'select(.level=="ERROR")'

# Count errors per server
cat logs/actual-sync.log | jq 'select(.level=="ERROR") | .server' | sort | uniq -c
```

---

## 🎯 Best Practices

### Development

```json
{
  "logging": {
    "level": "DEBUG",
    "format": "pretty",
    "logDir": null,  // Disable file logging in dev
    "rotation": {
      "enabled": false  // Not needed without file logging
    },
    "performance": { "enabled": true }
  }
}
```

### Production (Default Settings)

```json
{
  "logging": {
    "level": "INFO",
    "format": "json",
    "logDir": "/var/log/actual-sync",
    "rotation": {
      "enabled": true,    // Default: enabled
      "maxSize": "10M",   // Default: 10M
      "maxFiles": 30,     // Default: 30 days
      "compress": "gzip"  // Default: gzip
    },
    "syslog": {
      "enabled": true,
      "host": "syslog.example.com"
    },
    "performance": { "enabled": true }
  }
}
```

### Customizing Retention

**Minimal retention (7 days)**:
```json
{
  "logging": {
    "rotation": {
      "maxFiles": 7
    }
  }
}
```

**Extended retention (90 days for compliance)**:
```json
{
  "logging": {
    "rotation": {
      "maxFiles": 90
    }
  }
}
```

**High-traffic servers (larger files)**:
```json
{
  "logging": {
    "rotation": {
      "maxSize": "50M",  // Allow larger files before rotation
      "maxFiles": 30
    }
  }
}
```

**Disable rotation (not recommended)**:
```json
{
  "logging": {
    "rotation": {
      "enabled": false  // Logs will grow indefinitely
    }
  }
}
```

---

## 🐳 Docker Logging

Mount logs volume:

```yaml
services:
  actual-sync:
    volumes:
      - ./logs:/app/logs
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
```

View logs:
```bash
docker compose logs -f actual-sync
```

---

## 🔧 Troubleshooting

### Logs Not Appearing

Check log level and directory permissions:
```bash
grep '"level"' config/config.json
ls -ld logs/
```

### Rotation Not Working

Verify config and disk space:
```bash
grep "rotation" config/config.json
df -h /var/log
```

### Syslog Not Receiving

Test connectivity:
```bash
nc -u syslog.example.com 514 < /dev/null
```

### File Descriptor Warnings

Always close logger on shutdown:
```javascript
logger.close();
```

---

## 📚 Related Documentation

- [Configuration Guide](./CONFIG.md)
- [Dashboard](./DASHBOARD.md)
- [Health Check](./HEALTH_CHECK.md)
- [Prometheus Metrics](./PROMETHEUS.md)

---

For detailed examples and advanced usage, see the full sections above or check `config/config.example.json`.