Skip to content

Cloudflare

Durable Objects for reliability and Analytics Engine for the audit warehouse — how they fit together.

Updated View as Markdown

Logbun on Workers has two separate pieces. Keep them straight:

  • Reliability — CloudflareReliabilityAdapter (logbun/durability/cloudflare, ESM-only). Lives inside a Durable Object. Owns the SQLite journal and DLQ ({prefix}_journal, {prefix}_dlq), recovers orphans, and schedules alarm() for retries and retention. It does not store queryable audit rows and does not use D1, R2, KV, or Node fs.
  • Destination — CloudflareAnalyticsEngineAdapter (logbun/adapters/cloudflare-analytics-engine, ESM + CJS). Lives wherever you construct it (usually inside the same DO so the AE binding is in scope). Owns the Analytics Engine dataset via writeDataPoint and queries it via the SQL API. It does not provide a journal — without the DO adapter, fire() is volatile.

For durable Workers you pair them: the DO is the durable admission log, AE is the queryable warehouse. Workers call the DO; the DO appends to its journal and writes to AE.

AE is an analytics sink, not a compliance store. Ingest is asynchronous and may be sampled, retention is ~3 months, and writeDataPoint is fire-and-forget. The DO journal is what survives process death.

Wiring them together

The Durable Object owns both. Env carries the AE binding into the DO:

audit-do.tsts
import { AuditLogger } from 'logbun';
import { CloudflareReliabilityAdapter } from 'logbun/durability/cloudflare';
import {
  CloudflareAnalyticsEngineAdapter,
  type AnalyticsEngineDatasetLike,
} from 'logbun/adapters/cloudflare-analytics-engine';

interface Env {
  AUDIT_AE: AnalyticsEngineDatasetLike; // analytics_engine_datasets binding
  ACCOUNT_ID?: string; // optional — enables AuditLogger.query()
  AE_API_TOKEN?: string; // optional — secret, Account Analytics Read
}

export class AuditDO {
  private audit: AuditLogger;

  constructor(private ctx: DurableObjectState, private env: Env) {
    this.audit = new AuditLogger({
      namespace: 'do',
      mode: 'durable',
      reliability: new CloudflareReliabilityAdapter({
        state: ctx,                 // DO storage.sql required
        tablePrefix: 'logbun',      // {prefix}_journal / {prefix}_dlq
        maxJournalEntries: 100_000, // wal_full
        maxDlqEntries: 10_000,      // dlq_full
        scheduleAlarms: true,
        alarmDelayMs: 1_000,
      }),
      adapter: new CloudflareAnalyticsEngineAdapter({
        binding: env.AUDIT_AE,       // writeDataPoint (bulkInsert)
        dataset: 'logbun_audit',     // SQL dataset (query)
        accountId: env.ACCOUNT_ID,   // optional — query
        apiToken: env.AE_API_TOKEN,  // optional — query
      }),
    });
  }

  async alarm() {
    await this.audit.runMaintenance();
  }
}

Wrangler

wrangler.jsoncjsonc
{
  "analytics_engine_datasets": [
    { "binding": "AUDIT_AE", "dataset": "logbun_audit" }
  ]
}

The dataset string must match in wrangler.jsonc and in the adapter constructor and must match ^[A-Za-z_][A-Za-z0-9_]{0,63}$. ACCOUNT_ID is a plain var, AE_API_TOKEN is a secret with Account Analytics Read. Both are optional — without them the adapter is write-only and AuditLogger.query() throws dataset, accountId, and apiToken are required for query. query always adds WHERE timestamp >= NOW() - INTERVAL '91' DAY and dedupes by id.

Standard Workers should call a DO binding. Do not treat isolate-scoped volatile fire() as durable.

Destination details — Analytics Engine

Full slot map, limits, and helpers live in Adapters — CloudflareAnalyticsEngineAdapter:

  • Shared dataset only — no adapterFactory / database_per_tenant.
  • ≤250 writeDataPoint calls per invocation (bulkInsert throws before any write; hosts must keep maxRecoveryBatch ≤250 — the default batcher maxSize 100 already is).
  • Index ≤96 bytes, total blobs ≤16 KiB per point — oversized points throw on write.
  • prune() is a no-op (platform TTL ~3 months). query collapses duplicate ids from WAL/DLQ replay.

Reliability details — Durable Object

These apply to CloudflareReliabilityAdapter, not to AE:

tablePrefix is a sanitized SQL identifier: ^[A-Za-z_][A-Za-z0-9_]{0,63}$. Default logbun.

init() recovers DLQ orphans and requests a pending-work alarm so an isolate restart does not lose the wake-up. runMaintenance() is single-flight (plus one trailing pass). Let alarm() propagate rejection so the platform can retry. The adapter first restores a pending-work alarm after a failed maintenance phase.

Admission vs scheduling

Journal append / DLQ write then call requestMaintenance(). If getAlarm / setAlarm fails after the row is committed, callers see DurableAdmissionSchedulingError with durableAdmissionCommitted === true. Do not resubmit that event.

import { isDurableAdmissionSchedulingError } from 'logbun';

try {
  await audit.fireAsync('audit.event', input);
} catch (error) {
  if (isDurableAdmissionSchedulingError(error)) {
    await reliability.requestMaintenance();
  } else {
    throw error;
  }
}

Use fireAsync + journal for admission that survives request end.

waitUntil

Hono middleware reads c.executionCtx.waitUntil structurally when present and wraps it so host throws cannot escape fire(). Elysia does not inject ExecutionContext — pass getWaitUntil. See Hono and Elysia.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close