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 schedulesalarm()for retries and retention. It does not store queryable audit rows and does not use D1, R2, KV, or Nodefs. - 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 viawriteDataPointand 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
writeDataPointis 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:
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
{
"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
writeDataPointcalls per invocation (bulkInsert throws before any write; hosts must keepmaxRecoveryBatch≤250 — the default batchermaxSize100 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).querycollapses duplicateids 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.