Skip to content

Self-hosting

Health checks, monitoring and logs

Health endpoints for each service, what to alert on, and where logs go.

Each service answers a health endpoint, logs to standard output, and keeps nothing on local disk. This page lists what to probe, what to alert on and where to look when something breaks.

Health endpoints

ServiceEndpointHealthy response
webGET /api/health on port 3000200 {"ok":true}
sandboxGET /health on port 4001200 {"ok":true}
realtimeGET /health on port 4010200 {"ok":true,"connections":<open sockets>}

The Helm chart wires these as liveness and readiness probes, and the web image has a matching Docker HEALTHCHECK. They report that the process is up and serving; they do not check Postgres, Redis or storage. For an end-to-end check, open a share link: that exercises web, Postgres, the sandbox, storage and your wildcard certificate in one request.

/api/health is public on the app host by design. realtime's /health is public too and says nothing beyond that the process is up and how many sockets it holds. The operator view (room sizes, whether redis is connected, and the follow-mode stream counters) is GET /internal/stats: it answers only a request that carries the header x-critlab-internal: <secret>, where the secret is INTERNAL_LOOKUP_SECRET, else DOMAIN_LOOKUP_SECRET, else AUTH_SECRET, the same order web and the sandbox use. With no secret configured, or a wrong or missing header, it answers the same 404 as any unknown path. The Helm chart passes INTERNAL_LOOKUP_SECRET to realtime; outside the chart, set one of the three on the realtime container. Room names are prototype identifiers, so keep both paths on the internal network: the chart exposes only /ws on the app host.

Error reporting

Error reporting is off unless you set SENTRY_DSN. Unset (the default), nothing initialises and no request leaves any pod. Set it to the DSN of a Sentry project, or of a self-hosted GlitchTip (same DSN format, no other change), and web, sandbox and realtime report unhandled errors there. In the Helm chart it is config.sentryDsn (and config.sentryEnvironment); with networkPolicy.enabled, also open egress to the DSN's host for the sandbox and realtime pods (networkPolicy.sandbox.extraEgress, networkPolicy.realtime.extraEgress).

A report holds an exception type, stack frames and the route template (/p/[slug], never a URL). It never holds the error message, comment text, URLs with ids or tokens, cookies, request bodies, headers, breadcrumbs, screenshots, IP addresses or an email. Browser errors are sent to web's own /api/diagnostics route, which forwards only to your DSN, so no page's Content Security Policy names an outside host. There is no session replay and no performance tracing.

Product analytics

Product analytics are off on a self-hosted install (BILLING_PROVIDER unset or none) and on for critlab cloud. Set ANALYTICS=on (Helm: config.analytics: "on") to record them in your own Postgres, ANALYTICS=off to force them off. It is read for each request, so a restart applies it; there is no per-user opt-out.

Nothing leaves your database. When on, web records ten server-side events (a sign-up, a workspace created, an agent's first request, a version published, a thread opened or resolved, a thread bundle read through an API key, an invitation sent, a beta limit reached) as one row each in product_events: the event name, the time, the workspace id and either a member's opaque user id or an agent's API key id. It never records a reviewer (a link or password visitor), an IP address, a user agent, a comment, a URL or any free text, and it sets no cookie. The overlay, the staging snippet and the sandbox record nothing. Deleting an account clears the user id on that person's events, and deleting a workspace clears the workspace id; the rows stay as counts with nothing to link them to anyone. The data retention job keeps raw rows 13 months and then folds them into daily totals (product_event_daily: day, event, member or agent, nothing else).

After switching it on for an install that already has accounts, run the backfill once, from the repository's apps/web directory with DATABASE_URL pointing at your database: pnpm analytics:backfill is a dry run that prints what it would add, and pnpm analytics:backfill --confirm writes. It derives the sign-up, workspace, first-agent-request and first-publish events from rows that already exist, adds nothing twice, and refuses the local development database unless you pass --allow-dev-database.

Scheduled jobs

web runs its own maintenance jobs. You do not schedule anything: every web process starts a timer for each job when it boots, the Helm chart needs no CronJob and Docker Compose needs no sidecar.

JobHow oftenWhat it does
Data retentionhourlyDeletes what the Privacy Policy says is not kept: audit events older than 13 months (change with RETENTION_AUDIT_MONTHS; 0 keeps them forever), sessions 30 days after they expire, expired sign-in codes, cached link titles older than 7 days (6 hours for a failed fetch), and the read marks a visitor without an account left on a thread once nothing has touched them for 180 days (a visitor who keeps reading keeps theirs). Two housekeeping rules are not in the policy's table: a custom share domain that was claimed but never passed DNS verification is removed 30 days after its last check (its creation, if it was never checked), and a row of the deletion ledger (ids and timestamps of what was deleted, kept for restores) is removed 35 days after the delete. A product analytics event older than 13 months is not deleted outright: it is counted into the daily totals first and then removed, so the totals keep their history while the rows that name a user, a key and a workspace go.
Notification digesthourlySends the digest emails people who chose "digest" get instead of one email per event. Without it only instant notification emails go out.
Asset sweephourlyDeletes the files no version references any more. Every delete already starts one sweep; this one drains whatever that left queued.
Review batch pollevery minuteCollects the results of an AI review batch. It does nothing, and takes no lock, unless a batch is outstanding, which cannot happen while AI review is off.
Canvas backlogevery minuteStarts the next canvas build that is owed: one a full queue could not hold, or whose process stopped mid-build (a build untouched for 6 minutes is put back in the queue, and failed after three such attempts). It does nothing, and takes no lock, unless a build is owed, and nothing at all with CANVAS_CRAWL=off. Builds also start by themselves: each one that ends claims the next.
Custom domain re-checkhourlyRe-reads the DNS that proves a custom share domain is still the workspace's: the TXT ownership token and the traffic record of every active domain whose last check is 6 hours old (20 per run, 4 at a time, 2 minutes at most; the next run takes what is left). It uses DNS only and never calls the TLS provider. A DNS answer that the record is gone or someone else's drops the domain to failed, so share links, routing and the frame-ancestors list stop trusting it, and writes one audit row in the workspace's log. DNS not answering (a timeout, SERVFAIL) changes nothing except the time of the attempt. It does nothing, and takes no lock, unless a domain is due.
Deletion ledger exporthourlyCopies the new rows of the deletion ledger to a bucket of its own (not the one your database backups go to) as JSON Lines, so that after a restore of Postgres to an earlier point the deletions made since can be applied again. It does nothing, and takes no lock, unless a DELETION_LEDGER_EXPORT_* variable is set; with only some of them set it logs which are missing.
Custom hostname releasehourlyRetries releasing the custom share hostnames of a deleted workspace at the TLS provider, when the release right after the deletion did not finish. Each hostname waits longer between tries, from an hour to a day, and after eight tries the job logs what to delete by hand at the provider. It does nothing, and takes no lock, unless a release is due.
Legal notice deliveryevery minuteSends critlab's own legal notices (a subprocessor change notice to workspace owners), queued when critlab's operators send one, one email per person, retried three times with a growing wait before it is marked failed. On a self-hosted install it does nothing at all: it runs only where critlab's own legal documents are shown, and takes no lock unless a notice is queued.

How it behaves:

  • Never on boot. The first run of each job is 3 to 8 minutes after the process starts, chosen separately per job, so a rollout does not make every replica hit Postgres, the mailer and storage at once. Each later run is the interval give or take 10%.
  • One runner at a time, across replicas. Before a run, a process takes a Postgres advisory lock for that job (pg_try_advisory_xact_lock, held in a short-lived transaction on a connection of its own). If another replica holds it, the run is skipped and tried again at the next tick. Within one process a job never overlaps itself.
  • Bounded and safe to repeat. The retention sweep deletes in small batches and skips rows another request is using; a second run finds nothing left. If a run stops on its cap with more due (capped in its log line), the next one comes after about five minutes rather than a full hour, so a first run over a big backlog finishes in an afternoon. To see what a first run would remove from an existing database, start web with RETENTION_DRY_RUN=1: the log says how many rows each rule would delete and none are deleted. RETENTION_DISABLED=1 turns retention off while the other jobs keep running.
  • Failures are log lines. A job that fails logs [scheduler] job failed with the error's class and code and never its message or a row, is reported to error reporting when SENTRY_DSN is set, and tries again at its next tick. A finished run logs [scheduler] job finished with counts only.
  • Connections. While a job runs, a web process holds a few extra Postgres connections for seconds: one per running job for its lock, plus the job's own queries, each closed after 30 seconds idle. Count that in when you size max_connections for many replicas.
  • Postgres setting to know. The lock lives as long as its transaction, and the transaction sits open while the job runs (seconds, at most a few minutes). If you set idle_in_transaction_session_timeout on Postgres, keep it above five minutes: a shorter one ends the transaction early, the lock goes, and a second replica may repeat the same job. Every job is safe to repeat, so the cost is a repeated sweep and never lost data.

Turning it off, or using your own cron

Set SCHEDULER=off (Helm: config.scheduler: "off") and web starts nothing. Do this if you would rather drive the jobs from your own cron, or to pause them. The same jobs are available as routes, which keep working whether the scheduler is on or off:

RouteJob
POST /api/internal/retentionData retention
POST /api/internal/notifications/digestNotification digest
POST /api/internal/assets/gcAsset sweep
POST /api/internal/review-batches/pollReview batch poll

Five jobs have no route and run only while the scheduler is on: the canvas backlog, the custom domain re-check, the deletion ledger export, the custom hostname release and the legal notice delivery. With SCHEDULER=off, custom domains are not re-checked, the ledger is not exported and a deleted workspace's hostnames are released only by the deletion itself.

Call each hourly (the poll every minute), from inside the deployment network, with the header x-critlab-internal: <INTERNAL_LOOKUP_SECRET> (the INTERNAL_LOOKUP_SECRET in your Secret). A wrong or missing secret gets an identical 404; never expose these routes to the internet. A Kubernetes CronJob is enough. Adapt the Service and Secret names to your release:

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: critlab-retention
spec:
  schedule: "17 * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: call
              image: curlimages/curl:8.10.1
              env:
                - name: INTERNAL_LOOKUP_SECRET
                  valueFrom:
                    secretKeyRef: { name: <your-secret>, key: INTERNAL_LOOKUP_SECRET }
              command:
                - sh
                - -c
                - >-
                  curl -fsS -X POST -H "x-critlab-internal: $INTERNAL_LOOKUP_SECRET"
                  http://<web-service>:3000/api/internal/retention

Leaving the scheduler on and also calling the routes is harmless, just redundant: every job is safe to repeat and to overlap.

What to alert on

SignalWhy
The migrate Job failing during an upgradeThe upgrade stops and the old version keeps running until you fix it.
5xx rate on the app hostMost failures surface here first.
Sandbox 404s rising sharplyUsually a certificate, DNS or SANDBOX_* mismatch between web and sandbox.
Wildcard certificate expiry for the sandbox zoneWhen it lapses, every prototype stops loading while the app still works.
Postgres connectionsEach web and sandbox process opens a pool of up to DATABASE_POOL_MAX (10) connections; replicas multiply it.
Disk usage of Postgres and object storage volumesThe chart's bundled defaults are 10Gi and 20Gi.
Mail send errors in web's logNobody can sign in with an email code if mail is down.
[scheduler] job failed in web's logA maintenance job, retention above all, did not run. The Privacy Policy's retention periods are only kept while it does.
Licence expiryA banner shows in the grace period; the platform admin sees the state in Settings → License. GET /api/license needs that admin's browser session, so it is not an automation endpoint.

Logs

All three services write plain-text logs to stdout and stderr; collect them with your cluster's log pipeline (kubectl logs deploy/critlab-web, deploy/critlab-sandbox, deploy/critlab-realtime). Lines are prefixed by origin, for example [@critlab/web], [@critlab/sandbox] or [critlab:mail].

Worth knowing:

  • With the console mail transport, sign-in codes and invitation links are printed to web's log. Restrict log access, or configure SMTP.
  • If writing an audit event fails, web logs the whole event with console.error rather than failing the user's action. Alert on those lines if the audit log matters to you.
  • The chart runs the containers with a read-only root filesystem. The one file the app may write is the licence at LICENSE_KEY_FILE, and only when that path is a writable mount.

The audit log itself lives in Postgres, not in log files; see Audit log.