Troubleshooting & FAQ
This page collects the failures people actually hit when running tripl, written as symptom → likely cause → fix playbooks. Each cause is grounded in how the worker, adapters, and startup checks actually behave — not guesswork.
Before you dig into a specific symptom, two facts explain most "nothing is happening" reports:
- The background worker and scheduler do the work, not the API. Scans,
metric collection, anomaly detection, and alert delivery all run as Celery
tasks on the
celery-workercontainer, dispatched on a schedule bycelery-beat. If either container is down, the UI stays up but nothing progresses. - Filling the catalog and collecting metric points are two different things.
A run of a scan (
run_scan) discovers events and fills the tracking plan. Metric points, anomalies, and alerts come from a separate, scheduledcollect_metricstask, dispatched only for scans that have both a schedule and a time column. Starting a run does not record metric points by itself.
A quick health check for a Docker deployment:
docker compose ps
docker compose logs -f celery-worker
docker compose logs -f celery-beat
You want to see postgres, rabbitmq, redis, app, celery-worker, and
celery-beat healthy/running, and the migrate one-shot already exited
successfully (Exited (0) in docker compose ps).
The browser repeatedly logs a realtime stream protocol error
Symptom. Project pages keep working, but Chrome repeatedly logs
ERR_QUIC_PROTOCOL_ERROR 200 (OK) for
/api/v1/projects/{slug}/events/stream.
Cause. The request is a long-lived Server-Sent Events stream. A 200
means the response started successfully; the protocol error means an HTTP/3
edge or proxy then rejected or reset that stream. Make sure no intermediary
adds or forwards connection-specific headers such as Connection or
Keep-Alive on HTTP/2 or HTTP/3 responses.
Fix. Upgrade tripl to a version whose realtime endpoint does not emit
connection-specific headers, and verify the edge preserves
Content-Type: text/event-stream, disables response buffering, and allows a
streaming response to remain open without applying response compression. The
endpoint sends a heartbeat every 15 seconds, so an edge idle timeout comfortably
above that value should not close healthy streams. While disconnected, the
frontend falls back to polling and reconnects with exponential backoff.
No metrics appear after a scan
Symptom. A scan finished and the catalog filled with events, but the monitoring charts stay empty and no anomalies or alerts ever show up.
Likely causes.
- The scan is not a monitoring scan. The dispatcher (
check_metrics_due) only ever selects configs where bothintervalandtime_columnare set. A config missing either is silently skipped — it will never collect metrics, only catalog events. Its runs still succeed, which is why this looks like nothing is wrong. celery-beatis not running. Metric collection is triggered by the beat schedule entrycheck-metrics-due, which fires every 300 seconds. With no beat container,collect_metricsis never dispatched.- The first complete bucket hasn't closed yet. On the very first run
(nothing collected yet) the dispatcher collects immediately; after that it
only fires once a new complete interval bucket exists — the latest complete
bucket is
floor(now) - interval. For a 6h interval you may wait up to 6 hours for the next point to appear. A collection that ran and found no rows counts as collected for that window, so a config whose warehouse window is still empty retries once per interval rather than every five minutes; it is not stuck. - The time column doesn't actually constrain the window. Metrics are
bucketed on
time_column. If the column isn't a usable timestamp in the warehouse, the windowed query returns nothing to bucket. - The worker can't reach the warehouse.
collect_metricsconnects to your data source the same way a scan does; a broken connection fails the run (see A scan run fails).
Fix.
-
Open Govern → Scans and look at the badge on the scan's row. Catalog only or Needs a time column means this scan was never going to produce a metric point, and the fix is in the form, not in the infrastructure.
-
Open the scan and check What this scan does at the top of the form. Choose Catalog + monitoring, then fill in the Time column and Schedule it asks for — the form refuses to save until both are answered. Save, then wait one beat cycle (≤ 5 minutes) for the first collection, or fill a past window right away with Run a one-off replay on the scan's Configuration tab (it needs the same time column and schedule, so it unlocks with them).
-
If the scan is deliberately Catalog only, nothing is broken: that mode records no metric points by design, so it raises no anomalies and sends no alerts.
-
Confirm beat is alive:
docker compose logs --tail=50 celery-beatdocker compose logs --tail=100 celery-worker | grep check_metrics_dueYou should see lines like
check_metrics_due: N configs checked, M dispatchedandDispatching collect_metrics for '<name>' (interval=...). -
If you just created the config and it's a long interval, give it one full interval before expecting a second data point.
Each scan runs at most one active collection at a time. If a previous run is genuinely stuck (worker OOM/redeploy with no heartbeat), the dispatcher marks it failed after 75 minutes without progress and lets the next run proceed — so a wedged run self-heals within that window rather than blocking collection forever.
Alerts never fire
Symptom. Anomalies show up in the monitoring view (or you expect them to), but no Slack/Telegram/email/webhook/Jira/Linear notification ever arrives.
The delivery chain is: collect_metrics → recalculate anomalies → match rules
and create AlertDelivery rows (pending) → send_alert_delivery actually
sends. A break anywhere in that chain produces silence.
Likely causes & fixes.
-
No anomaly was detected. Detection is statistical, not a fixed threshold. A series needs enough history before the seasonal (phase) baseline engages — until then it falls back to a rolling baseline that itself needs
min_history_bucketsof data, and low-volume series belowmin_expected_countare skipped entirely so noise doesn't flood you. Brand-new scans, sparse data, or a too-highsigma_thresholdall legitimately produce zero anomalies. Review the project's anomaly settings (baseline window, sigma threshold, minimum expected count) and let more history accumulate. If you have marked incidents on this scope a false positive, check Settings → Monitoring → Scope overrides too: each click permanently raised that scope'ssigma_thresholdandmin_expected_count, and the ratchet never decays — see False positives self-tune the thresholds. -
No enabled alert destination. If the project has no destinations, or none are enabled, no deliveries are created. Add and enable a destination.
-
No enabled rule, or the rule doesn't match. A destination with no enabled rules is skipped. A rule only fires for anomalies it matches (by scope/direction/etc.). Check the rule is enabled and its scope covers the anomaly you expect.
-
Cooldown, or a decision you made in the Inbox. A rule won't re-notify the same scope until its
cooldown_minuteselapses. Separately, an incident you marked acknowledged, resolved, false positive or muted produces no further deliveries at all — it is dropped before any message is built. All four suppress. Acknowledged is the one people forget, because it reads like a receipt; it is not, and an acknowledged incident is a common answer to "the rule is on, the destination is on, and nothing arrives". Suppression is per incident, meaning per (scan, rule, scope, direction), so it silences one scope of one rule and nothing else.They do not expire alike. Acknowledged, resolved and false positive last only as long as the incident: the first collection in which that scope stops firing puts the row back to
open, so the next occurrence is a new incident and alerts normally. A mute is the exception — it holds for its fullmuted_untilwhatever the signal does in between, and an indefinite mute (Inbox only) holds until you press Reopen. A lapsed mute counts asopenagain on its own.Open incidents sort above handled ones, so a silenced row is reached with the status filter (
?status=acknowledged/resolved/muted/false_positive) rather than by scrolling. Reopen lifts any of the four. Which decision you actually wanted is Silencing an incident. -
Email destination but SMTP isn't configured. Email sends fail with: "Email destination is configured but SMTP is not — set SMTP_HOST (and SMTP_USERNAME/SMTP_PASSWORD if your relay requires auth)." Set
SMTP_HOST, plusSMTP_FROM_ADDRESSor a per-destination From: address. The worker reads SMTP settings at send time, so a config change takes effect without recreating the destination. -
Destination credentials are invalid. Slack/Telegram/webhook/Jira/Linear each re-validate their secret at send time; on failure the delivery is marked
failedwith a message like "Slack destination configuration is invalid. Update the webhook URL." Check the failed delivery's error in the UI/logs. -
The delivery was stranded. If the worker died between creating the
pendingdelivery and dispatching it, or the broker was down at dispatch, a maintenance task (requeue_stranded_alert_deliveries, every 5 minutes) re-enqueues deliveries stillpendingafter 15 minutes, up to 5 attempts, then marks themfailed. A permanently failing delivery will eventually stop cycling and show as failed. -
A drift scope is on but nothing feeds it. The two drift scopes act on signals another part of the project has to produce first, so a rule can have one enabled and be structurally unable to fire. Variable value drift needs some variable to document an allowed-values list on the main branch — the variable's own list or a per-event override, either one is enough — which you supply under Variables, or a value drift already collected in the project (an open or snoozed row from the last 30 days); values documented on a working branch do not count until it merges. Distribution drift needs a scan that names the columns to watch (Scan settings → Distribution drift), or drift already collected in the project. The monitor's own screens now say this out loud: the rule editor and the monitor detail mark such a scope inline and link to the screen that supplies the missing data. The check behind those notices is project-wide, so a rule bound to a single scan shows no warning as long as some scan watches a column — see When a scope is on but nothing feeds it.
Fix workflow.
# collect_metrics logs its result, including how many deliveries it queued:
docker compose logs --tail=200 celery-worker | grep -iE "collect_metrics|alerts_queued"
# Why did a specific send fail? The error is persisted on the delivery and logged:
docker compose logs --tail=200 celery-worker | grep "Failed to send alert delivery"
Use the in-app rule simulator to confirm a rule matches a given anomaly before
blaming delivery — the simulator and the live pipeline share the same matcher
(tripl.alerting_matching), so if it doesn't match in the simulator it won't
match live either.
An incident I acknowledged fired again
Symptom. You acknowledged an incident in the Inbox — probably with a note saying what it was — and hours or days later the same scope paged you again. The obvious conclusion is that acknowledge does nothing, and the obvious next move is to mute it. Both are wrong, and muting is often the wrong lever for what you meant.
What actually happened. Acknowledge does suppress. An acknowledged incident
creates no further deliveries: it is dropped before the messages are built, not
built and quietly withheld. But the decision is scoped to that incident, and an
incident ends when its scope stops firing — at that moment the row returns to
open by itself. The next time the scope deviates it is a new incident, and a
new incident has never been acknowledged, so it alerts. That is deliberate:
whatever you decided about last Tuesday's drop must not silence next month's
outage.
So "it came back" almost always means the problem came back, with a quiet stretch in between that ended your incident. Check by expanding the row: a different row with a later first delivery is a genuine relapse; the same row still open means somebody pressed Reopen or a mute lapsed; a second row under another rule's name means two rules watch that scope and you silenced one of them.
Which lever you want depends on what you meant. Silencing an incident sets all of them side by side; the short form:
- "I am working on it and want quiet meanwhile." Acknowledge was already the right action and nothing is broken. Re-acknowledge the new incident, or fix the cause.
- "I want a guaranteed quiet window regardless of the signal." Mute — 1h, 24h, 7d, or indefinitely. A mute is the one decision that survives the incident ending: it holds for its whole duration whatever the data does, and an indefinite mute holds until you Reopen it. It changes nothing else, so the cause is still there when it lifts.
- "The alert is wrong for this scope." False positive. It silences the
incident exactly as an acknowledge does and permanently raises that scope's
sigma_thresholdandmin_expected_count, so the same benign pattern needs a bigger, higher-volume deviation to be flagged next time. Repeat clicks compound, the ratchet never decays, and every scope it has tightened is listed under Settings → Monitoring → Scope overrides — see False positives self-tune the thresholds. This is the lever for "the detector is wrong here", and the only one of the four that changes what gets detected. - "This event must never reach this channel again." Add a filter to the
rule —
eventnot_in[…]. It is permanent, per rule, and has no expiry. One catch: it excludes that event's own signals only. The project-total and event-type rollups the event contributes to carry no event id, and a filter on a field a signal does not carry passes through — so a rule watching project totals will still tell you when the total moves, event filter or not. - "This event should not be monitored at all." Archive the event. Scans stop collecting for it and detection skips it on every scope it appears in, so there is no signal left for any rule to match. Archiving is reversible; set another status and collection resumes.
They answer different questions. Acknowledge answers "is somebody on this?" and is worth pressing even when you know it will lapse, because the row then says handled by you and your colleagues stop opening the same incident. Mute answers "when may I be told again?". Pressing mute to mean "seen" loses the first answer and buys a silence you did not want.
The scan preview names no events
Symptom. The preview loads, but What this scan would create lists zero events, or far fewer than you expected.
The dry run is the same planner a real run uses, so "it would create nothing" is a real answer, not a broken panel. Six causes, in the order worth checking:
-
Nothing tells the scan how to name events. A scan names its events one of two ways: an Event type, which makes every row the same event, or an Event type column, whose values become the event names. With neither, the panel says so — Nothing tells this scan how to name events yet — and asks nothing of your warehouse, because there is nothing it could answer. This is also why Create scan stays disabled: a config with neither cannot ingest a single event, so a scan that has it would fail every run. Both controls sit together in the form's always-visible block; the column is picked from your query's columns, so load a preview first.
-
The window is empty. When the scan has a Time column, the dry run only reads rows inside its lookback (Limits → Lookback (hours), default 24). If your table has no rows in that window — a staging table, a backfill that stopped, a timestamp column in the wrong unit — there is nothing to name. The summary prints the window it used; widen the lookback or clear the time column to check. With no time column there is no window at all: the summary says No time window — the whole base query was read, and an empty answer is then about your whole query, not about a window.
-
The event name format is broken. If the panel shows Event name format error, that is the whole answer: a format referencing a key the rows cannot supply fails every run of the config, not just the preview. The message names the missing key and lists the keys that are available. Fix the format before creating the scan — this is exactly the failure the dry run exists to catch early.
-
Cardinality collapsed everything into one event. A column with more distinct values than the Cardinality threshold becomes a
${column}template rather than one event per value, so thousands of rows can legitimately produce one event. The panel names each collapsed column and its distinct count. Raise the threshold, or name the column in Event name format — a column the name is built from is always enumerated regardless of cardinality. -
The columns are unmapped or reserved. With an explicit Event type, a scan only uses columns that event type already declares; the rest are listed as unmapped and are skipped by a real run too. Columns filling a reserved role (event type, time, app version, platform, or an event-group-rule column) are listed as reserved: tripl already uses these, so they never become event fields, and their absence from the plan is intentional.
-
The derived name came out empty. Where the Event name format resolves to nothing — every column it names was NULL for those rows — the row is skipped rather than turned into a nameless event, and the panel warns Skipped N rows whose derived event name was empty (singular row when N is 1). A real run does the same, so this is not a preview artefact. Fix it in the format (name a column those rows actually populate, or add a literal segment) or in the base query (filter the NULL rows out, or coalesce the column). Note that a name with empty segments —
::,onboarding:start:— is not this case: those are real identities and are planned normally.
If the events list is present but prefixed with at least, nothing is wrong — the sample hit its cap, and the count is a floor rather than a total. That case has its own entry below.
The preview says "would create at least N events"
Symptom. The preview names events, but hedges the count.
Nothing is wrong. The dry run reads the most common column combinations inside the scan's lookback window — or across the whole base query, if the scan has no time column to window on — up to a cap of 5,000. When it hits that cap, more distinct events exist than it looked at, so N is a floor: the scan would create at least that many, possibly more. Saying a flat N there would be the one claim the panel is built not to make.
The panel also prints More distinct events exist than this preview looked at, followed by the remedies that apply to your scan. There are two, and the panel names the second one only when your scan actually has a window to shorten:
- Narrow the base query. Fewer columns means fewer combinations, so the same cap covers more of your data. Dropping a high-cardinality column you were not going to name events from is usually enough.
- Shorten the window. Limits → Lookback (hours) decides how much data the dry run reads at all. A shorter window with the same cap is more likely to be complete — but remember it is then a statement about less of your data, not about more of it. The lookback needs a Time column: it is the predicate the window is expressed on, so the form asks for the column before it offers the field. Without one there is no window, the cap is the only bound, and the panel does not offer this remedy at all.
There is deliberately no third remedy. The sample cap itself (sample_row_limit)
is an API-only field with no control in the product, so the panel never suggests
raising it.
A count with no at least is exact for what it read: every distinct event in the sample, with the exact number of sampled rows behind each one. It is still not a table-wide total, because the lookback window is a separate bound. The panel always says which window it used, including when there was none.
See Scans → The dry run.
A scan run fails / a data-source connection test fails
Symptom. A scan run ends in failed, or the Test connection button on a
data source returns an error.
How errors are surfaced. Raw driver/ORM exceptions embed hostnames, ports, and library names, so tripl never shows them verbatim. User-facing fields get a sanitized summary instead. A failed scan run reads:
- "Scan failed: the data source did not respond in time." — a timeout.
- "Scan failed: could not connect to the data source." — connection refused, DNS failure, network unreachable, reset, etc.
- "Scan failed due to an internal error." — anything else.
A failed connection test is sanitized the same way but worded for what it is: it always begins "Connection test failed" and never "Scan failed" — a source you have never scanned cannot report a failed scan.
- "Connection test failed: the data source did not respond in time."
- "Connection test failed: could not reach the data source — check the host, port, and network."
- "Connection test failed: authentication was rejected — check the credentials."
- "Connection test failed. Check the connection settings and try again." — anything else.
The full exception (with host/port/driver detail) is only in the worker logs, so always check there first:
docker compose logs --tail=200 celery-worker | grep -iE "scan failed|connection"
A handful of scan conditions are surfaced verbatim because they're
actionable. They all begin Scan failed: — that prefix is what tells the UI a
message was authored by tripl and is safe to show as-is, so the backend adds it
to every curated message rather than leaving each raise site to remember it:
- Row limit reached. "Scan failed: The scan query reached the configured row limit (50000); increase scan_row_limit to avoid partial generation." The default cap is 50,000 rows for scans (100,000 for metrics). Narrow the base query, set a time column + lookback so less data is scanned (the Time column field is on the form in both modes — a Catalog only scan can be windowed too), or raise the per-config row limit. Catalog-metric collection reports the same condition as "… reached the metric query row limit (100000) for chunk …" and fails the chunk on purpose: collection replaces a window by deleting it and re-inserting, so writing a capped result would erase the tail of the window rather than leave it as it was. Narrow the metric's breakdown or replay in shorter chunks.
- Misconfigured event typing. "Scan failed: This scan has no Event type and no Event type column, so it cannot name any events. Set one under the scan's Configuration tab." The config names neither, so it cannot name a single event. The scan form no longer lets one be created or saved — Event type and Event type column are asked for together in the always-visible block, and Create scan stays disabled until one of them is answered — so a config in this state was made through the API or predates that gate. Open it under Govern → Scans, answer the question, and save.
- Event name format references a column that is gone. "Scan failed: the
event name format references unknown keys:
action. Available keys: …" The scan's Event name format names a column the query no longer supplies — usually because the column's field was deleted from the event type, often by accepting amissing_fieldschema drift. Fix it by editing the Event name format so it only references columns the query returns, or by re-declaring the field on the event type. tripl now refuses the drift accept that causes this (see Schema drift).
Likely causes & fixes for connection failures.
| Adapter | Common cause | Fix |
|---|---|---|
| PostgreSQL | TLS negotiation or unreachable host. Non-local hosts default to sslmode=require, so a remote server with no TLS fails loudly rather than silently falling back to plaintext; localhost defaults to prefer. | Confirm host/port reachable from the worker container; check the server's TLS settings. A remote server that genuinely has no TLS needs sslmode set explicitly to prefer/disable on the data source. |
| ClickHouse | Wrong host/port/secure flag, or a probe query that returns no rows. | Verify connection params; "Connection probe returned no rows" means it connected but the probe was empty — check the query/permissions. |
| BigQuery | Missing project id or invalid service-account JSON. The probe names which of the two it is — see the verbatim messages below. | Set the project id in the host field and paste valid service-account JSON. |
A configuration problem tripl can name is shown in full. Those messages hold no host, port or credential, and generalizing them away would send you to re-check settings that are all correct, so they follow the prefix unchanged:
- "Connection test failed: BigQuery: host (project_id) is required"
- "Connection test failed: BigQuery: service-account JSON credentials are required"
- "Connection test failed: BigQuery: invalid service-account JSON: …" (with the position of the syntax error, never the contents of the key)
- "Connection test failed: PostgreSQL 13.23 is too old for tripl: every time-bucket query uses date_bin(), which was added in PostgreSQL 14. Upgrade the server to 14 or newer."
Everything else collapses to one of the four categories above, because the raw driver text carries host, port and credential detail.
Test connection persists last_test_status and the sanitized
last_test_message on the data source and invalidates the cached list, so the
result you see in the UI is the actual probe — not a stale value. The button and
the background probe write that field through the same rule, so the same failure
reads the same way whichever one ran.
The scan detail shows this curated error beside the failed run. Identical recent failures collapse into a failed last N runs streak; expand it when you need the individual attempts. After fixing the source/query/configuration, use Run again on the failed scan. This starts a new run and preserves the earlier failure history.
Scheduled collection is due whenever a new complete bucket exists, and a run that fails writes no bucket — so a broken config used to be retried on every dispatcher tick (every five minutes) no matter what its interval said. After three consecutive failures it now waits instead, roughly doubling the gap each time, never longer than 24 hours and never shorter than the config's own interval.
Two consequences worth knowing: a fixed config can take up to that wait before it retries by itself — press Run again if you don't want to wait — and the backoff only counts scheduled runs, so your manual runs neither trigger it nor clear it.
The same floor applies to two quieter versions of the loop. A run that succeeds but collects nothing — an empty warehouse window, or events that match nothing — writes no bucket either; it now counts as having covered that window, so the config is tried again at its own interval instead of every five minutes. And a catalog metric whose last collection errored waits one interval before the scheduler retries it (an hour for an event-composition metric, which has no interval of its own). Collect now on the metric ignores the wait.
That wait is measured from the failure itself, so editing the metric does not restart it — you can fix the SQL, save, and still be collected at the moment you would have been anyway. The metric's own page shows when: while it is waiting, Next collection is that moment rather than "due now", which is the same answer the scheduler is working from.
Scan tasks have a hard time limit of 60 minutes (the worker's default) and do
not retry automatically (max_retries=0). Metrics collection gets a much
longer hard limit (25 hours) so a long historical replay isn't killed mid-run —
but it also doesn't retry. A genuinely slow warehouse query is killed at the
limit rather than retried; shrink the window or row count instead of waiting it
out.
A branch merge is blocked
Symptom. Merging a plan branch returns an error instead of merging.
Likely causes (each maps to a specific API error):
| Error | Meaning | Fix |
|---|---|---|
400 Branch is already merged | The branch was merged previously. | Nothing to do; open a new branch for further changes. |
409 Branch must be approved before merging | The branch isn't in the approved state. | Get the required approvals first. |
409 incomplete_base_snapshot | The branch was created before complete merge baselines were available, so a safe three-way merge is impossible. | Recreate the branch from current main and reapply the intended changes. |
409 conflicts | Both sides changed the same state differently, or one side deleted a parent while the other added/changed a child. These are hard blockers unless listed as field-level resolutions. | Reconcile manually: recreate the branch from current main, or remove the conflicting change. |
409 unresolved_field_conflicts | An event-type field was changed on both branch and main relative to the base. | Resolve each field inline (choose ours/theirs) in the conflict view, then merge again. |
409 missing_owner_approvals | The branch touches an owned event type without that owner's approval. | Request approval from the listed owner(s) before merging. |
Fix. Field-level (modify/modify) conflicts on event types are resolvable through the inline resolution flow. Entity-level add/remove conflicts and conflicts on other entity kinds are not covered by inline resolution — rebase the branch onto current main and redo the change, or drop it.
Migration or startup failure
Symptom. The app (or migrate) container exits on boot, or the API
refuses to start.
Production startup checks failed
In non-debug (production) mode the API runs assert_production_ready() during
startup and refuses to boot with RuntimeError: Production startup checks failed: followed by a bulleted list. Each bullet is a missing/unsafe setting:
-
ENCRYPTION_KEYis empty or not a valid Fernet key. Data-source and alert secrets would be stored as plaintext. Generate one:python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' -
SECRET_KEYis empty. Session token hashes would be unkeyed. Generate:python -c 'import secrets; print(secrets.token_urlsafe(32))' -
SESSION_COOKIE_SECURE=falsein production. Set ittruewhen serving over HTTPS (the production compose stack already does). -
CORS resolves to empty or to the wildcard
*. SetCORS_ALLOW_ORIGINSorAPP_BASE_URLto your explicit frontend origin. A wildcard breaks cookie-based auth because browsers reject credentialed requests against*. -
A connection URL still uses the dev defaults (
tripl:triplorguest:guest). Set real credentials forDATABASE_URL,SYNC_DATABASE_URL, andRABBITMQ_URL.
The production compose file wires these from .env and will refuse to even
render if POSTGRES_PASSWORD, RABBITMQ_PASSWORD, ENCRYPTION_KEY,
SECRET_KEY, or APP_BASE_URL are unset. See
Configuration and Deployment.
Schema migration failed
In production, schema upgrades run once via the migrate one-shot
(alembic upgrade head) before the app and workers start — app,
celery-worker, and celery-beat all wait for
migrate: service_completed_successfully. If migrate fails, the app never
starts. Inspect it:
docker compose logs migrate
Common causes: the database isn't reachable yet (migrate waits for
postgres to be healthy), or a migration can't apply against the existing
schema. Fix the underlying DB/connection issue and re-run
docker compose up -d — the one-shot retries the upgrade.
Running multiple app/worker replicas never races the upgrade because the
one-shot migrate gate runs first. Don't add alembic upgrade to the app or
worker start command in production — that reintroduces the race the one-shot
exists to prevent.
For local development the api service runs alembic upgrade head itself before
starting uvicorn. If a script shebang is broken after a directory rename, call
the module directly: uv run python -m alembic upgrade head.
RabbitMQ or PostgreSQL connection errors
Symptom. The worker/beat logs show repeated broker connection errors, tasks never run, or the API logs database connection errors.
RabbitMQ (the Celery broker)
- The broker URL comes from
RABBITMQ_URL(e.g.amqp://tripl:<password>@rabbitmq:5672//). In production it must not use theguest:guestdev default — the startup check rejects it. - Celery is configured to retry the broker connection on startup
(
broker_connection_retry_on_startup = True), so a worker that boots before RabbitMQ is ready keeps trying rather than crashing. Persistent failures mean the broker is genuinely unreachable or the credentials are wrong. - In compose,
celery-workerandcelery-beatwait forrabbitmq: service_healthy(arabbitmq-diagnostics pinghealth check). If RabbitMQ never becomes healthy, those services won't start.
docker compose ps rabbitmq
docker compose logs --tail=80 rabbitmq
docker compose logs --tail=80 celery-worker | grep -i amqp
The broker's consumer_timeout in
infra/rabbitmq/rabbitmq.conf
is raised to 26 hours — above the collect_metrics hard time limit (25 hours) —
so a long metrics replay that holds its delivery unacked for the whole run isn't
force-requeued mid-run. If you replace that config, keep the consumer timeout
above the collect_metrics time limit or long replays will be redelivered and
run twice as duplicate, competing executions.
PostgreSQL (application database)
- The async API uses
DATABASE_URL(postgresql+asyncpg://...); Celery workers useSYNC_DATABASE_URL(postgresql+psycopg://...). Both must point at the same database with real credentials. A worker that can't reach Postgres can't claim jobs or write results, even if the API is fine. - The connection pools use pre-ping, so a connection dropped by the DB (restart, idle timeout) is detected and replaced transparently — you don't normally need to restart workers after a brief Postgres blip.
- In compose, services wait for
postgres: service_healthy(pg_isready -U tripl).
docker compose ps postgres
docker compose logs --tail=80 postgres
docker compose exec postgres pg_isready -U tripl
If the API starts but the worker errors, double-check that both URLs are
set and use the right driver prefix (+asyncpg for the API, +psycopg for the
worker).
Redis is optional. REDIS_URL being empty disables caching (every read falls
through to the database) but does not break anything — so Redis connection
problems degrade performance, they don't stop scans, metrics, or alerts.
FAQ
An old link or bookmark points at /p/<slug>/settings/scans — is it broken?
No. Scans moved to /p/<slug>/scans (Govern › Scans is a top-level surface, not
a settings tab). Both /p/<slug>/settings/scans and
/p/<slug>/settings/scans/<scan-id> redirect to the new paths, so bookmarks,
older docs, and the deep links in already-delivered alerts keep working.
How do I read a scan run? Open the scan, expand the run, and read What this run did — plain sentences about your data, not internal counters. It tells you how many warehouse rows the run read, which events it added to your plan, how many were already there (and that they were left alone, not lost), how many metric points it recorded, and whether anything it produced raised a signal or queued an alert. A catalog-only scan says outright that it collects no metric points, so nothing downstream can fire.
Every raw counter the run reported is still there under Show raw counters — Events created, Variables created, Events skipped, Columns analyzed, Event breakdowns, Distribution rows, Signals added, Alerts queued. Nothing was removed; it is one click further down.
The run says it raised 2 signals but Anomalies shows a different number. Which is wrong? Neither. Raised N anomaly signals is that run's delta — what this run added. The Anomalies page counts what is open now: signals from earlier runs that have not closed, and — unfiltered — signals from other scans and catalog-metric signals that belong to the project rather than to any scan. Two different questions, two legitimately different answers. Where the two disagree the run report prints the scan's current count under the sentence (5 signals from this scan are open now), so you are not left comparing a number here against a number on another page. The activity feed's "N new signals" on a scan card is the same delta.
Two runs both say "Rows read" but the numbers look unrelated. Because they count different populations. A catalog run reports the rows the catalog analyzer read, bounded by Row cap per run; a metrics run reports the rows read across every metrics chunk, bounded by Row cap per metrics run. The column header cannot say which, so hover the figure — the stat card and every cell in the run table carry a title naming the population and its cap.
Do I need to run scans on a schedule to get metrics?
Yes — the schedule is what makes a scan a monitoring scan. A scan with no
schedule is Catalog only: it fills your tracking plan when you run it and
records no metric points, so nothing downstream of them can fire. Metric points
come from collect_metrics, and the dispatcher only ever selects scans that set
both a schedule and a time column — the pair Catalog + monitoring asks
for. What you never have to do is trigger that collection: pick the mode, and
celery-beat dispatches it from then on. Starting a run by hand adds events and
fields to your plan and writes no metric point — and the one manual metrics path,
Run a one-off replay on the scan's Configuration tab, is itself disabled
until the scan has both.
Why is my brand-new scan not flagging any anomalies? Anomaly detection needs history. Until enough buckets accumulate, the detector uses a rolling fallback and skips low-volume series; with very little data it will correctly report nothing. Give it time, and check the project's anomaly settings (sigma threshold, minimum expected count, baseline window).
To look at just this scan instead of the whole project, open the scan, expand a
run, and click Signals added — it opens Anomalies filtered to that scan
(/p/<slug>/anomalies?scan=<scan-config-id>). On a busy project one large scan
can supply most of the page, so per-scan is often the only readable view. If the
counter reads 0 it is not a link: that run raised nothing, which is itself the
answer. If the link opens on No open anomalies from <scan>, the signals
that run raised have closed since — the scan stays selected so you can see that
is what happened, and Show all scans widens the view.
Also check the scan's mode. Catalog only scans record no metric points, so they raise no anomalies by design — the scan's own page says so in one line under its name, and its row carries a Catalog only badge. A scan badged Needs a time column has a schedule but no time column, so the scheduler never runs it and it collects no metric points — runs you start by hand still add events to your plan, which is why that scan can have green runs and no anomalies at the same time.
A scan failed with a generic "internal error" — where's the real reason?
User-facing fields are sanitized to avoid leaking host/port/driver details. The
full exception is in the worker logs:
docker compose logs celery-worker.
My alert never arrived but the UI shows an anomaly. What now?
Walk the delivery chain in Alerts never fire: destination
enabled? rule enabled and matching? cooldown/mute? (email) SMTP set? Then check
the worker logs for Failed to send alert delivery — the failure reason is
persisted on the delivery.
I acknowledged an incident and wrote down why, and it alerted again. Do I need to mute it? Probably not. Acknowledge really does stop deliveries, but only for as long as that incident lives — once the scope goes quiet the row reopens itself, so the next occurrence is a new incident and alerts. Mute is for "do not tell me before T" and is the only decision that survives the incident ending; false positive is for "the detector is wrong about this scope" and permanently tightens its thresholds. See An incident I acknowledged fired again.
The app won't start after I set DEBUG off.
That's the production readiness gate. Read the Production startup checks failed bullet list in the logs and set each missing secret/origin. See
Migration or startup failure.
Can I retry a failed scan automatically?
No. Scan, metrics, and connection-test tasks use max_retries=0 — a failure is
final for that run. Fix the underlying cause (connection, row limit, query) and
click Run again. Stranded alert deliveries are the exception: those are
re-enqueued automatically by the maintenance reaper.
Why did a deleted variable come back after the next scan? The scan rediscovered its warehouse column or JSON-path binding. Delete removes the plan row, while Exclude from scans keeps a tombstone that prevents recreation and stops new contexts/drift. Open Plan → Variables, exclude the variable, and use Restore if the decision changes later. Automatic retirement (below) behaves the same way — it deletes the row, it does not leave a tombstone — so a retired variable reappears if its source path starts arriving again. Exclusion is what makes a removal stick.
A scan-created variable vanished from the plan. Where did it go?
Every catalog run ends by retiring the scan-created variables nothing refers to
any more, so a catalog stops growing a permanent row per key of a JSON column
keyed by free text. A row is removed only when all of this holds: its
description is still the scan's own, its bindings are still only the source path
the scan gave it, it documents no values, and it has no per-event override, no
value drift, no observed context, and no stored event field or meta value naming
any of its tokens as ${token}. Anything you edited, documented, overrode,
triaged, or excluded is kept — and so is a variable that a single ${token}
still names, even with no observed contexts. The run's details list reports the
count, and Plan → Variables has an Unused filter that shows exactly what
a run would take. See
Variables & templates.
A variable's observed values read "No values stored". What is it telling me? That the variable has contexts — some event field does refer to it — and not one of them holds a value. It is a different state from the dash on neighbouring rows, which means no context exists at all. Open the event's value popover to see which contexts are empty and what each one binds to. An empty context is not by itself a fault: a binding pointed at a column that is genuinely empty has nothing to store. Note also that a variable with no stored values raises no value drift, so an empty drift count says nothing about whether the documented contract holds. See Variables & templates.
Why does a variable show value drift? The scan observed values outside the effective documented list (the event override when present, otherwise the global list). Review it from Variables or the event detail: accept globally, accept for that event, snooze, or mark false positive. See Variables & templates.
Why did a value drift I already accepted come back? Because the scan saw a value that was not in the set you accepted. An acceptance covers exactly the values it documented; anything newer reopens the row, and the row then lists only the new values. Use Show N resolved in either review panel to inspect or reopen a drift you resolved earlier.
Where do I configure SMTP, encryption keys, and connection URLs?
All via environment variables / .env. See Configuration
for the full list and Deployment for the compose stack.
The authoritative defaults live in
backend/src/tripl/config.py.
How do I run the database migration by hand?
In the running stack: docker compose run --rm migrate. Locally in the backend:
uv run alembic upgrade head (or uv run python -m alembic upgrade head if the
console script shebang is broken).
Still stuck?
Collect the relevant logs and open an issue on GitHub. Useful context to include:
docker compose ps
docker compose logs --tail=300 celery-worker
docker compose logs --tail=100 app
docker compose logs --tail=100 migrate
Redact any secrets before sharing. tripl already keeps host/port/credential detail out of user-facing fields, but raw logs may contain connection strings.