Nothing is showing up

Every failure on this page is one we have actually hit. They share a shape: nothing errors. The plugin reports no problem, the dashboard renders fine, and the data simply is not there. So the fastest route is not to read logs β€” it is to work down this list in order.


Start here: three checks, about a minute

1. Is the API healthy?

curl https://api.sentrinel.dev/health
{
  "status": "ok",
  "databaseHealthy": true,
  "schemaReady": true,
  "telemetryStore": "clickhouse",
  "telemetryHealthy": true,
  "ingest": { "policy": "auto", "using": "redpanda", "redpanda": "up", "direct": "up" }
}

Every boolean must be true, and ingest.using says which path is carrying telemetry right now. redpanda: "down" with using: "direct" means the broker is unreachable and batches are being written to the store directly β€” collecting, but without the durability buffer. direct: "down" with using: "redpanda" means the store is unreachable and batches are queueing in the log until it returns. What each boolean failing means:

Field If false
databaseHealthy The API cannot reach Postgres. Nobody can sign in. Check DATABASE_URL.
schemaReady Migrations did not apply. Every authenticated route will 500. Check the API logs at boot.
telemetryHealthy The telemetry store is unusable. Metrics may still write, but reads 500. Usually credentials, not the network.

status: "degraded" means one of them is false. The HTTP code stays 200 when only telemetry is down β€” restarting the API cannot fix ClickHouse, and failing the health check would take auth down with it.

2. Is your serverUrl the API, and not the dashboard?

serverUrl: "https://api.sentrinel.dev"   // βœ… the API
serverUrl: "https://app.sentrinel.dev"   // ❌ the dashboard

The dashboard is static files on a CDN. It has no ingest endpoint, so telemetry sent there disappears into a 404 that the plugin does not treat as a misconfiguration. This is the single most common cause of "I set it up and nothing happened."

3. Is request logging turned on?

sentrinelPlugin({
  // …
  requestLogging: { enabled: true },
})

It is off by default. With it off you still get charts, endpoint tables and error rates β€” those come from in-process counters β€” but Request logs, request detail, payloads and the trace links are all permanently empty. The result looks like a half-broken product rather than an unset option.


Symptom: charts have data, Request logs is empty

Request logging is off. See check 3 above. Aggregated metrics and individual request rows travel on different paths β€” counters are computed in your process and flushed as rollups; rows are only sent when requestLogging.enabled is true.

If it is on, check sampleRate. At 0.1 you keep one in ten successful requests. Errors and slow requests are never sampled out, so a page showing only failures is the expected look of a low sample rate, not a bug.


Symptom: nothing at all, and the plugin is silent

Work through these in order.

The key is the wrong kind for that surface. A 403 whose message starts This is a "…" key; it cannot send to … means a key issued for one integration was used by another β€” a mobile key posting a replay, a collector key posting requests, an AI agent key trying to ingest at all. Issue the kind the message asks for; keys are deliberately bound to one integration so a leak exposes only that one. Keys issued before kinds existed are server keys and are unaffected.

The key does not match the app. Ingest requires that the API key, appName and env all belong to the same app. A mismatch is a 403, which the plugin reports once β€” check your process output for a Sentrinel warning at startup.

appName: "checkout-api"   // must equal the app's name exactly
env: "prod"               // must equal the key's environment

Renaming an app in the dashboard without updating appName breaks ingest silently from the next flush onward.

You are using the row id instead of the key. POST /api/apikeys returns both, and they are easy to swap:

{ "key": "…row uuid…", "secretKey": "snt_live_…" }

secretKey is the credential. key is the database row id and will be rejected as an invalid API key.

The process exited before a flush. The default flushInterval is 30s. A short-lived script or a container that stops immediately may never flush. Call await flush() before exit, or lower the interval.


Symptom: logs appear, but they are not attached to a request

The link is carried by a field whose name differs between record types, and using the wrong one fails silently β€” the record still inserts and still shows up in its list. Only the link is missing.

Record Field
Log requestId
Error requestLogId

Both SDKs send the right one. If you are posting to the ingest API yourself, this is worth double-checking. The API accepts either spelling on both endpoints for exactly this reason, but the table above is the documented shape.


Symptom: a trace opens but has no spans

Each span carries its own id, and the field is id β€” not spanId:

{ "spans": [ { "id": "0011223344556677", "name": "db.query" } ] }

A span without id is rejected with a 400 naming the field. Spans come from traceSpan(); a request with no instrumented work inside it produces a trace with only its root span, which is normal.


Symptom: signing in fails, or the dashboard says the API returned an error

The sign-in page distinguishes two cases, and the wording tells you which:


Symptom: everything was working, then stopped after a deploy

Check that the running container actually picked up its configuration. A stored config that was corrected but never redeployed leaves the old value running:

docker service inspect sentrinel-api \
  --format '{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' \
  | grep -c DATABASE_URL

Compare what the platform stores against what the container has. They can disagree, and the container is what runs.


Mobile: crashes are not arriving

No storagePath. A crash kills the process long before the 30-second flush, so the report only survives if it was written to disk first. Without a path the core falls back to the system temp directory, which the OS may clear before the next launch:

Sentrinel.init(storagePath: (await getApplicationSupportDirectory()).path);

SentrinelFlutter.run() does this for you.

Only guard(), without the Flutter handlers. Flutter catches build, layout and paint errors itself; they never reach a zone. guard() alone therefore misses the majority of real Flutter crashes while looking correctly wired up:

FlutterError.onError = (d) => Sentrinel.flutterErrorHandler(d.exception, d.stack);
PlatformDispatcher.instance.onError = (e, s) {
  Sentrinel.platformErrorHandler(e, s);
  return true;
};

A native crash. A segfault in an iOS or Android library kills the process below Dart and leaves nothing for the SDK to report. Not covered β€” see the limitations in the SDK README.

Reports arrive one launch late. That is by design: the crash is written to disk, and delivered when the app next starts.


Mobile: Release health is empty

No release. Crash-free rate is per release; without one every build is unknown and a regression is invisible:

Sentrinel.init(release: '1.4.2');

No sessions. The page needs one session per launch, which the SDK sends automatically β€” unless persistCrashes: false, which turns off the session marker along with the spool.

Everything shows as abnormal. The app is not shutting down cleanly, so the marker survives every launch. Call Sentrinel.close() on termination, or accept that force-quits are the norm on mobile and read the crashed column instead β€” that one only counts sessions where a crash report was actually written.


Browser: nothing from the web app

Work down in order β€” each is more common than the one below it.

1. Is the tunnel route actually mounted?

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/api/sentrinel \
  -H 'Content-Type: application/json' -d '{"errors":[]}'

202 is right. 404 means the route is not where the SDK is posting β€” check endpoint against the path you mounted. 405 means it is mounted but only answers a different method; the tunnel needs POST.

If you are on TanStack Router or a framework with file-based routes, check the filename: a leading underscore (_sentrinel.ts) makes it a pathless layout route, and the endpoint silently does not exist.

2. Is the SDK running at all?

Turn on debug: true and reload. You should see [sentrinel] sent … within flushInterval. Nothing at all usually means initSentrinelBrowser() is being called in a module that only runs on the server β€” it returns a no-op there, deliberately, and says nothing about it.

3. Is the key on the server rejecting the batch?

The page always sees 202, by design, so a bad key is invisible from the browser. Look at your server log for [sentrinel:tunnel] … rejected (403). That means apiKey, appName and env in createSentrinelTunnel disagree β€” the key must belong to that app and that environment.

4. Is your error on the ignore list?

Script error., ResizeObserver loop… and anything from a browser extension are dropped by default, along with whatever you put in ignoreErrors. Test with something unmistakable:

sentrinel.captureError(new Error('sentrinel wiring check'));
await sentrinel.flush();

5. Requests are missing, but errors arrive.

Only fetch is patched. A library built on XMLHttpRequest (older axios builds) is not recorded. Check what your HTTP client actually uses.


Browser: the trace stops at the browser

Frontend and backend records exist, but the waterfall shows only one side.


Symptom: ingest started returning 402 Payment Required

The credential is fine and the request is fine β€” the account owes money. This is not a 403, deliberately: a 403 would send you hunting for a bad key.

The body says which of the two cases it is and when data stopped:

{
  "error": "Sentrinel trial ended on 2026-08-18. Telemetry kept being accepted for 7 days after that, and stopped on 2026-08-25. Choose a plan to resume ingest β€” your existing data is retained.",
  "reason": "trial_expired"
}

reason is trial_expired or payment_required. Either way:

Going over the monthly quota is a different response β€” 429, not 402 β€” and resolves itself when the quota resets.

Symptom: request rows have no country (or no client IP)

Both are read from headers set by whatever terminated TLS in front of you.

Symptom: session replay records nothing

Work through it in this order β€” the first two are almost always the answer.

  1. TELEMETRY_STORE is not clickhouse. Ingest answers 501 with an explicit message. Recordings are far too large for the Postgres store, so they are refused rather than silently discarded.
  2. rrweb is not installed. The recorder is imported dynamically, so a missing dependency is not a build error β€” it is a warning at runtime and no recording. bun add rrweb, and set debug: true to see it.
  3. Nothing has failed yet. By default replay uploads only when an error fires. A healthy session sends nothing, by design. To confirm the pipeline, raise sessionSampleRate temporarily or throw something.
  4. The error happened during unload. The upload needs the page alive; sendBeacon cannot carry a multi-megabyte recording. A failure in the last moment before navigation may not survive.

Symptom: the replay plays back blank, or everything is asterisks

Asterisks are correct. Text and input values are masked by default β€” the recording shows layout, clicks and navigation, not content. Opt individual elements in with data-sentrinel-unmask.

A genuinely blank stage with a "did not carry a viewport size" note means the recording was taken somewhere that reported window.innerWidth === 0 β€” a headless browser, or an offscreen tab. The events are fine; the player substitutes its own size and says so.

Database: the collector will not start, and names pg_hba.conf

[sentrinel-pg] cannot start: no pg_hba.conf entry for host "10.0.0.4",
user "orders", database "orders", no encryption

Read to the end of that line. no encryption means the server requires TLS and the connection was not encrypted β€” the file it names is fine, and editing it is not the fix. Current collectors negotiate TLS automatically; upgrade, or be explicit:

sentrinel-collector config set DATABASE_URL='postgres://user:pw@host/db?sslmode=require'
sentrinel-collector restart

With SSL encryption instead, TLS worked and the server genuinely has no rule matching. Before widening pg_hba, compare the host in the message with the host in your DATABASE_URL:

no pg_hba.conf entry for host "147.78.130.119", …
DATABASE_URL=postgres://…@147.78.130.119:5432/lmsdb

The same address twice means the collector is on the database's own machine and reaching it through its public address, so the connection leaves the box and comes back β€” and Postgres judges it as a remote client.

Current collectors detect this and retry over loopback by themselves, so the fix is to update:

sudo sentrinel-collector update

If Postgres is not listening on loopback (SHOW listen_addresses; β€” it needs localhost, or *), or the collector really is on another machine, then add the rule on the database server and reload it:

hostssl  <database>  <user>  <collector-host>/32  scram-sha-256
SELECT pg_reload_conf();

Still stuck?

Turn on plugin logging and watch one flush:

sentrinelPlugin({ /* … */ debug: true, flushInterval: 2000 })

The plugin prints what it sends and, on a rejected batch, the server's own explanation. A batch rejected with 401 or 403 is always a key, appName or env mismatch β€” never a network problem.