DOCUMENTATION

Build your own client

Six rules, one small implementation in four languages, and what each field buys you.

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.
  • 202 means queued. The row is searchable about a second later.
  • Field-by-field reference: Ingest API v1.

Six rules, and why each one exists

rulewhy
Fire and forgetReporting must never be able to fail the request that reports. Swallow every exception, always.
One second, hardA console that is unreachable would otherwise add its timeout to every page your users load.
Never retryAn incident produces thousands of errors at once. Retries turn our bad minute into your outage.
Batch at shutdownCollect during the request, send once after the response has gone out. One POST, no latency.
Redact before sendingPasswords, tokens, cookies and authorization headers never leave the box. Do it in the client, not in the console.
Cap everythingTruncate 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.

fieldwhat it unlocks
release"born in this release", regression detection, release comparison
environmentproduction and staging stop sharing an issue
traceIda browser failure and the server error behind it line up as one request
context.dirthe deploy root, so a file can be checked against the repository
context.uri / method / statusthe request that failed, replayable later
priorityyour own gate: which levels become issues, per project

How the console answers

statusmeaningwhat a client does
202queuednothing
400 / 422the payload is wrongfix the client; do not retry
401 / 403key or origin refusedstop sending; alert an operator
413body too largetruncate harder
429rate limiteddrop the batch
503the queue is downdrop 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.