Telemetry API
Anonymous usage telemetry for CLIs, desktop apps, plugins, web apps and servers. Free, self-hostable, runs on Cloudflare Workers. Raw events live 90 days in Workers Analytics Engine. Only daily aggregates are kept long term.
Base URL: https://telemetry.anyr.workers.dev
Quickstart
- Pick a slug and send the first event with curl. A new slug is created by its first valid event. There is no signup step.
curl -X POST https://telemetry.anyr.workers.dev/my-tool \
-H 'content-type: application/json' \
-d '{"event":"ping","id":"3f2c9a7e-1d4b-4c8e-9a52-7b1e0d6f4a21","version":"1.0.0","os":"linux","arch":"x64"}'
# {"accepted":1,"dropped":0,"project":{"slug":"my-tool","created":true,"admin_token":"tk_...","page_url":"https://telemetry.anyr.workers.dev/my-tool"}}
Save
admin_tokenfrom that response. It is shown once. You need it to rename the project, rotate the token, import or delete.Send more events the same way. Later responses are
{"accepted":1,"dropped":0}, with no token.See it. Open
https://telemetry.anyr.workers.dev/my-tool, or read the JSON:
curl https://telemetry.anyr.workers.dev/api/my-tool/stats
Today's numbers come from a live query cached for about 5 minutes. Earlier days come from the daily rollup.
Sending events
POST https://telemetry.anyr.workers.dev/<project>
The body is one event object, or an array of at most 25 events. Content type application/json. Max body size is 16 KB.
| Field | Type | Required | Rules |
|---|---|---|---|
event |
string | yes | Matches ^[A-Za-z0-9_.:/-]{1,64}$. |
id |
string | no | A random install id that the client creates and keeps, at most 128 chars. The server hashes it with a secret salt and never stores it raw. |
version |
string | no | The app version. Letters, digits, ., +, -, _, at most 32 chars. |
os |
string | no | Free-form lowercase, at most 16 chars of [a-z0-9_-]. For example linux, darwin, windows. Send what the runtime reports. |
arch |
string | no | Same rules as os. x64, x86_64, arm64 and aarch64 are all fine. Do not remap them. |
ci |
boolean | no | True when the event came from a CI run. |
props |
object | no | At most 8 entries. Keys match [a-z0-9_.-]{1,32}. Values are string, number or boolean, and are cut to 64 chars. |
n |
integer | no | Occurrence count, 1 to 1000. Default 1. |
Rules the server applies:
- The country comes from Cloudflare. The client cannot set it.
- The IP address and user agent are never stored.
- Any event with an
idcounts that id as an active user for that UTC day.installandpingare not special. Days are UTC. - Without
id, the server uses a hash that rotates every day. That counts daily users (DAU) only. Weekly and monthly users need a stableid. - A bad optional field (for example an
oswith spaces) is ignored, and the event is still counted. Only a missing or invalideventdrops the event. - Invalid events in a batch are dropped. The valid ones are kept.
- Identical events in one request are merged into one data point with a summed
n.acceptedcounts data points after merging,droppedcounts invalid events.
Responses:
| Status | Meaning |
|---|---|
| 202 | Accepted. Body is {"accepted": <number>, "dropped": <number>}. When this request created the project, the body also has project and the token is in the x-telemetry-admin-token header. See Projects below. |
| 400 | Body is not valid JSON, is not an object or array, has more than 25 events, or has no valid events. |
| 404 | Invalid or reserved slug. |
| 413 | Body is larger than 16 KB. |
| 429 | Per-IP rate limit (with a Retry-After header in seconds), the project's daily cap, or the new project limit. Do not retry in a loop. |
CORS is open (access-control-allow-origin: *), so browsers can send events directly.
Batch example:
curl -X POST https://telemetry.anyr.workers.dev/my-tool \
-H 'content-type: application/json' \
-d '[{"event":"run","id":"3f2c9a7e","props":{"command":"build"}},{"event":"run","id":"3f2c9a7e","props":{"command":"test"},"n":3}]'
OTLP
Any OpenTelemetry SDK or collector can send to the same project. Only OTLP/HTTP with JSON is supported.
export OTEL_EXPORTER_OTLP_ENDPOINT=https://telemetry.anyr.workers.dev/my-tool
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
SDKs append /v1/logs, /v1/traces and /v1/metrics to the endpoint. You can also call them directly:
POST https://telemetry.anyr.workers.dev/<project>/v1/{logs,traces,metrics}
- Protobuf bodies (any content type other than
application/json) get 415. SetOTEL_EXPORTER_OTLP_PROTOCOL=http/json. - gzip bodies (
content-encoding: gzip) are accepted. Other encodings get 415. A body over 256 KB, or over 1 MB after unzip, gets 413. - At most 250 points per request. The success response is
{}, or{"partialSuccess": {...}}when records were rejected (invalid event name, empty metric, or over the 250 limit). - The first export with at least one valid record creates a new project. The admin token is only in the
x-telemetry-admin-tokenresponse header, and exporters drop it. Create the project with one curl first (see Quickstart), then point the exporter at it. An export with no valid records to an unknown slug gets 400 and creates nothing.
How OTLP data maps to an event:
| Event field | Source |
|---|---|
event |
Logs: the event.name attribute, else the log body when it is a valid event name, else log. Traces: the span name. Metrics: the metric name. Names must match the native event rule. |
n |
Metrics only: the sum of a delta sum, the count of a histogram, or the number of data points for a gauge or cumulative sum. Logs and spans count 1. |
id |
Attribute service.instance.id, else host.id, else user.id. |
version |
service.version |
os |
os.type |
arch |
host.arch |
props |
Other record attributes, with the same limits as native props. Attributes that can identify a person or host are dropped (user.*, host.*, client.*, url.*, exception.*, and keys containing email, token, path, session, and similar). |
Set the install id as a resource attribute:
export OTEL_RESOURCE_ATTRIBUTES=service.instance.id=<random-install-id>,service.version=1.0.0
Client guidelines
Follow these in every client. They are what makes telemetry safe to ship.
- A variable counts as set when it has any value except empty,
0orfalse. This applies toDO_NOT_TRACK,CIand the<APP>_TELEMETRY_*flags. - Honor
DO_NOT_TRACKand<APP>_TELEMETRY_DISABLED. Send nothing when either is set. If a config file holds an opt-out and it cannot be read, send nothing. - Skip sending in CI unless the user opts in with
<APP>_TELEMETRY_CI=1. When opted in, sendci: true. <APP>_TELEMETRY_DEBUG=1prints the payload and does not send it. It has no side effects: no id file, no daily stamp, no notice.- Create a random id once and keep it at
~/.config/<app>/telemetry-id, or the platform equivalent. Never derive it from the machine, user, hostname or path. Prefer the tool's own env prefix and config directory when it has one. - Fail closed. If the id or the daily stamp cannot be saved, send nothing. Otherwise a daemon would send on every tick.
- Send fire-and-forget with a timeout of 1 second or less. Never block the app, never crash it, never retry in a loop.
- Send
installonce on first run. Then send at most onepingper UTC day, plus meaningful events such as a command run. Use the UTC date for the daily gate. - Show a one-line notice on first run, in the app's user-visible channel: stderr for a CLI, the log or notify channel for a daemon or plugin, the UI for a desktop app. It says what is sent and how to turn it off.
- Send no personal data. No file paths, hostnames, emails, usernames, arguments values or error messages.
Ready-made snippets for curl, shell, TypeScript, Rust, Go, Python and the browser are on each project page, and in https://telemetry.anyr.workers.dev/<project>/install.md.
Projects
A project is created by its first valid event, native or OTLP. There is no signup step.
- The slug matches
^[a-z0-9][a-z0-9-]{1,38}$and is not a reserved word (api,docs,fonts,health,llms.txt, and similar). Other slugs get 404. - A request with no valid events never creates a project.
- At most 3 new projects per IP and 100 in total per UTC day. Over either cap, the request gets 429
{"error": "new project limit reached for today"}and nothing is stored. Existing projects are not affected. - The response that creates the project carries the admin token, once: in the native body as
project.admin_token, and in thex-telemetry-admin-tokenheader for native and OTLP. Only its hash is stored. It cannot be shown again. - Save the token from your first curl. If the first event comes from a client that ignores responses, such as a shipped app or an OTLP exporter, the token is lost.
- When two requests create the same slug at the same moment, one gets the token and the other is accepted without it.
List the public projects:
curl https://telemetry.anyr.workers.dev/api/projects
The response is {"projects": [...]}. It lists only projects with daily rollups in the last 30 days. Each entry has slug, name, description, homepage, created_at, series (daily users for the last 30 closed UTC days, oldest first) and dau (yesterday's daily users). A new project shows up after its first nightly rollup at 00:10 UTC. Its page at https://telemetry.anyr.workers.dev/<project> works right away.
Manage a project
Every change needs the project's admin token in Authorization: Bearer <admin_token>. A missing or wrong token gets 401. An unknown project gets 404.
Set the display name, description and homepage: PATCH https://telemetry.anyr.workers.dev/api/<project>
curl -X PATCH https://telemetry.anyr.workers.dev/api/my-tool \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"name":"My Tool","description":"A CLI that does one thing","homepage":"https://example.com"}'
# {"project":{"slug":"my-tool","name":"My Tool","description":"A CLI that does one thing","homepage":"https://example.com","created_at":"..."}}
| Field | Rules |
|---|---|
name |
At most 60 chars. |
description |
At most 200 chars. |
homepage |
An http(s) URL, at most 200 chars. |
A field you leave out stays as it is. null or "" clears it. A bad field gets 400 and changes nothing.
The slug cannot change. Raw events in Analytics Engine are stored under it. Only the display name can change. To move to a new slug, send events to the new slug.
Rotate the admin token, for example after a leak. The old token stops working at once:
curl -X POST https://telemetry.anyr.workers.dev/api/my-tool/token -H "Authorization: Bearer $ADMIN_TOKEN"
# {"admin_token":"tk_..."}
Delete a project and its rollups:
curl -X DELETE https://telemetry.anyr.workers.dev/api/my-tool -H "Authorization: Bearer $ADMIN_TOKEN"
Raw events in Analytics Engine cannot be deleted. They expire after 90 days.
Lost token: there is no self-service reset, because the token is the only proof of ownership. The operator of the deployment can set a new one: bun scripts/reset-token.ts <project> prints a new token and the D1 command that stores its hash.
Stats API
GET https://telemetry.anyr.workers.dev/api/<project>/stats?days=7|30|90|365
Public, no auth. Any other days value falls back to 30. Response:
{
"project": "my-tool",
"days": 30,
"generated_at": "2026-10-13T12:00:00.000Z",
"summary": {
"dau": 42, "wau": 180, "mau": 510, "events": 9120,
"today": { "users": 17, "events": 301 }
},
"series": [{ "day": "2026-10-12", "dau": 42, "events": 310 }],
"dims": {
"event": { "ping": [510, 4100], "run": [220, 5020] },
"version": { "1.2.0": [400, 7000] },
"prop:command": { "build": [90, 2000] }
}
}
summary.dau,wau,mauare for the latest closed day.summary.eventsis summed over the range.summary.todayis live from Analytics Engine and cached for about 5 minutes.serieshas one entry per day, with zeros for days without data.dimsmaps a dimension name to values, each as[users, events], top 20 per dimension by events. Dimension names areevent,version,os,arch,country,ci,sourceandprop:<key>. Users are summed per day, so they are user-days, not unique users.
Rollups run daily at 00:10 UTC. To rebuild one day from live data (admin only, the last 89 days):
curl -X POST "https://telemetry.anyr.workers.dev/api/my-tool/rollup?day=2026-10-12" -H "Authorization: Bearer $ADMIN_TOKEN"
Import and backfill
Use this to bring history from another system. Compute the daily aggregates locally, then upload them. See scripts/import.ts in the repo, which turns raw NDJSON rows into exact rollups.
POST https://telemetry.anyr.workers.dev/api/<project>/import with Authorization: Bearer <admin_token>
{
"rows": [
{
"day": "2026-07-01", "dau": 12, "wau": 40, "mau": 90, "events": 150,
"dims": {
"event": { "run": [10, 120], "ping": [12, 30] },
"version": { "1.0.0": [12, 150] },
"prop:command": { "build": [6, 50] }
}
}
]
}
- At most 360 rows per request, one row per day, no future days.
dimsvalues are[users, events]. Dims are cut to the top 20 per dimension. The dims JSON is at most 16 KB.- Import never overwrites a day that was rolled up from live data. Importing the same day again replaces the earlier import.
- Response:
{"imported": <rows written>, "skipped": <rows kept as they were>}.
Privacy
- The
idis hashed with a secret salt and the project name (sha256, first 16 hex chars). The raw id is never written anywhere. - IP address and user agent are never stored. Without
id, they are mixed into a hash that rotates every day and only counts daily users. - The country comes from Cloudflare's edge, as a 2-letter code.
- Hashed ids exist only in Analytics Engine and are deleted after 90 days. The database holds only daily counts.
- Stats pages and the stats API are public. Do not send anything you would not publish as an aggregate.
Limits and free tier
| Limit | Value |
|---|---|
| Body size | 16 KB |
| Events per request | 25 (native), 250 points (OTLP) |
| Rate limit | 60 requests per minute per IP (best effort, per Worker instance) |
| Daily cap | 20,000 data points per project per UTC day, then 429 |
| New projects | 3 per IP and 100 in total per UTC day, then 429 |
| Raw event retention | 90 days |
| Rollups | One row per active project per day, kept long term |
The service is meant to run inside the Cloudflare free plan. Workers allows 100,000 requests per day and Analytics Engine allows 100,000 data points per day, both per account. Sending events to an existing project never writes to D1. The first event of a new project writes one row.
Self-hosting
git clone https://github.com/duyet/telemetry && cd telemetry && bun install && bunx cf deploy
- Enable Analytics Engine once per account: Cloudflare dashboard, Workers & Pages, Analytics Engine.
- There are no secrets and no environment variables. The Worker creates its own D1 schema on first request, and generates its own hashing salt.
cf deploycreates the D1 database, the Analytics Engine dataset and the daily cron.- Source: https://github.com/duyet/telemetry