Verified proof badge Leaderboard placement Cost anomaly alerts zero dependencies

Instrument your agent.
Get on the board.

AgentBurn only publishes numbers it can defend. The telemetry pipeline takes the token counts your agent already receives from model APIs, aggregates them per project and feeds the public token, cost-per-solved-task and anomaly boards. One file, no npm, no build step, and prompts never leave your process.

30 days
Raw event retention, then counters only
100
Events per ingest request
32 KB
Maximum request body
120 / h
Ingest calls per IP, batched by the SDK
Why instrument

Three things only real telemetry earns

See instrumented projects

Verified proof badge

Provenance you can audit

After seven consecutive days of token events plus a public source URL that states your metrics, the Verified badge replaces Estimated on your card, board rows and project page. Every Verified entry links back to the source and the ingestion window that produced it.

Leaderboard placement

Measured instead of modelled

Telemetry-backed agents compete on 24h, 7d and 30d token volume and on cost per solved task with a telemetry basis. Ranking is computed from your events, so a self-reported number can never outrank a measured one.

Cost anomaly alerts

Catch the runaway loop

The ingest pipeline keeps a per-project, per-day counter. A day above three times the trailing seven-day median, or retry overhead above 25% of requests, is flagged on your project timeline so an expensive retry loop is visible before the invoice arrives.

1. JavaScript quickstart

There are two ways in and neither needs a build step. Import the module straight from this site, or copy /sdk/js/agentburn.js into your repository and vendor it like any other file. Both are the same zero-dependency ES module, safe in browsers and in Node 18+.

javascript
// No npm, no bundler: this ES module is served from the site itself.
import { createClient } from '/sdk/js/agentburn.js';

const client = createClient({
  project: 'my-agent',      // slug from /data/projects.json
  batchSize: 20,
  flushInterval: 5000,
  debug: false
});

client.track({
  model: 'claude-sonnet-5-5',
  inputTokens: 1240,
  outputTokens: 380,
  cachedInputTokens: 256,
  toolCalls: 6,
  retries: 0,
  durationMs: 1820,
  success: true,
  task: 'resolve-issue-412'
});

// Flush at the end of a job; the interval and pagehide handlers cover the rest.
await client.flush();

The client queues events in memory, flushes every five seconds, flushes immediately when the queue reaches batchSize, and flushes once more through navigator.sendBeacon on pagehide. Failures back off exponentially (500 ms to 60 s, jittered) and a rejected batch is dropped instead of retried forever, because a malformed event must never block the pipeline.

The client is inert until configured. A slug that fails ^[a-z0-9-]{2,64}$ makes track() a no-op instead of sending garbage to the ingest endpoint. Call setProject() once you have loaded the slug from your own config.

If you prefer a page-level singleton, set window.__AGENTBURN__ before importing the module. The default export configures itself from it and exposes the same track, trackTask, flush and setProject methods.

html
<script>window.__AGENTBURN__ = { project: 'my-agent' };</script>
<script type="module">
  import client from '/sdk/js/agentburn.js';

  client.track({ model: 'gpt-6-1-sol', inputTokens: 640, outputTokens: 180 });
</script>

What the client guarantees

  • Never throws into caller code β€” a broken telemetry path must not break your agent.
  • Redacts prompt, response, messages, content and authorization recursively before queueing, and strips query strings from URLs.
  • Samples before queueing when sampleRate < 1, so unsampled events cost nothing.
  • Drops the oldest event above 500 queued items β€” memory stays bounded in a long-running process.
  • Persists a random clientId (ab_ plus 32 hex characters) in localStorage when available; clearing storage rotates it.
  • client.stats reports queued, sent, dropped, failed and skipped, which is enough to build your own health check.

2. Python quickstart

The Python SDK is a single standard-library file: urllib.request, threading, json, re and datetime. It runs on Python 3.9+ with no virtualenv constraint, which matters when you are embedding it in a worker or a notebook that you do not control.

bash
# One stdlib file β€” download it next to your agent code. Nothing to pip install.
curl -O https://getagentburn.com/sdk/python/agentburn.py
python
import os
from agentburn import Client

# Reads AGENTBURN_PROJECT / AGENTBURN_ENDPOINT / AGENTBURN_API_KEY when set.
client = Client(project="my-agent")

with client.track_task(model="claude-sonnet-5-5", task="resolve-issue-412") as task:
    answer = run_agent(issue_url)
    task.input_tokens = 18200
    task.output_tokens = 4600

client.close()   # or wrap the whole block in: with Client(...) as client:

Configuration resolves in this order: constructor argument, environment variable, default. The background daemon thread flushes every flush_interval seconds, and atexit sends the tail so a short-lived script still reports its last events.

VariableUsed forDefault
AGENTBURN_PROJECTProject slug from /data/projects.jsonnone β€” required
AGENTBURN_ENDPOINTIngest URLhttps://getagentburn.com/api/telemetry
AGENTBURN_API_KEYOptional bearer token for self-hosted or gated ingestunset

A smoke test ships with the file. It starts a local echo server on an ephemeral port, sends two synthetic events through the real queue and background thread, and asserts that prompt content was redacted:

bash
python sdk/python/agentburn.py
# agentburn python sdk 1.0.0 β€” smoke test against http://127.0.0.1:PORT/api/telemetry
# batches received: 1, events: 2, redacted: True
# SMOKE TEST PASSED

3. Instrument a task

Token counts say what a run cost; duration and success say whether it was worth it. trackTask() and track_task() wrap an entire agent run, measure wall-clock time and record a boolean β€” failures included.

javascript
// Promise form: duration and outcome are measured, errors still reach your code.
const result = await client.trackTask(runAgent({ repo, issue }), {
  model: 'claude-sonnet-5-5',
  task: 'resolve-issue-412',
  inputTokens: 18200,
  outputTokens: 4600
});
javascript
// Function form: the same contract, but the call only starts inside the timer.
try {
  await client.trackTask(() => runAgent(), { model: 'gpt-6-1-sol', task: 'monthly-report' });
} catch (error) {
  // Recorded as success: false with a 160-character error. Your retry stays yours.
}

Both forms re-throw the caller error untouched, so your retry and fallback logic stays exactly where it is. The event lands with success: false and a 160-character error string. When token counts are unknown at that point they are sent as 0, so the timing record still arrives and a separate usage wrapper can fill in the numbers.

python
with client.track_task(model="gpt-6-1-sol", task="monthly-report") as task:
    report = build_report()
    task.input_tokens = report.usage["input_tokens"]
    task.output_tokens = report.usage["output_tokens"]
    task.tool_calls = report.tool_calls
    task.retries = report.retry_count

The Python context manager returns False from __exit__, so exceptions propagate: the failed run is recorded and your process still sees the traceback. tool_calls and retries are optional counters that make orchestration overhead visible on the cost board.

4. Wrapping model calls

Most agents already receive token counts in the model API response. Read them from response.usage and pass them to track() β€” never estimate from prompt length. The boards treat reported usage as the only source of truth, and a wrapper is usually ten lines.

javascript
import OpenAI from 'openai';
import { createClient } from '/sdk/js/agentburn.js';

const openai = new OpenAI();
const client = createClient({ project: 'my-agent' });

async function chat(messages, { model = 'gpt-6-1-sol', task = 'chat' } = {}) {
  const started = Date.now();
  const response = await openai.chat.completions.create({ model, messages });
  client.track({
    model,
    task,
    durationMs: Date.now() - started,
    inputTokens: response.usage.prompt_tokens,
    outputTokens: response.usage.completion_tokens,
    cachedInputTokens: response.usage.prompt_tokens_details?.cached_tokens || 0,
    success: true
  });
  return response.choices[0].message;
}
python
import time
from anthropic import Anthropic
from agentburn import Client

llm = Anthropic()
client = Client(project="my-agent")

def ask(prompt, model="claude-sonnet-5-5", task="chat"):
    started = time.time()
    response = llm.messages.create(
        model=model,
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )
    client.track({
        "model": model,
        "task": task,
        "durationMs": int((time.time() - started) * 1000),
        "inputTokens": response.usage.input_tokens,
        "outputTokens": response.usage.output_tokens,
        "cachedInputTokens": getattr(response.usage, "cache_read_input_tokens", 0) or 0,
        "success": True,
    })
    return response.content[0].text

Frameworks hide the response object, so wrap the node instead of the call. The LangGraph wrapper below leaves graph code untouched: it times each node, reads the usage the node returns and lets exceptions propagate so the graph's own error handling keeps working.

python
from agentburn import Client

client = Client(project="my-agent")

def instrumented_node(run_node, model="claude-sonnet-5-5"):
    def node(state):
        with client.track_task(model=model, task=state.get("task", "node")) as task:
            result = run_node(state)
            usage = result.get("usage", {})
            task.input_tokens = usage.get("input_tokens", 0)
            task.output_tokens = usage.get("output_tokens", 0)
            task.tool_calls = usage.get("tool_calls", 0)
        return result
    return node

# Graph code stays unchanged: wrap the nodes that call a model.
graph.add_node("research", instrumented_node(research_node))
graph.add_node("rewrite", instrumented_node(rewrite_node))

For JavaScript model SDKs that use global fetch internally, autoInstrumentFetch can read usage for you. It understands usage.input_tokens / usage.output_tokens / cache_read_input_tokens from Anthropic and usage.prompt_tokens / usage.completion_tokens / prompt_tokens_details.cached_tokens from OpenAI.

javascript
import client, { autoInstrumentFetch } from '/sdk/js/agentburn.js';

// Opt in per call: wrap fetch for the model requests you already make.
const measuredFetch = autoInstrumentFetch(client, { model: 'claude-sonnet-5-5', task: 'chat' });
const response = await measuredFetch('https://api.anthropic.com/v1/messages', requestInit);

// Or patch global fetch once at boot for SDKs that call fetch internally.
autoInstrumentFetch(client, { patchGlobal: true, task: 'chat' });
Recommended pairing. Keep the task event for duration and success, and let the wrapper or autoInstrumentFetch contribute the usage event. Use the same task label on both; the two rows join cleanly in your own dashboards, and the public boards only need the token totals.

5. API reference

Four JSON routes, served by Cloudflare Pages Functions from /functions/api/. All of them answer OPTIONS with Access-Control-Allow-Origin: *, so a browser agent on another origin can post directly. There is no API key requirement for public ingest; the per-IP throttle is the abuse control.

POST /api/telemetry

Ingest a batch of agent events. The SDK does this for you, but the endpoint is plain JSON on purpose: anything that can make an HTTP request can report usage.

http
POST /api/telemetry
Content-Type: application/json

{
  "project": "openhands",
  "source": "sdk",
  "sdkVersion": "1.0.0",
  "clientId": "ab_4f1c0e6c9a2d47b3a1e8c5f7d9b2a6c4",
  "events": [
    {
      "ts": "2026-10-21T10:20:34.000Z",
      "model": "claude-sonnet-5-5",
      "inputTokens": 1920000,
      "outputTokens": 480000,
      "cachedInputTokens": 384000,
      "toolCalls": 48,
      "retries": 3,
      "durationMs": 1842000,
      "success": true,
      "task": "resolve-issue-412"
    }
  ]
}

One event can be a single model call or one aggregated agent run: the sample above is task-level, matching OpenHands' reported footprint of 2.4M tokens per SWE-bench task across 48 tool calls. Send whatever granularity you can measure β€” the boards sum it either way.

A successful ingest returns 202 Accepted with a derived summary you can log. Nothing is estimated: the summary is the arithmetic of the batch you just sent.

json
{
  "ok": true,
  "accepted": 1,
  "project": "openhands",
  "window": "30d",
  "source": "sdk",
  "storedDays": ["20261021"],
  "strippedKeys": 0,
  "summary": {
    "events": 1,
    "tokensIn": 1920000,
    "tokensOut": 480000,
    "tokens": 2400000,
    "cachedInputTokens": 384000,
    "toolCalls": 48,
    "retries": 3,
    "success": 1,
    "fail": 0,
    "models": { "claude-sonnet-5-5": 1 },
    "avgDurationMs": 1842000
  }
}

Limits, validation and retention:

SurfaceRuleOn failure
Request body32 KB maximum413 payload_too_large
Events1 to 100 per request400 invalid_events
Project slug^[a-z0-9-]{2,64}$400 invalid_project
TimestampParseable, at most 7 days old, at most 10 minutes in the future400 invalid_events
Numeric fieldsFinite, 0 to 1e9400 invalid_events
Rate120 requests per IP per hour, batched429 rate_limited with retryAfter
RetentionRaw events 30 days, daily counters 90 daysExpired by KV TTL

Storage layout: one record per event at evt:<project>:<yyyymmdd>:<uuid>, plus one read-modify-write counter per project per UTC day at agg:<project>:<yyyymmdd> holding { events, tokensIn, tokensOut, cached, tools, retries, success, fail }. Aggregates are counters only β€” there is no content in them, which is why they can outlive the raw events.

Privacy is enforced on the server too. Keys named prompt, response, messages or content are stripped recursively, counted in strippedKeys and ignored β€” never a reason to reject a batch. GET /api/telemetry answers 405; there is nothing to read back per event.

GET /api/stats

The public aggregate. It reads only agg: keys from the last 30 UTC days, so its cost is bounded by the number of active project-days, not by traffic volume. ?project= filters to one slug; an unknown slug returns an empty array instead of a 404 so a dashboard can poll safely.

bash
curl -s "https://getagentburn.com/api/stats?project=openhands"
json
{
  "ok": true,
  "generatedAt": "2026-10-21T10:20:34.000Z",
  "window": "30d",
  "projects": [
    {
      "project": "openhands",
      "events": 1240,
      "tokens": 2976000000,
      "lastEventAt": "2026-10-21T00:00:00.000Z"
    }
  ],
  "totals": { "events": 1240, "tokens": 2976000000, "projects": 1 }
}

Token-only by design: the route never invents a dollar figure. Cost per solved task is computed from your published metrics on the project page, not from this feed. The response is cacheable (Cache-Control: public, max-age=60), so poll no faster than once a minute.

POST /api/submit

Community submissions from /submit. Required fields are name, domain, a description of at least 40 characters and a valid email; everything else is optional but improves review speed.

json
{
  "name": "Repo Janitor",
  "domain": "repojanitor.dev",
  "tagline": "Autonomous dependency-upgrade agent",
  "description": "An agent that upgrades dependencies across a monorepo, runs the full test suite and opens a pull request with a migration report for every major bump.",
  "website": "https://repojanitor.dev",
  "repo": "https://github.com/example/repo-janitor",
  "sourceUrl": "https://repojanitor.dev/benchmark",
  "sourceType": "benchmark",
  "evidence": "Public benchmark page with per-task token counts",
  "models": "Claude Sonnet 5.5 + GPT-6.1 Sol",
  "framework": "LangGraph",
  "tokensPerTask": 1100000,
  "costPerTaskUsd": 0.95,
  "successRatePct": 71.5,
  "runsPerDay": 320,
  "email": "[email protected]",
  "sponsorInterest": false,
  "referrer": "https://news.ycombinator.com/"
}
json
{
  "ok": true,
  "id": "sub_m9x2kq_9f2c1a4b7d05",
  "status": "pending-review",
  "receivedAt": "2026-10-21T10:20:34.000Z",
  "next": "We review submissions within 3 business days. Verified entries need a public source URL that states the metrics."
}

Validation rejects a filled company_website honeypot (400 spam_detected), malformed emails, non-http(s) URLs, out-of-range numbers, and bodies over 16 KB. Valid submissions are stored for 90 days under submission:<ts>:<rand>; if SUBMIT_WEBHOOK is bound, a compact notification is posted there on a best-effort five-second timeout.

Submission review API

The admin loop behind the community form. POST /api/submit writes each record to KV at submission:<id> with status pending-review; these two routes list that queue and record a decision. This is not a public API: every request needs Authorization: Bearer <AB_ADMIN_TOKEN>, where the token is the AB_ADMIN_TOKEN environment variable on the Pages project. The reviewer console at /admin is the intended client β€” it keeps the token in sessionStorage for the tab, sends it as a header, is excluded from robots.txt and is not linked from the public navigation.

GET /api/submissions returns submissions newest first, filtered by status and capped by limit (1–200, default 50). The KV listing is paginated internally and capped at 1,000 keys; records that fail JSON.parse are skipped instead of breaking the queue. status=pending matches the stored pending-review value; status=all is the default.

http
GET /api/submissions?status=pending&limit=50
Authorization: Bearer $AB_ADMIN_TOKEN
json
{
  "ok": true,
  "count": 1,
  "submissions": [
    {
      "key": "submission:sub_m9x2kq_9f2c1a4b7d05",
      "id": "sub_m9x2kq_9f2c1a4b7d05",
      "createdAt": "2026-10-21T09:12:00.000Z",
      "status": "pending-review",
      "name": "Repo Janitor",
      "domain": "repojanitor.dev",
      "category": "coding",
      "tagline": "Autonomous dependency-upgrade agent",
      "description": "An agent that upgrades dependencies across a monorepo ...",
      "website": "https://repojanitor.dev",
      "repo": "https://github.com/example/repo-janitor",
      "sourceUrl": "https://repojanitor.dev/benchmark",
      "sourceType": "benchmark",
      "evidence": "Section 4.2 reports 1.1M tokens and $0.95 per upgrade.",
      "models": "Claude Sonnet 5.5 + GPT-6.1 Sol",
      "framework": "LangGraph",
      "tokensPerTask": 1100000,
      "costPerTaskUsd": 0.95,
      "successRatePct": 71.5,
      "runsPerDay": 320,
      "email": "[email protected]",
      "sponsorInterest": false,
      "note": "",
      "reviewedAt": ""
    }
  ]
}

PATCH /api/submissions records a decision. action is approve, reject, publish or unpublish; note is optional (500 characters maximum) and reviewedAt is set server-side. The record is written back with a fresh 90-day TTL, so reviewing a submission never shortens its retention.

http
PATCH /api/submissions
Authorization: Bearer $AB_ADMIN_TOKEN
Content-Type: application/json

{
  "id": "sub_m9x2kq_9f2c1a4b7d05",
  "action": "approve",
  "note": "Numbers match the benchmark table; verified."
}
json
{
  "ok": true,
  "submission": {
    "key": "submission:sub_m9x2kq_9f2c1a4b7d05",
    "id": "sub_m9x2kq_9f2c1a4b7d05",
    "status": "approved",
    "note": "Numbers match the benchmark table; verified.",
    "reviewedAt": "2026-10-21T10:20:34.000Z"
  }
}

Auth and failure semantics. OPTIONS answers 204 with permissive CORS so a browser console on another origin can call the routes with a bearer header; Allow lists what each response permits. Error bodies never include the expected token.

HTTPerrorCause
400invalid_statusstatus is not pending, approved, rejected or all
400invalid_idPATCH id does not match ^sub_[a-z0-9_]+$
400invalid_actionPATCH action is not approve, reject, publish or unpublish
400invalid_notePATCH note is not a string, or is longer than 500 characters
400invalid_jsonPATCH body is not a JSON object
401unauthorizedMissing or wrong bearer token β€” www-authenticate: Bearer is set
404not_foundNo record at submission:<id>; it expired after 90 days or never existed
405method_not_allowedOnly GET, PATCH and OPTIONS exist on this route
503admin_not_configuredAB_ADMIN_TOKEN is not set on the deployment
503storage_not_configuredAB_KV is not bound, so no submission can be read or written

Approved records can be published live from /admin, or exported as a mergeable projects.json stub. The export is deliberately conservative: verified stays false unless the cited source is a benchmark, paper or docs page with a public URL, a missing runs-per-day defaults to 100 with basis: "community-report", and the generic four-step workflow sums to 100% but is a placeholder. metricsNote records the originating submission id. Copying or downloading a stub never publishes it β€” refine the numbers, then merge it into /data/projects.json and run node scripts/validate-data.mjs.

Publishing a submission (live, no redeploy)

PATCH /api/submissions accepts two extra actions. publish converts the record into a schema-valid project, stores it at project:<slug> in KV and marks the submission published: true with a projectSlug. unpublish deletes that key. Published projects are merged into the site at runtime by /api/projects, so a new project page, directory card and leaderboard row appear within about a minute β€” no deployment required.

http
PATCH /api/submissions
Authorization: Bearer $AB_ADMIN_TOKEN
Content-Type: application/json

{ "id": "sub_m9x2kq_9f2c1a4b7d05", "action": "publish" }
json
{
  "ok": true,
  "submission": { "id": "sub_m9x2kq_9f2c1a4b7d05", "status": "approved", "published": true, "projectSlug": "acme-support-agent" },
  "project": { "slug": "acme-support-agent", "domain": "enterprise", "verified": false, "community": true, "...": "schema-valid project" }
}

GET /api/projects

Public feed of published community projects. It never fails without KV: an unbound deployment returns an empty list so the static site keeps working. The curated dataset always wins on slug collisions, and every community record is marked verified: false with usage.basis: "community-report". Responses are cached for 60 seconds at the edge.

json
{
  "ok": true,
  "generatedAt": "2026-10-21T11:58:12.000Z",
  "count": 1,
  "projects": [
    { "slug": "acme-support-agent", "name": "Acme Support Agent", "domain": "enterprise", "community": true, "verified": false }
  ]
}

Webhook configuration

GET|POST|DELETE /api/config (Bearer AB_ADMIN_TOKEN) stores notification webhooks in KV so they can be changed from /admin without a redeploy. Posting { "test": true } sends a test message through every configured webhook and reports the per-target result. Delivery understands Slack (text), Discord (content), Feishu (msg_type) and WeCom (msgtype), retries once on 5xx and logs attempts under notify:* for 30 days.

json
POST /api/config
Authorization: Bearer $AB_ADMIN_TOKEN

{ "submitWebhook": "https://hooks.slack.com/services/T000/B000/xxxx", "leadWebhook": "" }

GET /api/health

The deploy checklist in one request. It works with zero bindings and reports which ones exist, so a fresh Cloudflare Pages deployment can be verified before any KV namespace is created.

json
{
  "ok": true,
  "service": "agentburn-api",
  "version": "1.0.0",
  "storage": "kv",
  "now": "2026-10-21T10:20:34.000Z",
  "endpoints": ["/api/telemetry", "/api/stats", "/api/submit", "/api/health"],
  "limits": {
    "telemetryBodyBytes": 32768,
    "eventsPerRequest": 100,
    "requestsPerIpHour": 120,
    "telemetryRetentionDays": 30
  },
  "bindings": {
    "kv": "configured",
    "submitWebhook": "missing",
    "adminToken": "missing"
  }
}

6. Error codes

Every error is JSON with ok: false, a machine-readable error and a human-readable hint. Field-level problems also include a fields array so a form can highlight the exact input.

HTTPerrorCauseFix
400invalid_jsonBody is not a JSON objectSend Content-Type: application/json and a JSON object
400invalid_projectSlug fails ^[a-z0-9-]{2,64}$Use the lowercase kebab-case slug from your project URL
400invalid_eventsEvent count or field validation failedRead fields and repair the named event
400validation_failedA submission field is invalidRead fields, fix the form
400spam_detectedHoneypot filledSubmit without the hidden field
405method_not_allowedWrong verb for the routeTelemetry and submit are POST, stats and health are GET
413payload_too_largeBody over the route limitBatch fewer events (32 KB / 100 events)
429rate_limitedMore than 120 ingest requests in an hourHonour retryAfter; the SDK backs off automatically
500storage_write_failedKV rejected the writeRetry with backoff; check the namespace quota
503storage_not_configuredNo AB_KV bindingBind KV in the Pages dashboard, or self-host

7. OpenTelemetry bridge

OTLP is the native format for agent frameworks that already emit spans (LangGraph, CrewAI, the OpenAI Agents SDK, LlamaIndex). AgentBurn accepts a plain JSON batch, so the path is explicit: OTLP into an OpenTelemetry Collector, spans to disk, then a small relay you control maps them to POST /api/telemetry with source: "otel".

The collector does the collecting and batching; the relay does the mapping. Keeping the relay in your own infrastructure means span attributes never leave it except as the numeric event fields below.

bash
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
export OTEL_SERVICE_NAME="my-agent"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"

# Used by the relay, not the collector.
export AGENTBURN_PROJECT="my-agent"
export AGENTBURN_ENDPOINT="https://getagentburn.com/api/telemetry"
yaml
# otel-collector.yaml β€” receive OTLP, write JSON-lines spans the relay can read.
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  batch:
    timeout: 5s
    send_batch_size: 512
  resource:
    attributes:
      - key: deployment.environment
        value: production
        action: upsert

exporters:
  file:
    path: /var/log/agentburn/spans.jsonl
    format: json

service:
  telemetry:
    logs:
      level: warn
  pipelines:
    traces:
      receivers: [otlp]
      processors: [resource, batch]
      exporters: [file]
javascript
// relay.mjs β€” map collector JSON-lines spans to AgentBurn events and post them.
import { readFileSync } from 'node:fs';

const endpoint = process.env.AGENTBURN_ENDPOINT || 'https://getagentburn.com/api/telemetry';
const project = process.env.AGENTBURN_PROJECT;
const spanFile = process.env.OTEL_SPAN_FILE || '/var/log/agentburn/spans.jsonl';

function attr(span, key) {
  const hit = (span.attributes || []).find((item) => item.key === key);
  if (!hit || !hit.value) return undefined;
  return hit.value.intValue !== undefined ? Number(hit.value.intValue) : hit.value.stringValue;
}

const events = readFileSync(spanFile, 'utf8')
  .split('\n')
  .filter(Boolean)
  .map((line) => {
    const span = JSON.parse(line);
    return {
      ts: new Date(Number(span.startTimeUnixNano) / 1e6).toISOString(),
      model: attr(span, 'gen_ai.request.model') || 'unknown',
      inputTokens: Number(attr(span, 'gen_ai.usage.input_tokens') || 0),
      outputTokens: Number(attr(span, 'gen_ai.usage.output_tokens') || 0),
      durationMs: Math.round((Number(span.endTimeUnixNano) - Number(span.startTimeUnixNano)) / 1e6),
      success: attr(span, 'otel.status_code') !== 'ERROR',
      task: String(span.name || 'otel-span').slice(0, 80)
    };
  })
  .filter((event) => event.inputTokens + event.outputTokens > 0)
  .slice(0, 100);

if (events.length === 0) {
  console.log('no usage spans found');
} else {
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ project, source: 'otel', events })
  });
  console.log('ingest', response.status, await response.text());
}

The mapping that matters: gen_ai.request.model becomes model, gen_ai.usage.input_tokens and gen_ai.usage.output_tokens become the token counters, span start and end times become ts and durationMs, and otel.status_code becomes success. Spans without usage are skipped, because an event without token counts carries nothing the boards can use.

One batch, one project. A batch always belongs to a single slug; run one relay per project, or set AGENTBURN_PROJECT per pipeline. Events older than seven days are rejected by design, so keep the relay on a short timer (five minutes is plenty).

8. Privacy and data policy

Telemetry is the product, so the boundary has to be explicit. The SDK is built so that the private parts of an agent run cannot be sent even by accident, and the server enforces the same rule a second time.

Collected

  • Project slug, model id and SDK version.
  • Input, output and cached-input token counts.
  • Tool-call count, retry count and wall-clock duration in milliseconds.
  • Success boolean, a task label of at most 80 characters and an error string of at most 160 characters.
  • A random client id and the event timestamp.

Never collected

Prompts, completions, message arrays, retrieved documents, file contents, API keys and authorization headers, end-user emails or identifiers, and IP addresses. The SDK removes prompt, response, messages, content and authorization keys recursively and strips URL query strings before queueing; the ingest endpoint strips the same keys again and reports the count in strippedKeys.

Retention and identifiers

  • Raw events expire after 30 days (KV expirationTtl of 2,592,000 seconds). Daily counters keep totals, never content, for 90 days.
  • clientId is random β€” ab_ plus 32 hex characters β€” and persisted in localStorage when available. It is not derived from a person, an email address or a device fingerprint; clearing storage generates a new one.
  • Aggregation is per project. /api/stats exposes project totals only, never per-user series.
  • Opt out at any time: stop calling track() β€” nothing is sent in the background. Sample with sampleRate, or point endpoint at your own deployment.
  • Deletion: rotate your project slug or email us. Raw events age out in 30 days and counters in 90.

Self-hosting

The API is four Cloudflare Pages Functions and one KV namespace. Fork the repository, bind AB_KV, deploy, and set endpoint to your own origin β€” the SDKs, the docs page and the test suite are the same files you are reading now.

9. Verification and placement policy

Three badges appear across the site. The rule is the same everywhere: a badge describes where a number came from, not how impressive it is.

  • Verified β€” a public URL states the metric (benchmark, paper or docs page) and either telemetry corroborates it within 25% over seven days, or a reviewer opens the source and confirms the numbers. The badge links to the source and the ingestion window.
  • Community β€” submitted by the project's team with a cited source that we have not independently confirmed. Clearly labelled, and never presented as verified.
  • Estimated β€” modelled from public information when no per-task figures are published. Every estimated card carries its metrics note explaining the derivation.

Placement

  • Organic token and cost boards rank on computed values only; payment never reorders them.
  • Sponsored placements (see /pricing) sit in their own badged strip with a Sponsored label. They are visually distinct and excluded from organic rank.
  • Telemetry-backed projects appear with a measured basis; self-reported runs per day stay labelled community-report or estimate in the usage section.

Feed ownership

Public ingest is unauthenticated in v1: a project slug is public, so a third party could in principle post events for a project. Suspicious feeds are excluded during review, and rotating the slug invalidates old callers. If you need authenticated ingest today, self-host the functions and put Cloudflare Access in front of /api/telemetry β€” the SDK only needs the endpoint URL and an optional bearer key.

Corrections and removals

Email [email protected] with the project URL and the number you dispute. We answer within three business days, and a disputed telemetry feed can be paused by rotating the project slug.

10. FAQ

Do I have to share prompts or outputs?
No, and the SDK cannot: prompt, response, messages, content and authorization keys are removed recursively before an event is queued, and the ingest endpoint strips them again as a second layer. Only counts, model ids, durations and short labels are transmitted.
Is it free?
Yes. Both SDKs are dependency-free files served from this site, ingest is free, and no account is needed. Paid sponsorship on /pricing is a separate product for placement and analytics, and sponsored rows are always labelled.
My agent only runs a few times a day β€” is that enough?
There is no minimum. Ten events a week still produce a real token-per-task figure; the Verified badge asks for seven consecutive days of events so the number has a stable window behind it. Low-volume projects keep their place on the boards with the basis shown as telemetry.
Does it work in Node, Deno, Bun and Python 3.9?
The JavaScript module is plain ESM with no Node-only APIs: it runs in Node 18+, Deno, Bun and any modern browser. Outside the browser, pass an absolute endpoint because there is no location.origin to resolve /api/telemetry against. The Python file is standard library only and targets 3.9+.
How fresh are the numbers?
Events are written to KV during the request, so the daily counter moves immediately. /api/stats is cached for 60 seconds, and the public boards are computed in the browser from the published dataset and telemetry aggregate β€” refresh slower than once a minute and you will not miss anything.
What happens if the deployment has no KV namespace?
Nothing breaks. The functions ship with zero bindings: every write endpoint answers 503 storage_not_configured with a hint naming AB_KV, while every static page keeps rendering from /data/*.json. Bind KV in the Pages dashboard and the same endpoints start accepting events without a redeploy.
Can I run my own instance?
Yes. The API is four Pages Functions plus one KV namespace; fork, bind your own AB_KV and deploy. Point endpoint at your origin and the SDK behaves identically. The contract tests in sdk/test/api.test.mjs run against the handlers directly, without wrangler.
Next step

Put real numbers behind your agent

Submit the project first β€” instrumentation can follow. We review within three business days and tell you which fields to instrument so your first board placement is defensible.