Ingestion
Cloudflare Workers
Send a Worker's logs, request spans and exceptions to Bilis — with Cloudflare's own OTLP export, a Sentry-compatible SDK, or both.
Fill this page in with a real API key
The samples below carry a placeholder key and an example host. Log in or create an account to issue one and have them rewritten in place.
Or hand it to a coding agent
Copy this prompt into Claude Code — or any agent that can read a URL — and let it do the setup against the current version of this page.
Set up Bilis — self-hosted log, trace and metric storage — in this project, following its "Cloudflare Workers" guide: https://bilis.app/docs/ingestion/cloudflare-workers.md Bilis endpoint: https://bilis.app API key: bilis_YOUR_API_KEY Read the guide first, then make the changes it describes. Keep the API key in the environment rather than in a file that gets committed, and finish by sending one record and confirming it arrives.
A Worker has no host to install an agent on and no long-lived process to run an OpenTelemetry SDK in, so the usual ways of shipping telemetry do not apply. Two do, and they complement each other:
| Route | What you get | Code change | Plan |
|---|---|---|---|
| Cloudflare OTLP export | console.* output, the runtime's own logs, request spans |
none | Workers Paid |
| Sentry SDK | Exceptions with stack traces, breadcrumbs and request context | a wrapper | any |
Use the export for the everyday picture and add the SDK when you want the stack trace behind an error. The snippets on Projects → your project → Monitor errors are filled in with this instance's address and the project's DSN.
Cloudflare OTLP export
Workers Observability can export traces and logs to any OTLP/HTTP endpoint. It sends the JSON encoding, which Bilis reads natively. Metrics are not exported by Cloudflare yet.
1. Add two destinations
In the Cloudflare dashboard, open Workers & Pages → Observability → Destinations and add one destination per signal:
| Field | Traces | Logs |
|---|---|---|
| Name | bilis-traces |
bilis-logs |
| Type | Traces | Logs |
| OTLP endpoint | https://bilis.example.com/api/v1/traces |
https://bilis.example.com/api/v1/logs |
| Custom header | Authorization = Bearer bilis_YOUR_API_KEY |
same |
The endpoint is the full URL, path included. The header carries a secret API key — not the DSN's public half.
2. Point the Worker at them
// wrangler.jsonc
{
"observability": {
"traces": { "enabled": true, "destinations": ["bilis-traces"], "persist": false },
"logs": { "enabled": true, "destinations": ["bilis-logs"], "persist": false }
}
}
# wrangler.toml
[observability.traces]
enabled = true
destinations = ["bilis-traces"]
persist = false
[observability.logs]
enabled = true
destinations = ["bilis-logs"]
persist = false
Redeploy the Worker. Data takes a few minutes to arrive, and the destination's status in the dashboard turns to Last: n minutes ago once Bilis has accepted a batch. Error there almost always means a wrong endpoint path or key.
persist: false keeps the data out of Cloudflare's own dashboard storage,
which is billed separately; drop it if you want both. head_sampling_rate
(0–1) is accepted on either block when a busy Worker produces more than you
want to keep.
Cost and availability
The export is a Cloudflare feature, not a Bilis one. It needs the Workers Paid plan, is in beta, and from 1 October 2026 it is billed by Cloudflare at 10 million included events a month per signal, then $0.05 per million. Check Cloudflare's page for the current terms.
Sentry SDK
@sentry/cloudflare wraps your handler, catches what it throws and posts it to
the DSN. Bilis accepts that protocol (what is stored),
so the only change from Sentry's own setup is the DSN.
npm install @sentry/cloudflare
// wrangler.jsonc
{
"compatibility_flags": ["nodejs_compat"],
"vars": { "SENTRY_DSN": "https://bilis_pk_YOUR_PUBLIC_KEY@bilis.example.com/1" }
}
// src/index.ts
import * as Sentry from '@sentry/cloudflare';
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 0,
initialScope: { tags: { service: 'checkout-edge' } },
}),
{
async fetch(request, env, ctx) {
return new Response('Hello from the edge');
},
} satisfies ExportedHandler<Env>,
);
Three settings matter here:
nodejs_compatis required by the SDK; it needs acompatibility_dateof2024-09-23or later.tracesSampleRate: 0. Bilis stores the SDK's errors and drops its transactions, so sampling them only costs requests. Take traces from the OTLP export instead. (In a Worker the SDK's own spans also report0ms, because the runtime's clock only advances on I/O.)- The
servicetag. An event carries no service of its own and a Worker reports noserver_nameto fall back on, so without the tag the error lands with an empty service.
The DSN is the public half of a key, so it can sit in vars; a secret works
just as well (wrangler secret put SENTRY_DSN). Durable Objects and Workflows
need their own wrapper — instrumentDurableObjectWithSentry and
instrumentWorkflowWithSentry — given the same options callback. If you build
with Vite, Sentry's Vite plugin does the wrapping for you; the DSN and the two
settings above are all that change.
To check the setup, throw from a route and look for an ERROR line in
Logs, filtered by the service you tagged:
if (new URL(request.url).pathname === '/debug-bilis') {
throw new Error('Bilis test error');
}
Both at once
The two routes do not share ids: the SDK starts its own trace, so an SDK error does not link to the span Cloudflare exported for the same request. Search by time and service to put them side by side — or, if the stack trace is not essential, rely on the export alone, whose logs already include the runtime's record of an uncaught exception.