Ingest protocol¶
The wire protocol between agents (collector, WordPress plugin, third-party)
and the console. The full specification (spec/ingest-v1.md) is reproduced
verbatim below.
PHPRay ingest protocol v1 (draft, 2026-09-18)¶
How data gets from a server to a console. Applies to the Go collector (phpray-collector),
the WordPress plugin and any third-party agent. Consoles: PHPRay Cloud (https://ingest.phpray.dev)
and Host Edition (self-hosted, same API). Status: draft — freeze together with collector 0.14.
1. Principles¶
- Outbound only. Agents open HTTPS connections to the console; the console never connects to a server. No inbound ports, no SSH, no shell.
- Aggregates always, traces selectively. Every minute an agent ships per-site aggregates
(cheap, bounded). Individual traces are shipped only for levels
normal/full/alertand are sampled server-side when the site exceeds its plan quota. - No secrets, no payloads. SQL is shipped as a fingerprint (numbers and quoted strings
replaced), URIs without query values (query keys kept, values
*), no request/response bodies, no cookies, no headers exceptHost. Hostnames can be pseudonymised per site (privacy.mask_host). - Idempotent and lossy-safe. Batches carry a
batch_id; the console deduplicates for 24 h. An agent that cannot deliver keeps at mostbuffer.max_mb(default 32 MB) on disk and drops the oldest traces first, never aggregates.
2. Identity¶
| Thing | Identifier | Where it comes from |
|---|---|---|
| Account (agency / host) | account_id |
console |
| Server | server_key = prk_<32 hex> |
created in console → pasted into collector.toml ([cloud] server_key) or WP plugin settings |
| Site | site_id = sha1(host + \0 + docroot)[:16] |
computed by the agent; the WP plugin uses home_url() host + ABSPATH |
| Agent | agent = collector/0.14.0 or wp-plugin/0.1.0 |
constant per build |
A server_key belongs to exactly one account and one server; rotating it in the console
invalidates the old key after 24 h grace.
3. Endpoints¶
All requests: Authorization: Bearer <server_key>, Content-Type: application/json,
optional Content-Encoding: gzip (recommended above 8 KB). Responses are JSON.
Clock skew: the agent sends sent_at (unix ms); the console uses its own receive time for
ordering and returns server_time so the agent can log drift.
POST /v1/aggregates¶
One request per minute per server (may batch several minutes after an outage).
{
"batch_id": "b_<uuid>", "sent_at": 1789737600000, "agent": "collector/0.14.0",
"server": {"hostname_hash": "<sha1[:16]>", "php_versions": ["8.3.33"], "os": "linux-amd64"},
"minutes": [
{"ts": 1789737540, "site_id": "…", "host": "shop.example", "app": "wordpress",
"requests": 412, "errors_5xx": 3, "errors_php": 7, "profiled": 12,
"duration_ms": {"avg": 231.4, "p50": 198.0, "p95": 640.2, "max": 3120.5},
"cpu_ms": {"avg": 91.2}, "memory_mb": {"avg": 42.1, "max": 128.0},
"db": {"queries": 40120, "ms": 18200.4, "n1_requests": 5},
"http": {"calls": 63, "ms": 9120.0, "errors": 2},
"levels": {"summary": 380, "normal": 27, "full": 4, "alert": 1},
"components": [{"name": "plugins/woocommerce", "incl_ms": 1201.5, "self_ms": 640.1, "calls": 18800}],
"top_uris": [{"uri": "/checkout/", "requests": 31, "p95_ms": 1120.0}]}
]
}
Response 202 {"accepted": 3, "duplicates": 0, "server_time": …}.
POST /v1/traces¶
Batches of up to 500 trace records (the JSONL record produced by the extension or the WP
plugin, unchanged, plus site_id). Sent at most every 5 s, or immediately for alert.
{"batch_id": "b_<uuid>", "sent_at": …, "agent": "…",
"traces": [ { "site_id": "…", "ts": …, "host": "…", "uri": "…", "level": "full", … } ]}
Response 202 {"accepted": 120, "dropped_quota": 0}. If dropped_quota > 0 the console is
sampling; the agent should lower its own traces.sample_rate to the value returned in
X-PHPRay-Trace-Sample (percent) until the next successful full batch.
GET /v1/control¶
Long-poll (up to 25 s) for control messages addressed to this server — this is how "profile URL X for 10 minutes" from the console or the DirectAdmin plugin reaches the extension without touching php.ini:
{"server_time": …, "messages": [
{"id": "m_1", "type": "profile", "site_id": "…", "url_prefix": "/checkout/",
"sample_rate": 100, "until": 1789738200},
{"id": "m_2", "type": "config", "site_id": "…", "traces": {"sample_rate": 10}}
]}
The collector writes profile messages into the shared-memory control table read by the
extension at RINIT (docroot → {url_prefix, sample_rate, until}); the WP plugin applies them
in-process. Agents acknowledge with POST /v1/control/ack {"ids": ["m_1"]}.
GET /v1/health¶
200 {"ok": true, "account": "…", "server": "…", "plan": "studio", "quota": {"traces_per_day": 200000, "used": 84121}}.
Used by phpray status and by the WP plugin settings page ("connected as …").
4. Errors and limits¶
| Code | Meaning | Agent behaviour |
|---|---|---|
| 401 | bad or rotated key | stop sending, surface in phpray status and dashboard |
| 402 | plan exhausted / unpaid | keep aggregates, stop traces, surface |
| 413 | batch too large | split batch in half and retry |
| 429 | rate limited (Retry-After) |
back off, keep buffering |
| 5xx | console problem | exponential backoff 5 s → 5 min, keep buffering |
Hard limits: aggregates 1 request/min/server (burst 10 after outage), traces 500 records
or 4 MB per batch, 12 batches/min/server. Site limit per plan enforced on site_id.
5. Privacy defaults (agent side, before sending)¶
- SQL → fingerprint;
queries[].sqlnever contains literals. Backtrace frames: file path abbreviated fromwp-content/orvendor/, no absolute home paths. - URI: path + fingerprinted query (
?utm_source=*&page=*);ignore_urisnever sent. hostmay be replaced withsite_idwhenprivacy.mask_host = true(per site).- No IP addresses of visitors. No user identifiers. No cookies, headers, bodies.
- Errors: message truncated to 200 chars; file paths abbreviated as above.
6. Versioning¶
/v1 is stable once frozen; additive fields only. Breaking changes → /v2 with 12 months
of parallel support. Agents send agent and consoles may refuse EOL agents with 426.