log_trail_drift 0.1.1
log_trail_drift: ^0.1.1 copied to clipboard
Drift log store for log_trail: one table in your own drift database, with SQLite in a background isolate or on the web.
Drift log store for log_trail: one
table in the drift database you already have. Entries survive restarts, SQLite
runs wherever your executor runs (a background isolate with
NativeDatabase.createInBackground, the browser with WasmDatabase), and the
in-app viewer from log_trail_flutter works over it unchanged.
Setup #
Declare the table in your project with the package's column mixin and add it
to your database. drift's generator only understands table classes declared
in the package it runs in, so the table is yours and LogEntryColumns is the
whole schema; this package generates nothing and needs no build configuration.
import 'package:drift/drift.dart';
import 'package:log_trail_drift/log_trail_drift.dart';
part 'app_database.g.dart';
// Name the row class: drift would otherwise call it `LogEntry`, which clashes
// with log_trail's `LogEntry`.
@DataClassName('LogEntryRow')
@TableIndex(name: 'log_entries_by_time', columns: {#time, #id})
class LogEntries extends Table with LogEntryColumns {}
@DriftDatabase(tables: [Users, LogEntries])
class AppDatabase extends _$AppDatabase {
// ...
}
Adding the table to a database that is already shipped is a normal drift migration:
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
if (from < 5) {
await m.createTable(logEntries);
await m.createIndex(logEntriesByTime);
}
},
);
Then open a store over the generated table object and plug it into the logger
like any other LogStore:
final store = DriftLogStore(db, db.logEntries, limit: 50000);
logger
..addSink(RepeatCollapsingSink(LogStoreSink(store)))
..finishStartup();
// Shutdown: the logger first (it waits for every accepted entry), then your
// database, which the store never closes.
await logger.close();
await db.close();
The journal mode is your database's choice. A log is many small write
transactions, and in SQLite's default DELETE journal mode each one syncs
the disk twice; WAL syncs once and lets the viewer read while the store
writes. If your database does not set it already, do so when opening it:
NativeDatabase.createInBackground(
file,
setup: (db) => db.execute('PRAGMA journal_mode = WAL'),
);
Your own table: extra columns and scoping #
Mix LogEntryColumns into a table with its own name and columns, and
subclass DriftLogStore to fill those columns and to restrict the store to
its part of the table. Several stores, or your own queries, can then share
one table:
@TableIndex(name: 'audit_log_by_time', columns: {#time, #id})
class AuditLog extends Table with LogEntryColumns {
TextColumn get tenant => text()();
}
final class TenantLogStore extends DriftLogStore<$AuditLogTable, AuditLogData> {
TenantLogStore(super.database, super.table, this.tenant, {super.limit});
final String tenant;
@override
Map<GeneratedColumn<Object>, Expression<Object>> additionalColumns(LogEntry entry) =>
{table.tenant: Variable(tenant)};
@override
Expression<bool> get scope => table.tenant.equals(tenant);
}
With a scope, limit, count, queryTags, every query and every removal
(including clear) apply to the scoped rows only. Extra columns without a
value from additionalColumns must have a default or be nullable.
Design #
- One table, no helper tables or triggers.
entryholds the whole entry as JSON (logEntryJson), the source of truth;time,level,tagsandattributesare an index derived from it on write. A new entry field needs no schema change. - Everything goes through drift's typed query builder against the table's
generated columns. Store order is
(time, id); pages continue from aLogCursorwith a(time, id)comparison that the index serves, not withOFFSET. Put the@TableIndexon(time, id)as shown. - Tags and attributes are delimited texts (U+001F) matched with
instr(): parameterized, and%/_mean nothing special. Attribute values are compared as canonical JSON (map keys sorted). No index serves these columns, norlevel: only(time, id)is indexed. A page is read in store order until it is full, so a filter that most rows satisfy stops early, while a filter few rows satisfy (a rare tag, an attribute value with a few hits) reads every row of the scope. A tag no stored entry carries is answered from the in-memory tag counts without a query. Measured withbenchmark/filter_scan_benchmark.dartat 50 000 entries of ~750 bytes on a laptop: a page by a common tag 1.5 ms, a full scan by an attribute value 15–20 ms; expect several times that on a phone, once per filter change, on the executor's isolate. This is the price of one table;log_trail_sqliteindexes tags and attributes in helper tables instead. Setlimitto bound it. - Text search stores nothing extra. It folds case beyond ASCII, which
SQLite's
lower()cannot, so SQL only narrows the candidates withinstr(lower(entry), ?)for the ASCII fragments of the needle (LogTextCondition.prefilterTokens) and the decoded rows are checked in Dart, page by page (collectLogPage, which checks the needle on the raw JSON before decoding a row). The prefilter lowercases every row's JSON, so it is the most expensive filter: 40–60 ms per page at 50 000 entries in the benchmark above. A needle without ASCII fragments (all Cyrillic) has no prefilter: a rare one reads the log through the executor in growing rounds, about 0.5 s in that benchmark and seconds on a phone, dominated by moving the rows out of SQLite, not by decoding them; a common one fills the page from the first rows. One table cannot carry an FTS index; an app whose users search a non-Latin log should uselog_trail_sqlite, whose trigram index answers such a needle in milliseconds. - Tag counts and the entry count are kept in memory, built on first use by
one
GROUP BY tagsquery (one row per distinct tag set, not per entry) and maintained by the store's own writes and removals: correct as long as this store is the table's only writer within its scope. Background isolates spool their entries (LogSpoolSink,package:log_trail/isolates.dart); the main isolate imports them here. - The API is asynchronous end to end, so a
fatalentry the root dispatches inside the logging call is queued to the executor beforelogreturns and committed when the executor gets to it. An application that exits right after a fatal entry mustawait logger.flush()first.log_trail_sqlitecommits such an entry synchronously instead. - SQLite failures surface as
LogStoreException(fromlog_trail_sqlite), unwrapped from drift'sDriftRemoteExceptionwhen the executor runs in another isolate, and without query parameters, which for a write are the whole entry. - The contract test checks pages, tags and change events against
InMemoryLogStorewith random logs and queries, over a plain table and over two scoped stores sharing one table.
Works with any drift executor; on the web use WasmDatabase from
package:drift/wasm.dart. Requires SQLite 3.35 or newer (eviction deletes
with RETURNING): the SQLite bundled by sqlite3_flutter_libs, which drift
recommends on mobile, and drift's sqlite3.wasm are far newer; the system
SQLite of older Android versions is not.