Send data
Cloudflare Workers
Trace requests, crons, queues, fetches and database calls in a Worker.
The Workers SDK traces a Worker with one wrapper. Every request, cron run and queue batch becomes a trace, outgoing fetch() calls become child spans, and spans are sent after the response with ctx.waitUntil, so they don't slow users down.
Install
The SDK is two dependency-free files. Copy sdk/core.ts and sdk/worker.ts from the knotel repository into your Worker, for example into src/knotel/. It uses AsyncLocalStorage, so turn on Node.js compatibility:
{
"compatibility_flags": ["nodejs_compat"]
}import { instrument } from "./knotel/worker";
export default instrument(
{
async fetch(request, env, ctx) {
return new Response("ok");
},
},
(env) => ({
endpoint: "https://YOUR-INSTANCE",
key: env.KNOTEL_KEY,
service: "api",
}),
);bunx wrangler secret put KNOTEL_KEY
| Option | Description |
|---|---|
endpoint | Base URL of your knotel instance. |
key | The project's ingest key. |
service | service.name shown in knotel. |
resource | Extra resource attributes, e.g. { "deployment.environment.name": "production" }. |
propagate | A function (url: URL) => boolean deciding which outgoing fetches get a traceparent header. Default: all. |
ignore | A function (request: Request) => boolean. Matching requests are served without tracing, so no spans are sent. See Filtering noise. |
What it records
| Handler | Root span | Attributes |
|---|---|---|
fetch | GET /path | Server span joined to the caller's trace via traceparent. http.request.method, url.full, url.path, server.address, user_agent.original, cloudflare.colo, http.response.status_code. 5xx responses and throws are errors. |
scheduled | cron 0 9 * * * | faas.trigger, faas.cron. |
queue | queue my-queue | Consumer span with messaging.destination.name and messaging.batch.message_count. |
| Outgoing fetch() | GET api.stripe.com/v1/charges | Client span with method, URL (no query string), server.address and status. |
| D1 | SELECT users | db.system.name cloudflare-d1, the SQL as db.query.text, the binding as db.namespace, the operation and table, and rows read/written from the result. |
| KV and R2 | KV get SESSIONS | db.system.name cloudflare-kv / cloudflare-r2, the binding as db.namespace, the operation, the key, and whether it was a hit. |
| Queue producers | send my-queue | Producer span with messaging.system, messaging.destination.name and the batch size. |
| Durable Objects and service bindings | POST COUNTER/increment | Client span carrying traceparent, so the callee's spans join the trace. |
Binding calls are traced by wrapping the env your handler receives, so no call site changes. Anything else on env — vars, secrets, Vectorize, Workers AI — is handed to your code untouched.
Database and custom spans
D1, KV and R2 are traced for you. Other database drivers don't go through fetch() or a binding, so wrap the calls you want to see with trace(). Use the OpenTelemetry database attributes: they power database nodes on the service map and let you group by operation or collection.
import { trace } from "./knotel/worker";
const user = await trace(
"mongodb findOne users",
{ "db.system.name": "mongodb", "db.operation.name": "findOne", "db.collection.name": "users" },
() => users.findOne({ id }),
);
const orders = await trace(
"postgresql SELECT orders",
{
"db.system.name": "postgresql",
"db.operation.name": "SELECT",
"db.collection.name": "orders",
"db.query.text": "select * from orders where user_id = $1",
},
() => sql`select * from orders where user_id = ${id}`,
);
const cached = await trace(
"redis GET",
{ "db.system.name": "redis", "db.operation.name": "GET" },
() => redis.get(`session:${id}`),
);trace(name, attributes, fn) makes a child of whatever span is active, records thrown errors, and returns fn's result. Outside an instrumented handler it just runs fn.
Name requests by route
Root spans are named after the path, so /users/42 and /users/43 are different names. Set the route from your router to group them:
import { currentSpan } from "./knotel/worker";
const span = currentSpan();
if (span) {
span.name = `${request.method} /users/:id`;
span.setAttribute("http.route", "/users/:id");
}Caveats
- Workers only advance the clock during I/O, so a span around purely CPU-bound work shows about 0 ms.
- A Durable Object's own code isn't instrumented. Calls into it are traced from the caller's side, and carry
traceparent, so instrumenting the class itself joins the same trace. Only a stub'sfetch()is traced, not its RPC methods. - A queue producer span names the binding, not the queue behind it: a producer binding doesn't expose the queue's name.