The single source of truth for the ingest contract. All clients (PHP, browser JS, Node.js) and the server implement exactly this.
Responses use the envelope {"success": true|false, "error"?: "...", "errors"?: {...}}. Status codes: 202 accepted, 400 invalid payload, 401 bad/missing key, 403 origin/key forbidden, 404 unknown row, 405 wrong method, 413 body too large, 422 validation errors, 429 rate limited, 503 redis down.
Ingest: server-side errors (PHP, Node.js)
POST /api/v1/ingest
X-Console-Key: <project api_key> (secret, 64 hex chars)
Content-Type: application/json
Body: a single error object or a JSON array of up to 50 (max 256 KB). Returns 202 {"success":true,"accepted":N} — the entry is queued on a Redis stream and indexed by the worker within ~1s ("processed":true is included when the instance wrote rows inline because the worker was down).
Error object
{
"v": 1,
"runtime": "php", // what ran the code: php | browser | node | python | go | java | dotnet | ruby | rust
"entry": "web", // how it was entered: web | cli | worker
"kind": "error", // what it is: error | not_found | security (see "Kinds, not severities")
"priority": 3, // 0-7 syslog (0 emerg .. 7 debug)
"timestamp": "2026-06-11T17:30:00+02:00",// ISO 8601 or epoch ms; optional
"release": "a3f9c21", // optional deploy label, max 64 chars — git sha,
// svnversion output or any string; omit if unknown
"environment": "staging", // optional deployment stage, max 64 chars — stored and
// filterable always; the grid badges only non-production
"tags": ["checkout", "tenant:acme"], // optional tags, ≤10 — lowercase tokens `^[a-z0-9][a-z0-9_.:-]{0,31}$`
// (or ONE string split on , ; and whitespace); indexed, the TAGS
// column's pills and filter. Per-event tags may also ride
// context.extra.tags — the two are unioned (docs/plans/event-tags.md)
"client": "wordpress/0.5.5", // optional, the sender naming itself as "<sender>/<version>",
// max 64 chars — the PROJECTS grid shows the last one per
// project, the error detail's META the row's
"message": "Call to undefined method …", // headline (defaults to events[0].message)
"events": [ // exception chain, outermost first
{
"message": "…",
"className": "PDOException",
"file": "/var/www/app/src/Db.php",
"line": 42,
"backtrace": "#0 /var/www/app/...", // string, max 8 KB per event
"previous": false, // true for chained (previous) exceptions
"code": {"start": 37, "line": 42, // optional: ±5 source lines around the throw
"lines": ["…"]} // (PHP/Node senders; browser via server symbolication)
}
],
"context": {
"host": "www.example.at", // site host (grid "application" column)
"uri": "/checkout?step=2",
"method": "POST",
"status": 500, // optional — the response status the request ended with; indexed, filter `status` takes a code or a class (5xx)
"referer": "https://…",
"ip": "203.0.113.7", // end-user ip
"ua": "Mozilla/5.0 …",
"sessionId": "…",
"userId": "1234",
"traceId": "4bf92f3577b34da6…", // distributed-trace / request correlation id, max 64 chars —
// indexed, links errors of one request across services
"dir": "/var/www/app", // deploy dir — stripped from file paths for fingerprinting
"request": {"get": {}, "post": {}, "cookies": {},
"contentType": "application/json", // optional — what the app parsed the body as
"body": "{\"sku\":\"X-1\"}", // optional — the RAW body, 16 KB, scrubbed
"headers": {"accept": "application/json"}}, // optional — allowlisted names only
"args": ["cli.php", "import", "run"], // for entry=cli
"extra": {"anything": "…"}
}
}
Server-side processing
Everything below happens after a report is accepted. Clients should redact before sending — the server scrub is a backstop, not the plan.
Truncation
| value | cut at |
|---|---|
message | 2 KB |
backtrace | 8 KB per event |
blobs (request, extra) | 32 KB |
raw request.body | 16 KB |
Redaction and masking, applied to request and extra:
| what | rule |
|---|---|
keys matching password|passwd|pwd|token|secret|authorization|cookie|api[-_]key | dropped |
| e-mail values | masked to their length, domain kept — john.doe@example.com → j***.***@example.com |
username fields (user, username, user_name, user_login, login) | every fourth character kept — marcin → m***i*; past 24 characters the mask states the real length instead, x***x***x***x***x***x***[4000] |
uri / referer / args | same treatment: secret query params dropped, e-mails and usernames masked |
raw request.body | scrubbed as free text by the same names (it has no keys to walk) |
request.headers | reduced to what a replay may re-send: the standard accept* / content-type / x-requested-with set, plus the project's own x-…. Never a cookie, an authorization, or the forwarding family (x-forwarded-*, x-real-ip), which describe the end user |
Fingerprinting — 16 characters, computed from project + kind + runtime + class + normalized message + file.
- Line numbers and volatile message parts (ids, quoted values) are excluded, so occurrences group across deploys.
- The entry is excluded too: the same bug from a web request and from a cron job is one issue.
- Projects with
literal_fingerprintskeep the message's values instead — digits, quoted strings and URLs then distinguish issues. For feeds where the id in the text is the identity:tsid=18andtsid=101become separate errors and issues. Only case and whitespace still normalize. - Flipping that switch re-fingerprints future occurrences only. Open issues keep their history and go quiet.
Burst collapse folds by fingerprint, so under normalization a folded repeat may carry a different message than its anchor row. Those folds keep their own message in the burst samples and tick the row's folded_variants counter (the detail response's burst.variants), so the divergence is never silent.
Every error must be nameable: at least one of message, events[0].message, events[0].className or events[0].file non-empty.
- A batch containing a nameless entry answers
400. That shape is a sender bug — a common one is wrapping the batch in{"errors": [...]}instead of posting the array directly — and accepting it would fold every such sender into one blank-fingerprint issue. - The OTLP endpoint instead drops nameless log records per record and reports them via
partialSuccess.
Send when priority <= log_level (client-side threshold, default 5/notice).
The three axes, and the legacy type
Every event answers three questions. Each is a closed vocabulary, stored as its own field and filterable on its own:
| field | question | values |
|---|---|---|
runtime | what ran the code | php, browser, node, or an OTLP SDK language |
entry | how it was entered | web, cli, worker |
kind | what it is | error, not_found, security |
The grid's TYPE badge is a projection of the three — the runtime for errors, the kind otherwise — and is what the type= filter and sort read.
Senders written before the split send one slot, type (http, cli, js, node, otel, 404, security). The console decodes it at intake for as long as anyone sends it: http → php/web/error, cli → php/cli/error, 404 → not_found, and so on. See SENDER.md.
Kinds, not severities: kind: "not_found" and kind: "security"
Two kinds ride the same envelope but are not severities — the app worked, and what happened is a fact worth recording rather than a defect.
- Their priority is the console's word, never the sender's: whatever value arrives is replaced.
- Both bypass the project's
accept_prioritygate in favour of their own switch, so a project tuned stricter than INFO still receives them:
not_found— a not-found access event (scanner probes, broken links), pinned to priority 6 (INFO). The path is themessage("404 Not Found: /wp-login.php", query string stripped). Gated by the project's report_404 switch (default on); senders rate-limit these client-side.security— a refusal or audit line the app reports deliberately (the PHP sender'sreportRefusal()). Gated by the project's security_events switch, default on: the sender-side call is the deliberate act, this is the per-project off switch.
The event's NAME travels as events[0].className and must come from this closed vocabulary — an unknown kind refuses the event wholesale. Priority is pinned per kind, not chosen by the sender:
| class | priority | what it means |
|---|---|---|
auth_failure | 6 INFO | one is noise; the weight is aggregate (reputation, campaigns) |
csrf_reject | 6 INFO | " |
permission_denied | 6 INFO | " |
rate_limited | 6 INFO | " |
validation_refused | 6 INFO | " |
auth_success | 4 WARNING | a login that succeeded after recent failures — senders never report clean logins |
privileged_action | 5 NOTICE | something happened: a role grant, a plugin install, an option flip |
The rest of the shape:
messageis the human line, scrubbed sender-side —"login failed for m***i* from 203.0.113.7".- Two reserved
extrakeys carry evidence the console lifts into row fields:failures(int) andaction(token). Event rules askfailures >= N/action = Xof them. - Name the account in
context.userId— the application's internal id, never the login name — wherever the application knows it: the login that worked, the signed-in visitor a refusal answered. - Fingerprinted by class,
actionanduserId, never by its message: one issue per account when the sender names it, per project when not.
By default, refusals and 404s create no issues and no alerts — INFO sits under the default issue_priority. auth_success and privileged_action do create issues, through the ordinary thresholds.
A project that wants any shape louder or quieter writes an event rule (the RULES tab). An ESCALATE rule:
- makes matching events issues whatever their severity;
- marks them with an attention tier —
tier4 urgent … 1 low, plustier_reason, the rule's note, on the issue row; - alerts on High and Urgent, like a watch rule.
A rule may also state an impact — what kind of harm this is:
| values | security, data-loss, payment, availability, degraded, cosmetic, noise |
| carried as | impact on the issue row |
| who said so | impact_by: the rule, an attack observer, ai for the analysis's category, or kind when a security event or 404 probe had no other word |
| usable in | the issues search, and attention policies |
Installation-level defaults ship with the console — rules with no project, applied to every one:
| event | default |
|---|---|
auth_success after 5+ failures, or from a known-offender address | Urgent |
plain privileged_action | Normal |
privileged_action with action file_edit or app_password | High |
privileged_action upgrader: install theme (action upgrade_theme) — a theme installed through the admin | High |
A plain auth_success — fewer than five failures, an ordinary address — is still an issue (WARNING clears the default threshold), searchable per account, but carries no mark: it is not an INBOX case and pages nobody. An internal (non-routable) address is never a known offender.
Ingest: browser JS errors
POST /api/v1/ingest/js/<js_key> (public key, in the URL — sendBeacon
Content-Type: text/plain or application/json cannot set headers)
No secret is possible in a browser, so the protection is elsewhere:
| control | rule |
|---|---|
| origin | the request's Origin must exactly match one of the project's js_origins — scheme://host[:port], lowercase |
| rate limit | per IP, default 30/min |
| size | 64 KB |
| batch | at most 10 reports |
| fields | strict whitelist |
OPTIONS preflight is answered for the fetch fallback, and text/plain bodies keep sendBeacon preflight-free.
JS report object (what console-client.js sends)
{
"message": "Uncaught TypeError: x is undefined",
"name": "TypeError",
"stack": "TypeError: …\n at init (https://…/app.js:10:5)",
"file": "https://www.example.at/app.js",
"line": 10,
"col": 5,
"priority": 3,
"count": 4, // occurrences deduped client-side this page load
"url": "https://www.example.at/checkout",// page URL -> host/uri
"timestamp": 1781214000000,
"release": "2026.06.28", // optional deploy label (init option), max 64 chars
"environment": "staging", // optional deployment stage, max 64 chars
"tags": ["shop", "eu"], // optional tags (init option), ≤10 lowercase tokens or one string
"traceId": "4bf92f3577b34da6…", // optional request correlation id (app-provided)
"extra": {"userId": "1234"}
}
ip, ua and referer are taken from the request server-side.
Browser integration
<script src="https://CONSOLE-HOST/js/console-client.js"></script>
<script>
ovosConsole.init({
url: 'https://CONSOLE-HOST',
key: 'PROJECT_JS_KEY',
logLevel: 4,
release: '2026.06.28', // optional deploy label — indexed, unlike context fields
environment: 'staging', // optional deployment stage — indexed, badged beside the project name
tags: ['shop', 'eu'], // optional tags on every report — indexed, the TAGS column's pills
context: function () { return {userId: window.currentUserId}; }
});
// manual captures:
// ovosConsole.captureException(error, {orderId: 7});
// ovosConsole.captureMessage('checkout step skipped', 4);
</script>
The client dedupes repeated errors per page load (an error inside a rAF loop is sent once with a count), flushes after 2 s and on page hide via sendBeacon, and never throws into the host page. Vendor the file or load it from the console instance.
Node.js
Same endpoint and payload as PHP: runtime: "node", X-Console-Key, batched as a JSON array, AbortSignal.timeout(1000), every failure swallowed.
The dependency-free reference client is client-node/console-client.mjs. It:
- hooks
uncaughtException— awaits the send, then exits non-zero — andunhandledRejection; - attaches ±5 lines of source context (
code, above); - attaches a process snapshot under
context.extra:node,pid,uptimeSec,rssMb,heapMb; - can instrument
fetch, so failed calls group per endpoint.
import {init, captureException, captureMessage} from './console-client.mjs';
init({url: 'https://CONSOLE-HOST', key: process.env.CONSOLE_KEY, logLevel: 4,
release: process.env.RELEASE, context: () => ({service: 'my-worker'})});
With rollups: true in init() the client also feeds the traffic rollups below.
- Call
observeRequest({status, method, route, authed, durationMs})once per finished response. routeis the matched route pattern ('/users/:id'), ornullfor a router miss. Never the URI.- Each completed minute ships as one fragment to
/api/v1/ingest/rollup— boundary-driven, plus anunref()ed 30 s interval for idle processes.
Building a sender for a runtime we don't ship (Python, Go, …)? See SENDER.md — the same contract as an AI-ready authoring spec.
Ingest: OpenTelemetry (OTLP/HTTP logs)
POST /api/v1/ingest/otel (or …/otel/v1/logs — both work)
X-Console-Key: <project api_key>
Content-Type: application/json (OTLP/JSON; protobuf answers 415)
Content-Encoding: gzip (optional — the exporter's default;
zstd works when the server has
ext-zstd, others answer 415)
Point an OpenTelemetry Collector at the console and its log records become console errors — no console client in the sending service required. Runtime comes from the resource's telemetry.sdk.language, entry is web, kind is error. This section is the wire contract; OTEL.md is the operator's guide (filtering, batching, source annotations, troubleshooting).
exporters:
otlphttp/console:
logs_endpoint: https://CONSOLE-HOST/api/v1/ingest/otel
encoding: json # the endpoint does not decode protobuf
headers:
X-Console-Key: ${env:CONSOLE_KEY}
service:
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp/console]
Responses
These follow the OTLP/HTTP contract, not the {"success": …} envelope used elsewhere in this document.
| status | body | meaning |
|---|---|---|
200 | {} | every record accepted |
200 | {"partialSuccess": {…}} | some dropped: over console.otel.max_records (default 200 per request), or below the project's accept priority |
401 | google.rpc.Status | bad or missing key |
403 | google.rpc.Status | metrics for a project without rollups_enabled |
404 | google.rpc.Status | traces — export them elsewhere |
413 | google.rpc.Status | too large: 1 MB raw, 8 MB inflated |
415 | google.rpc.Status | protobuf, or an unsupported Content-Encoding |
503 | google.rpc.Status | redis down — the only status the exporter should retry |
…/otel/v1/metrics is the OTLP door onto the traffic rollups below.
Field mapping
| OTLP | console field |
|---|---|
severityNumber / severityText | priority (FATAL→2, ERROR→3, WARN→4, INFO→6, DEBUG/TRACE→7) |
body | message |
exception.type / .message / .stacktrace | the event: class, message, backtrace |
code.file.path + code.line.number | file / line (legacy code.filepath + code.lineno also read) |
code.column.number | extra.col (legacy code.column) |
resource service.name | host — the "application" column |
resource service.version | release |
deployment.environment.name | environment (deprecated deployment.environment also read) |
resource tags, record tags | tags — the installation's and the event's, a string array or one string, unioned (OTEL has no tags concept, so this is the console's own attribute) |
url.*, http.*, client.address, user_agent.original, session.id, user.id | request context |
record traceId | the indexed trace_id field — cross-service correlation |
resource process.command_args | CLI args |
| anything unmapped | context.extra — span_id, scope, severity at the top, resource attributes under resource |
Browser-origin resources (telemetry.sdk.language: webjs, or any browser.* resource attribute) ingest as runtime browser, with the page host taken from the record's url.full, and group like native browser reports.
Filter at the collector. A severity filter processor keeps INFO floods from leaving the source; whatever arrives anyway is cut by the project's accept priority.
Ingest: release announce (the deploy step)
POST /api/v1/ingest/release
X-Console-Key: <project api_key>
A release is known the minute it ships, not when its first tagged error arrives. The deploy step posts:
{"release": "42800", "at": 1788700000, "ref": "r42800", "source": "deploy.sh", "environment": "production"}
| field | required | rules |
|---|---|---|
release | yes | 1–64 printable characters, the same value the events carry |
at | no | epoch seconds, epoch milliseconds or ISO 8601; defaults to now; refused more than 5 minutes in the future |
ref | no | the VCS ref, shown on the release rail |
source | no | who announced it |
environment | no | the stage this release went to |
Answers 202 {"success": true, "accepted": 1|0, "duplicate": bool, "release", "at"}.
- A repeat is a duplicate and keeps the EARLIEST stamp: a deploy is one moment.
405/401/413/400/503behave as for rollups.- No opt-in. One small key is all it writes.
What reads it: the release rail (GET /api/v1/stats/releases), the verified close and the fix candidates all read the announce merged into the tagged releases. first is the deploy time when that came earlier, and a release no event has named yet appears with occurrences: 0, announced_at, ref and source.
Stored as console:releases:<project> (ZSET) plus one hash per release (docs/plans/release-announce.md).
Ingest: traffic rollups (per-minute counters)
POST /api/v1/ingest/rollup
X-Console-Key: <project api_key>
The denominator layer. A server-side accumulator flushes one fragment per minute per pool — request totals plus status / method / route / authed breakdowns — so the attack observer can quote a rate ("379 of 431 requests matched no route") instead of judging raw counts.
- Opt-in per project (
rollups_enabled): on for newly created projects, off for rows predating the switch. The sender keeps its own opt-in too. - A fragment for a project that has not opted in answers
403and writes nothing. - There is no browser door. A page cannot see the request stream, and a public endpoint for additive counters would be an open write primitive.
{
"minute": 29248320,
"host": "web-03",
"instance": "fpm-2",
"seq": 88214,
"requests": 18432,
"status": { "200": 17801, "404": 187, "500": 11 },
"methods": { "GET": 17994, "POST": 421 },
"routes": { "/product/{slug}": 6104, "__unmatched": 187 },
"authed": { "yes": 2210, "no": 16222 },
"clients": 3421,
"durations": {
"__total": [17000, 1400, 20, 8, 3, 1, 0, 0, 0, 0, 0, 0],
"/product/{slug}": [ 6000, 100, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0]
}
}
Rules the endpoint enforces. A violation refuses the whole fragment with 400 and the reason:
| field | rule |
|---|---|
minute | intdiv(time(), 60), within ±90 minutes of now |
instance, host | [A-Za-z0-9._-]{1,64} (host optional) |
| every counter | a non-negative JSON integer |
routes keys | the matched route pattern — /product/{slug}, never the raw URI. Keys containing ? or % are refused |
status keys | three digits |
methods keys | known verbs |
authed keys | yes / no |
- Unmatched requests belong in
__unmatched— that counter is the probe signal. - Everything past the top 40 routes folds into
__other. - Nothing request-derived may appear anywhere. A rollup is structurally incapable of carrying PII.
durations is optional (an older sender simply omits it): request-time histograms, one vector of exactly 12 bucket counts per route — plus the required __total vector whenever the map is non-empty. The bucket bounds are a fixed wire contract every sender hardcodes identically, upper bounds in milliseconds:
25, 50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 30000, +inf
- **Bucket *i*** counts requests slower than bound i−1 and at most bound i. The last bucket is everything past 30 s — hung requests.
- What is measured: server wall time, request start to shutdown. It includes framework boot; it excludes only the web server and the network.
- Counts behave like every other counter in the fragment: additive across pools, deduplicated by
(instance, seq), capped per route exactly likeroutes(overflow folds into__other, bucket by bucket). - Percentiles are computed at read time by histogram interpolation — which is why the console always shows them as ≈ approximations.
Fragments are additive. Pools on one host each flush their own and the server sums them, so (instance, seq) deduplicates at-least-once retries: the same seq twice counts once.
202 {"success": true, "accepted": 1, "duplicate": false}
202 {"success": true, "accepted": 0, "duplicate": true} // a replay
The OTLP alternative. Export a monotonic DELTA Sum named console.rollup.requests to …/ingest/otel/v1/metrics.
| values | integers |
| attributes | route / status / method / authed, from the same vocabulary |
| resource | service.instance.id |
| streams | one per attribute tuple |
The streams must partition the requests. Each data point's value counts into requests and into every dimension its attributes name, so separate per-dimension counters of the same traffic would multiply requests.
CUMULATIVE temporality, other metric names, histograms and non-integer values are counted and reported via partialSuccess — never silently dropped.
Storage: console:stats:rollups:<projectId>:<minute> hashes (26 h TTL), compacted by the worker rollups cron into :h:<hour> hashes (98 d TTL, top-20 routes). Turning rollups_enabled off stops new keys immediately; existing minute keys age out on their own.
Reading it back (no UI surface — the consumers are the attack observer and these read surfaces):
| surface | what it gives |
|---|---|
php cli.php stats rollups | one totals line per enabled project |
php cli.php stats rollups <project> <minutes> | the per-minute series, busiest routes, and — when senders report durations — the window's ≈p50/≈p95 |
MCP get_traffic (project, minutes ≤ 1440) | the same digest: totals, status / method / authed splits, top routes, the unmatched share, p50_ms / p95_ms (null until a sender reports durations), and one row per active minute — so an agent can judge error counts against real traffic |
The stats view's PERFORMANCE panel is the UI surface:
GET /api/v1/stats/performance?project=<name>&days=1|7|14
answers the panel payload, session-authenticated like the rest of /api/v1:
- window totals with
p50_ms/p95_ms; - the raw 12-bucket histogram and its bounds;
- an hour-grain
series— only hours that carried traffic; - the
routestable: per-route requests plus ≈p50/≈p95, worst p95 first, top 15; releases— first occurrence per release inside the window, from the errors index, rendered as vertical markers on the trend.
Cached 120 s per project and window.
The cross-project ranking sits above the panel on the STATS tab:
GET /api/v1/stats/ranking?days=1|7|14
answers one row per rollup-enabled project that saw traffic in the window:
| field | meaning |
|---|---|
requests | every request counted |
timed | the histogram's own sum — untimed requests count into requests only |
avg_ms | bucket-midpoint weighted estimate |
p50_ms, p95_ms | interpolated percentiles |
Sorted slowest average first; projects without histograms come last with null ms values. fleet carries the same statistics over the MERGED vector of every listed project — the reference the UI highlights against. Cached 120 s per window.
The release comparison rides beside it:
GET /api/v1/stats/releases?project=<name>[&release=X]
Without release — the rail: every release the project's tagged events named, newest first, with first/last seen, summed occurrences, and attention: {tier, reason, at} or null (the release's case: cohort for a week after the buddy's RELEASE COHORT card).
With one — the rail plus a comparison against the release first seen before it:
| compared | how |
|---|---|
| errors | per hour, and per 10k requests when rollups carry the denominator |
| duration | ≈p50/≈p95 from the histograms |
| issues born in it | by first_release |
| issues silent since it shipped | by last_release = the previous release |
Volume and duration numbers use the release's time window, including untagged events; the issue cohorts use the tags.
Unknown release: 404. Cached 120 s. Agents get the same answer through the compare_releases MCP tool, where release is optional and defaults to the newest known.
Inventory: the CVE sensor's intake
POST /api/v1/ingest/inventory
X-Console-Key: <project api_key>
A site's software inventory, platform-tagged. The contract is deliberately platform-neutral — the console is not married to any single CMS. Three vocabularies today: wordpress (plugin|theme|mu-plugin), drupal (module|theme|profile), and the CMS-agnostic composer (package) for any composer-based PHP app reporting its lock file:
{
"platform": "wordpress",
"core": "6.7.1",
"php": "8.3.9",
"items": [
{"type": "plugin", "slug": "contact-form-7", "version": "5.9.8",
"name": "Contact Form 7", "active": true}
]
}
{
"platform": "drupal",
"core": "10.4.2",
"php": "8.3.9",
"items": [
{"type": "module", "slug": "webform", "version": "6.2.1",
"package": "drupal/webform", "name": "Webform", "active": true}
]
}
Double opt-in (an installed-software list is a disclosure): the project's The cve_enabled switch (default off) answers 403 and writes nothing until someone turns it on — and the reporter carries its own switch at the site end.
The validator rejects rather than repairs:
| field | rule |
|---|---|
platform | a closed vocabulary, which also scopes type |
| slugs | [a-z0-9][a-z0-9._-]{0,99}, folded to lowercase |
| versions | [0-9A-Za-z._-]{0,32} — empty is legal, a header without Version is |
package | optional composer name vendor/name, folded to lowercase, refused when malformed: it is a matching key |
name | display only, control-stripped, capped at 100 characters |
| items | capped at 300; the overflow is dropped and counted — dropped rides the answer, never silent |
Nothing here may carry a path, URL, query string or option value.
Storage is one row per (project, platform) — the latest inventory, never a history.
- An unchanged report is the 24 h heartbeat: answered
202 {"accepted":1,"duplicate":true}, and only the freshness timestamp advances. - With the project's
cve_auto_updateswitch on, the answer also carriesauto_update: ["contact-form-7", …]— the slugs of the installed PLUGINS whose open finding the scanners are probing for. The WordPress plugin, under its ownauto_update_vulnerablesetting, switches on WordPress's native automatic update for exactly those and reports each switch-on as aprivileged_actionsecurity event (docs/plans/wordpress-cyberprotection-brainstorm.md, P4). Themes and quiet findings are never named; nothing is downgraded, deactivated or deleted. - The daily
security cve-synccron matches stored inventories against the per-lane vulnerability feed:
| platform | feed | matched by |
|---|---|---|
wordpress | Wordfence Intelligence scanner feed | slug |
| every other | OSV Packagist dump — Drupal SA, GHSA and Packagist advisories in one source | package |
Findings land per project; GET /api/v1/security/vulnerabilities reads them back.
Files: the integrity scan's intake
POST /api/v1/ingest/files
X-Console-Key: <project api_key>
A site's integrity-scan report (docs/plans/wordpress-integrity-scan.md): the sender's read-only walk for the file nobody shipped — PHP under uploads, media files that open with a PHP tag, root PHP core did not ship, hidden PHP, .htaccess / .user.ini / php.ini directives that make files execute or send visitors elsewhere, drop-ins and plugin data directories without their plugin — plus the site's hardening posture. The WordPress plugin (0.6.1+) sends it from its Scan now button and its background pass. The php-library's untracked pass (platform: php; Service\Console\Untracked, the cron line php cli.php console files) speaks the same report for a git or svn working copy: what the repository did not ship, one root area, detectors untracked (a PHP file — urgent under a web-reachable directory, high elsewhere), untracked_config (.htaccess, .user.ini, php.ini, web.config), untracked_dir (every other untracked file, counted per directory with an extension histogram — never a file name), modified (a tracked file whose content differs from the commit — the same tiers, a browser script under the web directory high) and missing (a tracked file gone from disk, info); the root area's foreign/modified/missing counters and posture.working_copy: git|svn. The shape is platform-tagged so another CMS's sender can speak it too.
{
"v": 1, "type": "files", "platform": "wordpress",
"core": "7.1", "php": "8.3.9", "client": "wordpress/0.6.1",
"release": "v12", "environment": "production",
"scan": {"id": "11ffd7cba9fe8a98", "mode": "manual", "started": 1789390318, "finished": 1789390319,
"duration": 671, "chunks": 2, "complete": true, "files": 7039, "dirs": 1077,
"unreadable": 0, "symlinks": 0, "skipped": ["content:upgrade"],
"counts": {"urgent": 2, "high": 1, "info": 1}, "truncated": {"urgent": 0, "high": 0, "info": 0}},
"areas": {"uploads": {"root": "/var/www/site/wp-content/uploads", "files": 9, "dirs": 5,
"executable": 2, "bytes": 4096, "probed": 3}},
"findings": [
{"detector": "uploads_php", "tier": "urgent", "area": "uploads", "path": "2026/09/x.php",
"size": 13, "mtime": 1789390000},
{"detector": "handler_php_extension", "tier": "urgent", "area": "uploads", "path": ".htaccess",
"line": 1, "detail": "PHP handler for .jpg .png"}
],
"posture": {"server": "apache", "file_edit_disabled": false, "uploads_php_denied": true,
"xmlrpc": true, "ini": {"auto_prepend_file": "", "opcache": true}}
}
Double opt-in (a list of a site's foreign files is a disclosure): the project's files_enabled switch (default off) answers 403 and writes nothing until someone turns it on — and the sender carries its own switch at the site end.
This is the one ingest contract that carries server paths, and every one of them is attacker-authored: the validator rejects rather than repairs, and a refusal sentence never quotes the path.
| field | rule |
|---|---|
platform | closed vocabulary (wordpress, php) |
area | root|uploads|content|plugins|core|themes|database — a finding's path is relative to that area's root in areas (absolute for an ini target outside the site); the database area's root is '' and its paths are users/<id>, options/<name>, options/cron/<hook>, posts/<id> |
path | 1–512 bytes, no control characters, no .. segment |
detector | [a-z][a-z0-9_]{0,31} — the sender's catalogue, not the console's: an unknown word is stored and printed as itself |
tier | urgent|high|info — the sender's PROPOSAL |
scan.mode | manual|background; scan.complete false = the walk was interrupted |
size, mtime, line | optional non-negative integers (line ≥ 1) |
detail | display only, control-stripped, 160 characters |
posture | the known keys only; a stranger key is dropped, never a refusal |
areas.* counters | files dirs executable bytes probed always; verified modified foreign missing (the checksum pass) and admins app_passwords cron_hooks options_scanned posts_scanned (the database pass) when the sender has them — non-negative integers |
checksums | core: verified|unavailable|skipped (a stranger word reads as skipped), core_locale, plugins_verified[] and plugins_unavailable[] as slugs — what wordpress.org's lists could vouch for; a stranger slug is dropped |
| findings | capped at 500; the overflow is dropped and counted — dropped rides the answer |
Storage is the ledger (file_findings, one row per project and finding identity — platform, area, path, detector, line) beside one file_scans row per project and platform (the latest pass and its posture, never a history). The answer says what the report changed:
{"accepted": 1, "new": 2, "seen": 5, "returned": 0, "gone": 1, "judged": true}
newrows are born open at the proposed tier;seenrows were listed again (one more scan on the books; an acknowledged row stays acknowledged).goneis the scan's word alone: a complete report whose sender counted no remainder no longer lists a path that was on the books.judged: falsemeans the report could not say — interrupted or truncated — and nothing was marked gone.returnedrows came back after a report said they were gone: the row reopens one tier higher (info → high → urgent), the operator's earlier acknowledgement is void, and the raise is never talked down by a later proposal. That is the line worth waking someone for — the hole is still open.
Each open row at urgent or high carries a case (docs/plans/cases.md, source file, mark foreign), so the INBOX lists it with the removal advice by detector — reinstall core or the plugin for a checksum verdict, revoke and rotate for an admin's application passwords, unschedule for a stray cron hook — and offers TICKET where the project has a tracker; a policy with SOURCE = file can notify. The project's mail and chat channels hear about it at once: one digest per report for the NEW urgent and high rows and every row that came BACK (Alert\Files, throttled per row and per return for a day), with the removal advice and the wave. A row whose date (mtime: a file's modification, an account's registration, a post's edit) falls into one of the project's attack waves — an hour before its first hit to six hours after its last, within the last 30 days — carries that wave in the case's reason (40 s after 203.0.113.99 stopped probing) and as wave: {ip, day, first, last, gap} on the band's row. GET /api/v1/security/files reads the ledger back; POST /api/v1/security/file-state is the operator's word.
Sourcemaps: minified browser stacks, resolved
POST /api/v1/ingest/sourcemap
X-Console-Key: <project api_key>
One Source Map v3 for one bundle of one release, uploaded by the build pipeline right after the assets ship:
curl -X POST -H "X-Console-Key: $CONSOLE_KEY" -H "Content-Type: application/json" \
--data "{\"release\":\"2.14.1\",\"path\":\"/assets/app.min.js\",\"map\":$(jq -Rs . < dist/app.min.js.map)}" \
https://console.example/api/v1/ingest/sourcemap
| field | rule |
|---|---|
release | printable, no spaces, ≤128 — must match what the site's client tags its errors with |
path | the bundle's URL path. A full URL is reduced to its path; a bare basename works as a fallback key; ≤512 |
map | the map JSON as a string, ≤32 MB raw — validated as a real v3 map before storing, so a broken upload fails the deploy step with a 400 rather than sitting in the store resolving nothing |
A re-upload for the same (release, path) replaces, answering 202 {"stored":1,"replaced":true|false}. Maps are stored zstd-compressed, one row per (project, release, path), pruned past 240 rows per project.
Resolution happens in the worker, per browser (type=js) error: the top the frame's file/line/col and up to 20 stack lines are resolved against the map of each bundle they name.
- Exact release match only. A wrong-version map resolves to confidently wrong lines, which is worse than none.
- Rewritten stack lines keep the minified truth:
at submitOrder (src/checkout.js:42:9) [was app.min.js:1:48211]. - When the map embeds
sourcesContent, the original source lands under the error as the same code block PHP senders ship — the detail view and the AI analysis read it unchanged.
Zero-config fetch: when an error names a bundle with no uploaded map, the worker fetches the bundle's //# sourceMappingURL= pointer once — inline data: maps included — and stores what it finds. It is gated hard:
- the URL's origin must be one of the project's
js_origins; - it must pass the same private-target check as the monitors' health URLs;
- misses are negative-cached for hours.
Sites that publish no .map files simply keep minified stacks. Uploading is always the reliable path.
Ping: cron dead-man's-switch
GET|POST /api/v1/ping/<ping_key> (public — the key is the secret)
A scheduled job pings this at the end of each run; the console records the time so monitors check can alert when an expected ping goes missing. Returns 204 (no body) on success, 404 for an unknown or inactive key.
* * * * * backup.sh && curl -fsS -m 10 https://<instance>/api/v1/ping/<ping_key>
Monitors (period, grace, the generated ping key) and per-project uptime checks are managed in the MONITORS tab. See docs/MONITORS.md for the ping URL and the uptime health_url contract.
Alerts
Per project: when a new occurrence has priority <= alert_priority, an email goes to alert_emails — at most one per fingerprint per alert_throttle_minutes (suppressed occurrences are counted and included in the next mail) and at most max_emails_per_hour (default 20) per project, then one storm notice.
When a Google Chat webhook is configured (and alert_priority is on), the channel gets event-style posts instead of per-occurrence noise:
| post | when |
|---|---|
🆕 NEW ISSUE | first occurrence of a fingerprint |
⚠️ REGRESSION | a resolved issue came back |
📈 SPIKE | one fingerprint exceeded spike_per_minute (default 60) |
Each fires at most once per fingerprint per hour, against a separate max_chat_per_hour budget. The webhook URL embeds its credential: it is masked in the UI and never logged.
Issue lifecycle
Every fingerprint is an issue with a state: open, resolved or muted (muted_days: 0 = forever).
| state | what an occurrence does |
|---|---|
open | the ordinary path: counted, alerted per the thresholds |
muted | arrives pre-checked and never alerts; when a timed mute expires, the next occurrence reopens the issue |
resolved | it is a regression: the issue reopens (regressed_at) and one alert bypasses the fingerprint throttle — at most one regression mail per fingerprint per hour |
Issue metadata lives on the fingerprint hash and expires retention + 7 days after the last occurrence, so a muted issue that stays silent that long starts fresh if it ever returns. maintenance backfill-issues enriches fingerprints created before this feature.
Retention
Error rows expire after the project's retention_days (instance default 90). Starring a row (important) removes its TTL permanently; un-starring restores the remaining retention. Under memory pressure Redis evicts the oldest rows first (volatile-ttl) — starred rows are immune.