# Pseudo-events

> log-table polling

---

webtrees has no event bus, so core actions (logins, failed logins, logouts, record edits, errors, searches) are detected by **polling the webtrees log table** (`app/Log.php`, table `log`) and firing an event per new matching log entry. It is **off by default**: nothing runs until you enable the offered `cronjob:pseudo-events` job.

**Built-in events** (one `cj_event` per new log row, payload = the row):

| Event | Log filter (log_type, message prefix) | Fires when |
| :--- | :--- | :--- |
| `cronjob:log-auth-failed` | `auth`, `Login failed` | a login attempt failed (wrong password, unknown user, unverified, not approved, no session cookies) |
| `cronjob:log-auth-login` | `auth`, `Login:` | a user logged in |
| `cronjob:log-auth-logout` | `auth`, `Logout:` | a user logged out |
| `cronjob:log-error` | `error`, – | an exception was caught and logged (the message is the trace) |
| `cronjob:log-edit-update` | `edit`, `Update:` | a record was updated (`GedcomRecord::updateRecord()`) |
| `cronjob:log-edit-delete` | `edit`, `Delete:` | a record was deleted (`GedcomRecord::deleteRecord()`) |
| `cronjob:log-search` | `search`, – | a search ran (**one log row per searched tree**) |

**Payload:** `log_id`, `log_time`, `log_message` (truncated to 250 characters, UTF-8-safe) plus `gedcom_id` / `user_id` when the log row carries them. The message prefixes are the hard-coded English strings of the current core; a core update that changes them silently stops the matching event (no fallback by design) — the event catalog descriptions and the `test-pseudo-events` suite pin the current mapping.

**How it runs**

- **One trigger, one cooldown.** Detection runs only from the offered `cronjob:pseudo-events` job (`*/5 * * * *` by default), in the tick's child process — isolated from any web request and bounded by the job's timeout, so a heavy detector can never block or slow down a page load. A 5-minute cooldown (a `data/` mtime marker) plus a lock keep repeated runs cheap and idempotent; the cooldown also caps a more frequent job cron.
- **Enabled by the job, gated per event.** Enabling the `cronjob:pseudo-events` job turns detection on (there is no separate switch — the tick only runs enabled jobs). A detector whose event no enabled event-triggered job listens for is skipped entirely — no query, no push.
- **Checkpoint + backpressure.** Each detector remembers the highest `log_id` it processed (state file). At most 200 rows are fired per run per detector; the checkpoint advances only as far as the last fired row, so a flood is drained over several runs — no data loss, only latency.
- **Baseline on first run.** The first detection only records the current max `log_id` and fires nothing, so enabling the job does not retroactively fire on existing data. (Re-listening to a previously unlistened event catches up its backlog, capped as above.)
- **Reset, not re-fire, on table shrink.** If the table shrank below a checkpoint (TRUNCATE / DB restore — plain row deletions cannot do this, MySQL auto-increment continues after deletes), the detector re-baselines silently.
- **Offline-safe & isolated.** Skipped while `data/offline.txt` exists; a broken detector never breaks the others; detection is serialized (flock) and state is persisted atomically to `data/cronjob/pseudo-events.json`.

The **log table is the source of truth**: under a flood the tick's coalescing runs a job once per event name per tick with the newest payload — older rows are not re-delivered, but they stay in the log table, and `log_id` in the payload lets a job that needs completeness re-read the range itself.

> If an event name vanishes, the admin table flags such triggers with the **unknown event** badge.

Detected events go into the same `cj_event` queue as webhooks and are consumed by the tick's normal event drain. To act on one, create an event-triggered job whose **Event name** matches (e.g. alert on `cronjob:log-auth-failed`, re-index on `cronjob:log-edit-update`).
