Browse resources

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:

wrangler.jsonc
{
  "compatibility_flags": ["nodejs_compat"]
}
src/index.ts
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
OptionDescription
endpointBase URL of your knotel instance.
keyThe project's ingest key.
serviceservice.name shown in knotel.
resourceExtra resource attributes, e.g. { "deployment.environment.name": "production" }.
propagateA function (url: URL) => boolean deciding which outgoing fetches get a traceparent header. Default: all.
ignoreA function (request: Request) => boolean. Matching requests are served without tracing, so no spans are sent. See Filtering noise.

What it records

HandlerRoot spanAttributes
fetchGET /pathServer 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.
scheduledcron 0 9 * * *faas.trigger, faas.cron.
queuequeue my-queueConsumer span with messaging.destination.name and messaging.batch.message_count.
Outgoing fetch()GET api.stripe.com/v1/chargesClient span with method, URL (no query string), server.address and status.
D1SELECT usersdb.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 R2KV get SESSIONSdb.system.name cloudflare-kv / cloudflare-r2, the binding as db.namespace, the operation, the key, and whether it was a hit.
Queue producerssend my-queueProducer span with messaging.system, messaging.destination.name and the batch size.
Durable Objects and service bindingsPOST COUNTER/incrementClient 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's fetch() 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.