A client is an HTTP POST. There is no SDK you must adopt, no agent to run and nothing to compile. This page is what a good one does, and why.
The contract in one screen
POST /api/v1/ingest,X-Console-Key: <api_key>,Content-Type: application/json.- Body: one error object, or an array of up to 50 — 256 KB maximum.
202means queued. The row is searchable about a second later.- Field-by-field reference: Ingest API v1.
Six rules, and why each one exists
| rule | why |
|---|---|
| Fire and forget | Reporting must never be able to fail the request that reports. Swallow every exception, always. |
| One second, hard | A console that is unreachable would otherwise add its timeout to every page your users load. |
| Never retry | An incident produces thousands of errors at once. Retries turn our bad minute into your outage. |
| Batch at shutdown | Collect during the request, send once after the response has gone out. One POST, no latency. |
| Redact before sending | Passwords, tokens, cookies and authorization headers never leave the box. Do it in the client, not in the console. |
| Cap everything | Truncate messages, backtraces and bodies before they are sent, so one pathological error cannot post a megabyte. |
Why no retry, really? The queue is on our side. A dropped report costs you one row; a retry loop during an outage costs you the site.
The smallest thing that works
PHP
final class Client
{
private array $queue = [];
public function __construct(
private string $url,
private string $key,
)
{
register_shutdown_function($this->flush(...)); // send after the response
}
public function report(Throwable $e): void
{
$this->queue[] = [
'v' => 1,
'runtime' => 'php',
'entry' => PHP_SAPI === 'cli' ? 'cli' : 'web',
'kind' => 'error',
'priority' => 3,
'message' => mb_substr($e->getMessage(), 0, 1000),
'events' => [[
'message' => mb_substr($e->getMessage(), 0, 1000),
'className' => $e::class,
'file' => $e->getFile(),
'line' => $e->getLine(),
'backtrace' => mb_substr($e->getTraceAsString(), 0, 8000),
]],
];
}
public function flush(): void
{
if($this->queue === [])
{
return;
}
// php-fpm only: hand the response to the visitor and close their
// connection, then keep working. Everything below now costs the user
// nothing. Call it ONLY if the application has not already — it ends
// the response, so anything the app still wanted to echo is lost.
if(function_exists('fastcgi_finish_request'))
{
fastcgi_finish_request();
}
$curl = curl_init($this->url . '/api/v1/ingest');
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(array_slice($this->queue, 0, 50)),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Console-Key: ' . $this->key],
CURLOPT_CONNECTTIMEOUT_MS => 300,
CURLOPT_TIMEOUT_MS => 1000,
CURLOPT_RETURNTRANSFER => true,
]);
@curl_exec($curl); // no retry, no exception, no log of our own
$this->queue = [];
}
}Node.js
const queue = [];
export const report = (error, extra = {}) =>
{
queue.push({
v: 1,
runtime: 'node',
entry: 'web',
kind: 'error',
priority: 3,
message: String(error.message).slice(0, 1000),
events: [{
message: String(error.message).slice(0, 1000),
className: error.name,
backtrace: String(error.stack ?? '').slice(0, 8000),
}],
...extra,
});
};
export const flush = async () =>
{
if (queue.length === 0)
{
return;
}
const body = JSON.stringify(queue.splice(0, 50));
await fetch(`${process.env.CONSOLE_URL}/api/v1/ingest`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Console-Key': process.env.CONSOLE_KEY},
body,
signal: AbortSignal.timeout(1000),
}).catch(() => {}); // never rejects into the caller
};
process.on('beforeExit', flush);Python
import atexit, json, urllib.request
QUEUE: list[dict] = []
def report(exc: BaseException) -> None:
QUEUE.append({
"v": 1,
"runtime": "python",
"entry": "web",
"kind": "error",
"priority": 3,
"message": str(exc)[:1000],
"events": [{"message": str(exc)[:1000], "className": type(exc).__name__}],
})
def flush() -> None:
if not QUEUE:
return
body = json.dumps(QUEUE[:50]).encode()
del QUEUE[:]
req = urllib.request.Request(
f"{URL}/api/v1/ingest",
data=body,
headers={"Content-Type": "application/json", "X-Console-Key": KEY},
)
try:
urllib.request.urlopen(req, timeout=1)
except Exception:
pass
atexit.register(flush)Go
type Client struct {
URL, Key string
queue []map[string]any
mu sync.Mutex
}
func (c *Client) Report(err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.queue = append(c.queue, map[string]any{
"v": 1,
"runtime": "go",
"entry": "web",
"kind": "error",
"priority": 3,
"message": err.Error(),
"events": []map[string]any{{"message": err.Error(), "className": "error"}},
})
}
func (c *Client) Flush() {
c.mu.Lock()
batch := c.queue
c.queue = nil
c.mu.Unlock()
if len(batch) == 0 {
return
}
body, _ := json.Marshal(batch)
req, _ := http.NewRequest("POST", c.URL+"/api/v1/ingest", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Console-Key", c.Key)
if resp, err := (&http.Client{Timeout: time.Second}).Do(req); err == nil {
resp.Body.Close()
}
}Java
public final class Console {
private final List<String> queue = Collections.synchronizedList(new ArrayList<>());
private final HttpClient http = HttpClient.newHttpClient();
public Console(String url, String key) {
this.url = url;
this.key = key;
Runtime.getRuntime().addShutdownHook(new Thread(this::flush));
}
public void report(Throwable t) {
queue.add("""
{"v":1,"runtime":"java","entry":"web","kind":"error","priority":3,
"message":%s,"events":[{"message":%s,"className":"%s"}]}
""".formatted(json(t.getMessage()), json(t.getMessage()), t.getClass().getName()));
}
public void flush() {
List<String> batch;
synchronized (queue) {
if (queue.isEmpty()) return;
batch = List.copyOf(queue.subList(0, Math.min(50, queue.size())));
queue.clear();
}
var request = HttpRequest.newBuilder(URI.create(url + "/api/v1/ingest"))
.header("Content-Type", "application/json")
.header("X-Console-Key", key)
.timeout(Duration.ofSeconds(1))
.POST(HttpRequest.BodyPublishers.ofString("[" + String.join(",", batch) + "]"))
.build();
try {
http.send(request, HttpResponse.BodyHandlers.discarding());
} catch (Exception ignored) {
// a monitoring client may never throw into the application
}
}
}Rust
pub struct Console {
url: String,
key: String,
queue: Mutex<Vec<serde_json::Value>>,
}
impl Console {
pub fn report(&self, err: &dyn std::error::Error) {
self.queue.lock().unwrap().push(serde_json::json!({
"v": 1,
"runtime": "rust",
"entry": "web",
"kind": "error",
"priority": 3,
"message": err.to_string(),
"events": [{ "message": err.to_string(), "className": "Error" }],
}));
}
pub async fn flush(&self) {
let batch: Vec<_> = {
let mut queue = self.queue.lock().unwrap();
if queue.is_empty() {
return;
}
queue.drain(..queue.len().min(50)).collect()
};
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(1))
.build()
{
Ok(client) => client,
Err(_) => return,
};
// the result is dropped on purpose: no retry, no panic, no log
let _ = client
.post(format!("{}/api/v1/ingest", self.url))
.header("X-Console-Key", &self.key)
.json(&batch)
.send()
.await;
}
}What to send beyond the message
Each of these costs nothing and changes what the console can do for you.
| field | what it unlocks |
|---|---|
release | "born in this release", regression detection, release comparison |
environment | production and staging stop sharing an issue |
traceId | a browser failure and the server error behind it line up as one request |
context.dir | the deploy root, so a file can be checked against the repository |
context.uri / method / status | the request that failed, replayable later |
priority | your own gate: which levels become issues, per project |
How the console answers
| status | meaning | what a client does |
|---|---|---|
202 | queued | nothing |
400 / 422 | the payload is wrong | fix the client; do not retry |
401 / 403 | key or origin refused | stop sending; alert an operator |
413 | body too large | truncate harder |
429 | rate limited | drop the batch |
503 | the queue is down | drop the batch |
Every one of them is "drop it and move on". That is the whole error-handling strategy, and it is deliberate.
Before you ship it
- Kill the console and confirm your app is unaffected — same response time, no errors of its own.
- Send 10 000 errors in a loop and confirm one POST goes out, not 10 000.
- Put a password in a request body and confirm it never reaches the console.
- Restart the app mid-request and confirm nothing is lost that matters.
Next: Ingest API v1 for the field reference, Browser client for the front end, or OpenTelemetry if you already run a collector.