Destination adapters store queryable audit records. Reliability adapters own journals and DLQs. Pair a destination below with FileReliabilityAdapter or CloudflareReliabilityAdapter for durable mode.
AuditLogger.query() throws if the logger is shut down or not initialized. Built-in destination adapters throw from query / bulkInsert / prune if they are closed or not initialized — they do not return an empty page as if no rows existed.
Adapter query is newest-first (ORDER BY id DESC). pagination.limit is required on IAdapter.query. nextCursor is the last row’s id when another page exists.
BunSQLiteAdapter
import { BunSQLiteAdapter } from 'logbun/adapters/bun-sqlite';
const adapter = new BunSQLiteAdapter({
path: '.logbun/audit.db',
synchronous: 'FULL', // FULL | NORMAL | OFF — default FULL
busyTimeoutMs: 5_000, // default 5000
});| Deps | None (bun:sqlite) |
| Best for | Dev, single-instance |
| Idempotency | INSERT OR IGNORE on primary key id |
| Journal | PRAGMA journal_mode = WAL |
| Prune | Batched DELETE … WHERE created_at < cutoff AND id IN (SELECT id … LIMIT 1000), up to 10_000 batches. Throws prune_incomplete if a full batch remains. days must be a finite number >= 1. |
| Indexes | (tenant_id, created_at), (tenant_id, id DESC), (tenant_id, action, created_at), (tenant_id, actor_id, created_at), (tenant_id, entity_id), plus standalone action/actor/entity |
| Integrity | Columns prev_hash, content_hash; best-effort ALTER TABLE … ADD COLUMN for older DBs |
Not multi-writer HA. Multiple processes must not share one SQLite file as concurrent writers.
Invalid synchronous throws BunSQLiteAdapter: synchronous must be FULL, NORMAL, or OFF. Invalid busyTimeoutMs throws BunSQLiteAdapter: busyTimeoutMs must be a finite integer >= 0.
TursoAdapter
import { TursoAdapter } from 'logbun/adapters/turso';
const adapter = new TursoAdapter({
url: 'libsql://my-db.turso.io',
authToken: process.env.TURSO_TOKEN!,
});| Peer | @libsql/client >=0.6.0 |
| Best for | Multi-tenant SaaS, edge, database_per_tenant |
| Idempotency | INSERT OR IGNORE |
| Writes | client.batch(..., 'write') |
| Prune | Same batched DELETE as SQLite. Throws prune_incomplete if rowsAffected is missing or a full batch remains after 10_000 × 1_000 rows. |
| Indexes | Same composite set as SQLite, including (tenant_id, id DESC) |
| Integrity | Same prev_hash / content_hash columns + best-effort migrate |
Config: { url: string; authToken: string }.
ClickHouseAdapter
import { ClickHouseAdapter } from 'logbun/adapters/clickhouse';
const adapter = new ClickHouseAdapter({
url: 'http://localhost:8123',
database: 'analytics', // default 'default'
username: 'default',
password: process.env.CH_PASSWORD,
retentionDays: 90, // TTL on CREATE TABLE only; safe integer 1..365000
queryFinal: true, // default true — deduped reads
});| Peer | @clickhouse/client >=1.0.0 |
| Best for | High-volume analytics / audit warehouse |
| Engine | ReplacingMergeTree · ORDER BY (tenant_id, id) · PARTITION BY toYYYYMM(created_at) |
| Idempotency | Sorting-key dedup after merges; query uses FINAL by default (queryFinal !== false) |
| Prune | ALTER TABLE … DROP PARTITION for YYYYMM partitions strictly older than the cutoff month (UTC). The cutoff month itself is left to create-time TTL. Does not throw prune_incomplete. |
| Timestamps | ISO → YYYY-MM-DD HH:mm:ss.SSS UTC via toClickHouseDateTime |
| Integrity | Nullable prev_hash / content_hash; best-effort ADD COLUMN IF NOT EXISTS |
CREATE TABLE IF NOT EXISTSdoes not migrate an existing MergeTree engine. Old installs need a manual migration to ReplacingMergeTree.retentionDaysTTL is applied only at CREATE TABLE time. Changing the option later does notALTERan existing table’s TTL.tenant_idisString DEFAULT ''(not nullable); empty round-trips as omittedtenantIdon read (''→undefined).- Prefer a shared ClickHouse database (not one DB per tenant).
- Invalid
retentionDaysthrowsClickHouseAdapter: retentionDays must be a safe integer in 1..365000.
Exported helpers:
import {
toClickHouseDateTime,
fromClickHouseDateTime,
} from 'logbun/adapters/clickhouse';CloudflareAnalyticsEngineAdapter — Workers Analytics Engine (destination)
Workers-only destination. For the analytics_engine_datasets binding. This
is not a reliability adapter and does not use D1/R2/KV or Durable Object
SQLite — that is CloudflareReliabilityAdapter (Durable Object journal/DLQ).
See the Cloudflare guide for how they pair. Use AE for
the warehouse, use the DO adapter for durability.
import { CloudflareAnalyticsEngineAdapter } from 'logbun/adapters/cloudflare-analytics-engine';
const adapter = new CloudflareAnalyticsEngineAdapter({
binding: env.AUDIT_AE, // writeDataPoint binding — required for bulkInsert
dataset: 'logbun_audit', // SQL dataset — required for query
accountId: env.ACCOUNT_ID, // 32-char account id — required for query
apiToken: env.AE_API_TOKEN, // Account Analytics Read token — required for query
// apiBaseUrl: 'https://api.cloudflare.com/client/v4', // default
});Workers durable stack: pair this destination with
CloudflareReliabilityAdapter inside the same Durable Object. The DO owns
the journal/DLQ (durability); AE owns the dataset (queryability). Outside a DO,
AE alone is volatile. Same idea as ClickHouse/Turso + FileReliabilityAdapter,
moved to Workers.
Write-only is expected.
bindingalone is enough forbulkInsert;dataset+accountId+apiTokenalone is enough forquery. Provide just the binding where you write, and add the SQL creds where you query.querywithout creds throwsdataset, accountId, and apiToken are required for query.
| Deps | None |
| Best for | Durable Workers — DO journal + AE warehouse |
| Runtime | Workers (analytics_engine_datasets binding + SQL API) |
| Idempotency | None on write — WAL/DLQ replay writes duplicate points; query collapses by id |
| Prune | No-op (3-month platform TTL). Does not throw prune_incomplete. |
| Writes | writeDataPoint sync, fire-and-forget |
| Query | SQL API — POST /accounts/:accountId/analytics_engine/sql, FORMAT JSONEachRow |
| Integrity | prevHash / contentHash in blobs 8/9 |
Fixed schema (one AE data point):
| Slot | Field |
|---|---|
index1 |
log.tenantId ?? tenantId ?? '' |
blob1–blob4 |
id, actorId, action, entityId ?? '' |
blob5 |
createdAt ISO (AE timestamp is write-time, not this) |
blob6–blob9 |
ipAddress, userAgent, prevHash, contentHash (each ?? '') |
blob10–blob12 |
oldValues, newValues, metadata JSON or '' |
double1 |
Date.parse(createdAt) epoch ms |
- ≤ 250
writeDataPointcalls per Worker invocation.bulkInsertthrows before writing when a batch exceeds 250, so an oversized recovery wave DLQs instead of silently dropping overflow. Hosts must keepmaxRecoveryBatch≤ 250 (the default batchermaxSizeis already 100). - Index ≤ 96 bytes; total blob bytes ≤ 16 KiB per point. Oversized points throw on write.
- Ingest is asynchronous and may be sampled — AE is not a compliance-grade unique store. The DO journal is the durable admission log.
- Shared dataset only — no
adapterFactory/database_per_tenant. - No DELETE:
pruneis a no-op; the platform retains ~3 months.queryalways boundsWHERE timestamp >= NOW() - INTERVAL '91' DAY.
Exported helpers:
import {
ANALYTICS_ENGINE_SCHEMA,
toAnalyticsEngineDataPoint,
fromAnalyticsEngineRow,
} from 'logbun/adapters/cloudflare-analytics-engine';toAnalyticsEngineDataPoint maps a record to the fixed slot schema. fromAnalyticsEngineRow is the inverse used on SQL API rows. Empty string fields round-trip as omitted (undefined), same as ClickHouse tenant_id.