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:
- "No response from β¦" β nothing came back. DNS, TLS, or the service being
down. Note that a 502 from a proxy carries no CORS headers, so the browser
reports a CORS error for what is really a dead API β check
/healthbefore touchingSENTRINEL_ALLOWED_ORIGINS. If the API is up and only the dashboard fails, then it is the origin list: it governs the session cookie, and an unlisted origin can still call the API but cannot stay signed in. - "β¦ is up but returned an error" β the API answered with a 5xx. This is the
API or its database, not your credentials. Check
/healthfirst.
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.
- Cross-origin API.
tracePropagationTargetsdefaults to same-origin only. An API on another host gets notraceparentuntil you add it:tracePropagationTargets: [/^\//, 'https://api.myapp.com']. - CORS. Adding
traceparentmakes the request non-simple, so the browser sends a preflight. If the server does not listtraceparentinAccess-Control-Allow-Headers, the call fails outright. - The backend is not instrumented. Propagation only joins traces if something on the other end continues them.
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:
- Nothing already collected has been deleted. Only new writes are refused.
- The dashboard blocked earlier than ingest did. Telemetry keeps arriving
for
SENTRINEL_INGEST_GRACE_DAYS(default 7) after the block, so that paying restores an unbroken history instead of one with a hole in it. A402means that window has now closed too. - Self-hosted? Set
SENTRINEL_TRIAL_DAYS=0. There is nobody to bill, and a trial clock on your own deployment will eventually lock you out of it.
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.
- No country means no edge, or an edge that does not send one. Cloudflare
(
CF-IPCountry), Vercel, Fastly and CloudFront all do; a bare VPS does not. Sentrinel does not ship a GeoIP database β that would be a licensing question and tens of megabytes in every one of your processes β so with no header there is no country. The field stays empty rather than guessing. - Client IP showing your load balancer means the proxy is not forwarding the
original address. Set
X-Forwarded-FororX-Real-IPon it. - Cloudflare sending
XXorT1is Cloudflare saying "unknown" and "Tor" respectively. Both are discarded rather than stored as a country.
Symptom: session replay records nothing
Work through it in this order β the first two are almost always the answer.
TELEMETRY_STOREis notclickhouse. Ingest answers501with an explicit message. Recordings are far too large for the Postgres store, so they are refused rather than silently discarded.rrwebis 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 setdebug: trueto see it.- 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
sessionSampleRatetemporarily or throw something. - The error happened during unload. The upload needs the page alive;
sendBeaconcannot 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.