epok

Detection tuning

Updated Jul 28, 2026 · 1d ago

Start here if detection looks dead on your logs. Epok reads severity from a field named level, severity, log_level, loglevel, or lvl, and treats the values error, ERROR, err, fatal, and FATAL as errors. That covers most shippers. It does not cover everything.

If your logs put severity somewhere else — @level, syslog_severity, priority, a numeric code, a nested object — or use values we do not map (SEVERE, E, 3), then nothing matches. Your logs arrive fine. Search works. But every error-shaped detector sees a stream with no errors in it, and the product looks like it does not work on your log shape.

It is not broken and it is not a support ticket. It is a level rule, which takes about a minute, and it is the single most common reason a trial looks quiet in hour one. This page covers that and the three other surfaces that reshape what the detectors see — plus, at the end, the exact blast radius of every reset path, because two of them are irreversible.

1. Level rules — teach Epok where your severity lives

A level rule rewrites a log line's level when the line matches a condition. Rules are applied at ingest, before storage, so a corrected level is what search, detectors, and alerts all see afterwards.

Two consequences worth knowing before you start. Rules are not retroactive — logs already stored keep the level they arrived with, so expect a seam in your history at the moment you add a rule. And the ingest path caches rules for up to 60 seconds, so a new rule takes effect within about a minute rather than instantly.

The rule shape

FieldAccepts
service_filter* (all services, the default) or one exact service name. Max 256 chars. There is no partial or wildcard service match.
match_field_msg (the message body, the default) or the name of a top-level structured field. Validated as ^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$. If the field is absent on a line, the rule simply misses that line.
match_typecontains (the default), regex, starts_with, ends_with, exact. The four literal types are case-insensitive. regex is not — write (?i) at the front of the pattern if you need it to be. A regex value is compiled at save time — a bad pattern is rejected with a 400 rather than failing silently later.
match_valueRequired. Max 1000 chars.
target_levelExactly one of debug, info, warn, error, critical. Anything else is a 400 — there are no other level values.
priorityInteger, higher first. Rules evaluate in priority order and the first match wins; ties break by rule id.
enabledBoolean. Settable on update only — new rules are created enabled. Disabled rules are skipped at ingest and by the test endpoint.

Limit: 50 rules per account. Creating, updating, and deleting rules requires owner or admin (or an API key with write scope); listing and testing do not.

Dry-run against a real log line first

POST /level-rules/test evaluates your rules against a sample line and changes nothing. Paste a line you actually have — copied out of the log explorer — and it tells you which rule would win and what level the line would end up with. This is the difference between believing a rule works and knowing it does.

The one thing to understand: the test runs your saved, enabled rules. It does not accept a rule body inline, so the workflow is create → test → adjust, not test → create. That is safe because rules only affect logs that arrive after them: a wrong rule mislabels nothing you already have, and you can fix or delete it before it costs you much.

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/level-rules/test \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "message": "{\"severity\":\"ERROR\",\"msg\":\"payment declined\"}",
  "service": "checkout",
  "current_level": "info"
}'

If you pass message as a JSON object and omit fields, its top-level keys become the fields the rule matches against — the same thing ingest does with structured logs. Pass an explicit fields object when your line is not JSON but your shipper adds structure. The response:

json
{
  "matched_rule": {
    "id": 12,
    "service_filter": "*",
    "match_type": "exact",
    "match_field": "severity",
    "match_value": "ERROR",
    "target_level": "error",
    "priority": 100,
    "enabled": true
  },
  "result_level": "error",
  "original_level": "info",
  "all_matches": [
    {"id": 12, "service_filter": "*", "match_type": "exact", "match_field": "severity",
     "match_value": "ERROR", "target_level": "error", "priority": 100, "enabled": true}
  ],
  "message_preview": "{\"severity\":\"ERROR\",\"msg\":\"payment declined\"}"
}

matched_rule is null when nothing matched, and result_level then equals the current_level you sent — that is the signal your rule is not doing what you think. all_matches lists every rule that matched, which is how you catch a high-priority rule shadowing the one you just wrote. The message body is capped at 64 KB before matching.

One place the test is more forgiving than ingest. The test endpoint runs regex rules case-insensitively; the ingest path does not. So a pattern like fatal|panic will report a clean match here and then quietly miss FATAL on real traffic. If your rule is a regex and you care about case, write it as (?i)fatal|panic — then the test result and the live behaviour agree. The four literal match types behave identically in both places.

Creating the rule

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/level-rules \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "service_filter": "*",
  "match_type": "exact",
  "match_field": "severity",
  "match_value": "ERROR",
  "target_level": "error",
  "priority": 100
}'

# Every field is optional except match_value and target_level.
# Defaults: service_filter "*", match_type "contains", match_field "_msg", priority 0.

Update with PUT /level-rules/{rule_id} (partial — omitted fields keep their current values) and remove with DELETE /level-rules/{rule_id}. The same rules are editable in the app under Settings → Log Processing, test box included.

2. Field mappings — run auto-detect first

Field mappings tell Epok which of your fields carry the four dimensions it reasons about: user_fields, endpoint_fields, service_fields, status_fields. Getting these right is what turns "errors went up" into "errors went up, on one endpoint, for 3% of users".

Do not hand-write these first. Auto-detect reads the field names actually present in your last 24 hours of logs and proposes a mapping from them, which means it can only ever suggest fields you really have — the most common hand-written mistake is mapping a field name you think you send and do not.

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/field-mappings/auto-detect \
  -H 'X-API-Key: YOUR_API_KEY'

It matches field names against keyword sets — user, customer, account, email for users; path, route, url, uri for endpoints; service, app, pod, host for services; status, code, response_code for status — either exactly or as a ./_ suffix, so http.user_id matches user_id. Fields starting with _ are skipped, and each field lands in at most one bucket (user, then endpoint, then service, then status — first bucket wins). The result is saved, not just returned:

json
{
  "user_fields":     ["user_id", "customer_id"],
  "endpoint_fields": ["http.route"],
  "service_fields":  ["service", "pod"],
  "status_fields":   ["status_code"],
  "auto_detected": true
}

Then correct what it missed. A PUT is a partial merge — send only the lists you are changing — and it marks the mapping auto_detected: false. Re-running auto-detect afterwards overwrites your edits, so treat auto-detect as the starting point, not a repair tool.

bash
curl -X PUT https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/field-mappings \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{ "user_fields": ["account_ref"] }'
# Partial merge: the three keys you omit keep their current values.
# Any PUT flips auto_detected to false — a later auto-detect run overwrites it.

In the app: Settings → Detection. Read access needs no special role; changing mappings requires owner or admin.

3. Detection settings

Per-account thresholds for volume-shaped detection and for how alerts group into incidents. GET returns both your effective settings (defaults filled in for anything you have not set) and the schema — the min, max, and default for every key, which is the authoritative version of the table below.

bash
curl https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/detection-settings \
  -H 'X-API-Key: YOUR_API_KEY'
# → {"settings": { ...every key with your value or the default... },
#    "schema":   { ...min / max / default / options / label per key... }}
KeyTypeRangeDefault
flatline_stale_stream_hoursinteger1 – 16824
flatline_minutesinteger1 – 305
flatline_min_baselinenumber1 – 100010
spike_z_thresholdnumber1.5 – 10.04.0
drop_z_thresholdnumber-10.0 to -1.5-4.0
spike_percentile_factornumber1.1 – 5.01.5
drop_percentile_factornumber0.05 – 0.90.2
learning_period_daysinteger1 – 303
baseline_stale_daysinteger7 – 9014
excluded_streamslist of strings[]

The z-score thresholds are the two you are most likely to want. Raising spike_z_threshold makes volume spikes harder to trigger; drop_z_threshold is the mirror image and is negative, so -5.0 is less sensitive than -4.0.

excluded_streams takes a chatty stream out of volume detection — spikes, drops, and flatlines. It is narrower than it sounds in two ways worth knowing before you rely on it. It does not silence the other detectors on that stream, so a new error there still alerts. And the match is exact, against the whole stream identifier — a partial or approximate value matches nothing and fails silently, with no error to tell you. Copy the stream string from the app rather than typing it. If your goal is "no pages from this thing at all", a mute schedule is the tool, not this setting.

Incident grouping

KeyTypeRangeDefault
incident_max_age_hoursinteger1 – 242
incident_max_alertsinteger10 – 1000100
incident_auto_close_hoursinteger1 – 244
incident_cross_service"off" | "same_deploy""off"
incident_reopen_window_minutesinteger10 – 1440120

Past incident_max_age_hours or incident_max_alerts, a new alert opens a fresh incident instead of joining the existing one. incident_cross_service defaults to off (each service gets its own incident); same_deploy merges alerts across services when the same deployment triggered them. incident_reopen_window_minutes reopens a recently-resolved incident rather than creating a duplicate.

One honest caveat: incident_auto_close_hours is accepted and stored, but stale incidents currently close on the global alert auto-resolve timer rather than your per-account value. Set it if you like; do not expect it to change behaviour yet.

Updating

PUT is a partial update merged over what you already have. Unknown keys are silently ignored — a typo in a key name returns 200 and does nothing, so check the response, which echoes the full effective settings. Out-of-range values, booleans where a number is expected, and non-string list items are rejected with a 400. If nothing in your body is a valid key, you get a 400 rather than a no-op.

bash
curl -X PUT https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/detection-settings \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "spike_z_threshold": 5.0,
  "excluded_streams": ["{service=\"noisy-cron\"}"]
}'

In the app: Settings → Detection. Changes require owner or admin.

4. The reset paths and their blast radius

Four endpoints delete things. Two of them cannot be undone. They differ far more than their names suggest, so here is exactly what each one touches.

All four require a signed-in session — owner for the two resets, owner or admin for the two log-deletion calls. An API key is rejected with a 403 no matter what scopes it has, which is deliberate: nothing automated should be able to wipe your account. The curl examples below therefore use a session cookie, not X-API-Key.

reset-intelligence — clears what Epok learned. Keeps your logs.

POST /api/v1/tenants/{id}/reset-intelligence. Owner only. This is the one you want after a migration, a bad test load, or any period that taught the detectors a "normal" that was never real.

Deletes: alerts, incidents, log-rate baselines, error fingerprints, stream metadata, the activity log, and AI usage records — for this account and project only, in a single transaction.

Keeps: every log you have stored, your detection settings, level rules, field mappings, notification channels, API keys, and users. Nothing is removed from log storage. Detectors relearn from incoming logs.

Irreversible — the alert and incident history is gone — but survivable. Your raw data is untouched, so nothing you could still investigate is lost.

In the app: Settings → General → Danger Zone.

delete-logs/preview — read-only. Run this before any deletion.

POST /api/v1/tenants/{id}/delete-logs/preview takes the exact same body as the deletion and deletes nothing. It returns how many log lines that filter and time range currently match. Owner or admin; rate-limited to one call per minute per account.

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/delete-logs/preview \
  -H 'Content-Type: application/json' \
  --cookie 'epok_access=YOUR_SESSION_COOKIE' \
  -d '{ "filter": "service:loadtest", "start": "-24h", "end": "now" }'

# → {"estimated_logs": 41822,
#     "time_range": {"start": "-24h", "end": "now"},
#     "filter": "service:loadtest"}

If estimated_logs is larger than you expected, your filter is broader than you think. That is the whole point of this call — the deletion itself has no confirmation step and no undo. The count is an estimate summed over hourly buckets, and your log volume keeps moving, so treat it as an order of magnitude, not a receipt.

delete-logs — irreversible. Deletes stored logs only.

POST /api/v1/tenants/{id}/delete-logs. Owner or admin, one call per hour per account. filter is required and uses the same search syntax as the log explorer; start defaults to -24h and end to now. A filter of * matches everything in the range.

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/delete-logs \
  -H 'Content-Type: application/json' \
  --cookie 'epok_access=YOUR_SESSION_COOKIE' \
  -d '{ "filter": "service:loadtest", "start": "-24h", "end": "now" }'

# → {"status": "ok", ...}  plus a background task id — deletion runs asynchronously

Deletes: matching log lines from storage. Permanently. There is no recycle bin, no soft delete, and no restore.

Keeps: everything in the intelligence layer — baselines, fingerprints, alerts, incidents. That asymmetry is worth holding on to: alerts will still reference log lines that no longer exist, and baselines will still reflect traffic you just deleted. If you want the learned state cleared too, run reset-intelligence afterwards.

Deletion runs in the background and the response returns immediately with a task id. Only one deletion may run at a time per account — a second call while one is in flight returns 409.

No app UI. This is an API-only operation.

reset-all — irreversible. Deletes your logs AND everything you configured.

POST /api/v1/tenants/{id}/reset-all. Owner only, one call per hour, and it requires confirm set to exactly the string DELETE ALL DATA — anything else is a 400.

bash
curl -X POST https://app.getepok.dev/api/v1/tenants/YOUR_TENANT_ID/reset-all \
  -H 'Content-Type: application/json' \
  --cookie 'epok_access=YOUR_SESSION_COOKIE' \
  -d '{ "confirm": "DELETE ALL DATA" }'

This is not a bigger reset-intelligence. It is a different operation. Where reset-intelligence clears learned state and keeps your work, reset-all deletes your work too.

Deletes: all stored logs for the account, plus nearly fifty tables of account data — including alerts, incidents, the level rules and field mappings described on this page, dashboards, saved searches, alert rules and routing rules, composite rules, SLO definitions and snapshots, log-metric definitions and datapoints, deployments, escalation policies, on-call schedules, mute schedules, maintenance windows, runbooks, pattern and semantic clusters, service dependencies, anomaly history and feedback, volume budgets, syslog ports, and the activity log.

Keeps: the account itself, users, sessions, API keys, invites, notification channels, and detection settings — enough that you can still log in and your shippers keep working. Nothing else.

Log deletion runs in the background; the database cleanup is synchronous and returns the number of rows removed. If log deletion fails to start, the database cleanup still proceeds — so a partial failure leaves you with your logs and none of your configuration. Preview your log volume first if that matters to you.

No app UI. API-only, by design.

What happens after a reset

Clearing baselines puts the account back on the same clock a brand-new tenant follows. Detection resumes within about an hour of data arriving, but on deliberately wider thresholds — it does not know your normal yet, so it errs toward staying quiet. The log-volume baseline matures in about three days, and thresholds keep tightening to full precision by day seven. During learning, pattern anomalies alert on errors first.

The three-day figure is the learning_period_days default from the table above. Shortening it makes detection confident sooner on less evidence, which usually means more false positives, not earlier true ones. It is a knob worth leaving alone unless you have a specific reason.

One sequencing note: fix your level rules and field mappings before you reset, not after. Rules apply only to logs that arrive after them, so a reset followed by a rule fix just starts the seven-day clock over on data that is still mislabelled.

Next

  • Detectors — what each detector watches, and which ones depend on the level field.
  • Alerting hygiene — muting, maintenance windows, and the audit trail. Tuning thresholds and silencing on purpose are different tools; reach for the right one.
  • Custom rules — when you want a specific condition alerted on rather than a tuned threshold.
  • Search syntax — the filter language used by delete-logs and its preview.
  • API reference — authentication, roles, and the rest of the endpoints.