Browse resources

Run your instance

Configuration

Bindings, secrets, retention, custom domains and updating.

Everything is configured in wrangler.jsonc, a deploy target, Worker secrets and a few Cloudflare resources that hold the spans.

Deploy targets

wrangler.jsonc only names things wrangler deploy creates by itself in any account: the two D1 databases, the KV namespace and the cron. It holds nothing that belongs to one account, so a fresh clone deploys as it is, on the Workers Free plan too.

What does belong to one account goes in a deploy target, targets/<name>.json: the account_id, the span store's bindings, a custom domain. Set KNOTEL_TARGET and the file is merged over wrangler.jsonc when the Worker is built (objects merge, arrays are appended). Git ignores every target except targets/example.json, so pulling updates never conflicts with yours, and one clone can deploy to several accounts.

cp targets/example.json targets/prod.json   # then fill it in
KNOTEL_TARGET=prod bun run deploy

The same variable works for bun run dev, which otherwise runs without the span store.

Setting up the span store

Spans are written twice: to D1, which answers the last HOT_WINDOW_DAYS instantly, and to a Cloudflare Pipelines stream, which writes them into an Iceberg table on R2 once a minute. Anything older than the hot window is read back from that table with R2 SQL.

The span store is optional. Without it everything works from D1 alone and HOT_WINDOW_DAYS is your retention: ranges that reach further back show what D1 still has, and the range picker strikes them through. It needs the Workers Paid plan (Pipelines) and a payment method on the account (R2), which is why it isn't part of the default deploy.

Run every command below from the repository root. With more than one Cloudflare account, prefix them with CLOUDFLARE_ACCOUNT_ID=your-account-id (or export it once), or wrangler stops with More than one account available.

1. Create the API token

One token does two jobs: the Pipelines sink uses it to write to the Iceberg catalog, and the Worker uses it to query R2 SQL, which has no Worker binding.

  1. In the Cloudflare dashboard, switch to the account knotel deploys to and open R2 Object Storage. Or go straight to https://dash.cloudflare.com/YOUR_ACCOUNT_ID/r2/api-tokens.
  2. Click Manage API tokens (on the right of the R2 overview, sometimes under an API menu), then Create Account API token. An account token keeps working if the person who made it leaves.
  3. Name it, for example knotel. Under Permissions choose Admin Read & Write: it is the only preset that includes the Data Catalog and R2 SQL. Object Read & Write is not enough.
  4. Set TTL to Forever. The sink keeps using the token for every write; when it expires, history silently stops growing.
  5. Click Create API Token and copy the Token value, the first of the three values shown (not the Access Key ID or Secret Access Key). It is shown once.

Keep it in a file only you can read, so it stays out of shell history and the repository:

printf %s 'PASTE_TOKEN_VALUE' > ~/.knotel-token && chmod 600 ~/.knotel-token

A custom API token (My Profile → API Tokens) works too, with Workers R2 SQL read, Workers R2 Data Catalog edit and Workers R2 Storage edit on the account.

2. Check the token before using it

ACCOUNT=your-account-id
TOKEN=$(cat ~/.knotel-token)
curl -s -X POST "https://api.sql.cloudflarestorage.com/api/v1/accounts/$ACCOUNT/r2-sql/query/knotel-spans" \
  -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"query":"SHOW DATABASES"}'

"success":true means the token can reach R2 SQL. An Unauthenticated. means the wrong value was copied or the permissions are too narrow. Before the bucket exists this reports warehouse does not exist, which is fine; run it again after step 3.

3. Create the bucket, stream, sink and pipeline

# The bucket, with its Iceberg catalog enabled
bunx wrangler r2 bucket create knotel-spans
bunx wrangler r2 bucket catalog enable knotel-spans

# The stream the Worker writes to. Its schema must match what the Worker sends:
# pipelines/spans.schema.json, which mirrors COLUMNS in src/ingest/store.ts
bunx wrangler pipelines streams create knotel_spans \
  --schema-file pipelines/spans.schema.json --http-enabled false

# The sink: Parquet files into the knotel.spans table, once a minute
bunx wrangler pipelines sinks create knotel_spans_sink \
  --type r2-data-catalog --bucket knotel-spans \
  --namespace knotel --table spans --roll-interval 60 \
  --catalog-token "$(cat ~/.knotel-token)"

# Connect them
bunx wrangler pipelines create knotel_spans_pipeline \
  --sql "INSERT INTO knotel_spans_sink SELECT * FROM knotel_spans"

The sink creates the table itself from the stream's schema on its first write. Don't create it by hand.

4. Store the token for the Worker

# Find your store id (every account has a default store)
bunx wrangler secrets-store store list --remote

# Prompts for the value: paste the same token
bunx wrangler secrets-store secret create YOUR_STORE_ID \
  --name knotel-r2-token --scopes workers --remote

5. Put them in a deploy target

Copy targets/example.json to targets/prod.json and fill it in. The stream id comes from bunx wrangler pipelines streams list.

targets/prod.json
{
  "account_id": "YOUR_ACCOUNT_ID",
  "pipelines": [{ "binding": "SPANS_STREAM", "stream": "STREAM_ID" }],
  "secrets_store_secrets": [
    { "binding": "R2_SQL_TOKEN", "store_id": "YOUR_STORE_ID", "secret_name": "knotel-r2-token" }
  ],
  "vars": {
    "R2_SQL_WAREHOUSE": "knotel-spans",
    "R2_SQL_ACCOUNT_ID": "YOUR_ACCOUNT_ID"
  }
}

Then KNOTEL_TARGET=prod bun run deploy. From now on deploy with the target every time: a deploy without it removes the bindings, and spans stop reaching the table until the next one.

6. Verify spans reach the table

Send a span (or wait for real traffic), give the pipeline two to five minutes — a brand-new pipeline takes longer than the one-minute roll interval to write its first file — then count rows:

curl -s -X POST "https://api.sql.cloudflarestorage.com/api/v1/accounts/$ACCOUNT/r2-sql/query/knotel-spans" \
  -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
  -d '{"query":"SELECT COUNT(*) AS n, MAX(start_us) AS latest_us FROM knotel.spans"}'

A growing n means the span store works end to end. If it stays at 0 for ten minutes, see History stays empty.

Changing the span schema

A Pipelines stream validates every record against the schema it was created with, and wrangler has no command to change it. So when an update adds a column (the commit touches pipelines/spans.schema.json), the stream, sink, pipeline and Iceberg table are recreated before the new Worker deploys. Deploying first sends records the old stream rejects, and long-range queries ask the old table for columns it doesn't have.

What this costs
The recreated table starts empty: history from before the change is dropped. D1 is untouched, so the last HOT_WINDOW_DAYS stay fully visible, and history rebuilds from the moment of the cutover. Spans arriving in the few minutes between steps 2 and 4 reach D1 but not the table.

The new stream needs a different name while the old one exists, so streams carry a version suffix; the sink, pipeline and table keep their names.

export CLOUDFLARE_ACCOUNT_ID=your-account-id
TOKEN=$(cat ~/.knotel-token)

# 1. A new stream with the new schema (the old one keeps accepting spans meanwhile)
bunx wrangler pipelines streams create knotel_spans_v4 \
  --schema-file pipelines/spans.schema.json --http-enabled false
bunx wrangler pipelines streams list        # note the new stream's id

# 2. Remove the old pipeline and sink, then drop the old table
bunx wrangler pipelines delete knotel_spans_pipeline --force
bunx wrangler pipelines sinks delete knotel_spans_sink --force

bunx wrangler r2 bucket catalog get knotel-spans   # prints the Catalog URI and Warehouse
CATALOG=https://catalog.cloudflarestorage.com/$CLOUDFLARE_ACCOUNT_ID/knotel-spans
PREFIX=$(curl -s "$CATALOG/v1/config?warehouse=${CLOUDFLARE_ACCOUNT_ID}_knotel-spans" \
  -H "authorization: Bearer $TOKEN" | python3 -c 'import json,sys; print(json.load(sys.stdin)["overrides"]["prefix"])')
curl -s -X DELETE "$CATALOG/v1/$PREFIX/namespaces/knotel/tables/spans?purgeRequested=true" \
  -H "authorization: Bearer $TOKEN" -w "%{http_code}\n"      # 204 = dropped
curl -s "$CATALOG/v1/$PREFIX/namespaces/knotel/tables" -H "authorization: Bearer $TOKEN"
# → {"identifiers":[]}

# 3. Recreate the sink and pipeline on the new stream
bunx wrangler pipelines sinks create knotel_spans_sink \
  --type r2-data-catalog --bucket knotel-spans \
  --namespace knotel --table spans --roll-interval 60 \
  --catalog-token "$TOKEN"
bunx wrangler pipelines create knotel_spans_pipeline \
  --sql "INSERT INTO knotel_spans_sink SELECT * FROM knotel_spans_v4"

# 4. Put the new stream id in your deploy target ("pipelines" → "stream"), then deploy
KNOTEL_TARGET=prod bun run deploy

# 5. Once spans reach the new table (see "Verify" above), remove the old stream
bunx wrangler pipelines streams delete knotel_spans_v3 --force

After the deploy, confirm three things:

  • D1 migrated: the newest name in bunx wrangler d1 execute DB --remote --command "SELECT name FROM _knotel_migrations ORDER BY name DESC LIMIT 1" matches the newest file in drizzle/. Migrations run on the first request, so open the site once first.
  • The table fills: the row count from Verify grows, and a new column reads back, e.g. SELECT COUNT(error_group) FROM knotel.spans.
  • A 30-day range loads on the project overview. It is answered from the new table, so an error here means the Worker and table disagree on columns.

The next change repeats this with knotel_spans_v5, deleting knotel_spans_v4 at the end.

Bindings

BindingTypePurpose
SPANS_STREAMPipelines streamEvery span, on its way to the Iceberg table. Optional, from the deploy target.
R2_SQL_TOKENSecrets Store secretAPI token used to query R2 SQL. Optional, from the deploy target.
DBD1 database knotelAccounts, projects, keys, usage, and the hot cache of recent spans. Created on first deploy.
CACHEKV namespaceEdge cache for key and token lookups and dashboard queries. Created on first deploy.

Variables and secrets

NameKindDefaultDescription
HOT_WINDOW_DAYSVariable7How many days of spans D1 keeps for fast queries. Older ranges are read from R2 SQL.
R2_SQL_WAREHOUSEVariableknotel-spansThe R2 bucket holding the Iceberg table.
R2_SQL_ACCOUNT_IDVariableYour Cloudflare account id, used in the R2 SQL endpoint.
SETUP_TOKENSecretOne-time code in the logsThe code that claims a new instance.
BETTER_AUTH_SECRETSecretGenerated and stored in D1Signs sessions. Changing it signs everyone out.
bunx wrangler secret put SETUP_TOKEN
bunx wrangler secret put BETTER_AUTH_SECRET

Retention

A cron trigger (17 3 * * *, daily at 03:17 UTC) trims D1 back to HOT_WINDOW_DAYS for spans and LOG_HOT_WINDOW_DAYS for logs. Without the span store that is the end of those spans, so set the window to the history you want (D1 holds 10 GB, 5 GB on the Free plan). With it nothing is lost: every span is also in the Iceberg table, which keeps the full history — until a span schema change recreates it. A longer hot window means more instant range options and a larger D1 database; a shorter one means more queries go to R2 SQL and take seconds.

Per project

Those two vars are the instance's defaults. Any project can keep less or more than them from Settings → Projects, where its retention is a menu next to the collection switch. Pick a number when a project doesn't need history — a staging environment, a noisy internal service — or when one needs more than the others.

A project's own number changes three things:

  • D1 prunes that project to its own window each night, at its own cost: deleting a row is billed like writing one, which is why the shorter window is a choice and not the default.
  • The span store is written only for a project keeping more than HOT_WINDOW_DAYS. One keeping a day or a week is never streamed to R2 at all, so it costs no Pipelines volume and no R2 storage; turning its retention down stops the writes, and nothing already written is removed.
  • Queries are trimmed to what's left: a 30-day chart on a project keeping 7 days asks for 7. It stays in D1 and answers instantly instead of scanning a range that was deleted, and the range picker strikes the ranges through.

Logs never outlive LOG_HOT_WINDOW_DAYS, whatever a project asks for: they have no cold store to move to, they outnumber spans several times over, and LOGS_DB is shared. Under that ceiling a project's own number wins, and its lines are deleted from the day tables that outlive it.

History in R2 is never deleted automatically, because R2 SQL has no DELETE — retention past the hot window is enforced on the way in (a project that keeps little is never written) and on the way out (queries stop at the horizon), not by deleting Parquet. To drop what is already there you remove it through an Iceberg-compatible client; R2 storage costs $0.015 per GB-month in the meantime.

Custom domain

Add a custom domain for a zone in your account and redeploy. Sign-in works on whichever origin a request arrives at, so the workers.dev URL, your domain and localhost all work without extra settings. Update the endpoint in your snippets afterwards.

targets/prod.json
"routes": [{ "pattern": "trace.example.com", "custom_domain": true }]

Updating

git pull
# Did the span schema change? Any output here means yes.
git diff ORIG_HEAD HEAD -- pipelines/spans.schema.json
bun install
bun run deploy

If the schema changed, follow Changing the span schema instead of deploying straight away. Otherwise the deploy is all there is.

New database migrations run automatically on the first request after a deploy. The browser script is rebuilt with each deploy; SDK files copied into your Workers only change when you copy them again.