loq_drift 0.1.0
loq_drift: ^0.1.0 copied to clipboard
Structured query logging interceptor for Drift, powered by loq. Logs SQL statements, durations, row counts, batches, and transactions as structured records aligned with OpenTelemetry database semantic [...]
0.1.0 #
- Initial release.
LoqDriftInterceptor: aQueryInterceptorthat writes a structured log record for each SQL query, batch, transaction step, and database open/close.- Default fields aligned with OpenTelemetry database semantic
conventions:
db.system.name,db.namespace,db.operation.name,db.query.summary,db.query.text,db.collection.name,db.response.returned_rows,db.operation.batch.size. Plusduration_msand two loq-native extensions in their ownloq.*namespace:loq.db.affected_rows: row count returned byrunUpdate/runDeleteon every dialect, and byrunInserton non-sqlite dialects. OTel doesn't standardize an "affected rows" attribute, so we keep this out of thedb.*namespace.loq.db.last_insert_rowid: forrunInserton sqlite, where Drift's executor gives back the auto-increment row id (not an affected count). The split keeps the names honest.
db.operation.batch.sizeis left out when the batch holds a single operation, per the OTel spec. The field is only useful for telling real multi-operation batches apart from single-statement calls.db.response.returned_rowsis OTel-spec but in Development / Opt-In status. The attribute name is settled but the spec hasn't promoted it to Stable. Future minor versions could narrow what it means. Documented in the README.db.response.status_code(OTel-spec Stable, set on error) is not emitted on its own. The value lives in dialect-specific exception types (SqliteException,PgException,MySQLClientException) thatloq_driftdoesn't import. README has per-dialect recipes for surfacing it through the existingerrorFields:hook.db.query.summaryderived as<OP> <table?>for single queries andBATCH <OP> <table?>for batches whose statements share a single operation (the common drift case: same statement repeated with different args). Mixed-op or empty batches fall back to plainBATCH. Transaction lifecycle events don't emit a summary since they aren't queries in OTel's sense, and dashboards keying offdb.query.summaryshouldn't pick up lifecycle noise.fields/errorFieldsare the one transformation point for their respective logs. Each gets a typedDriftLogEvent, one ofDriftQueryEvent,DriftBatchEvent,DriftTransactionEvent, orDriftLifecycleEvent. Pattern-match withswitchto branch on event shape; spread...event.defaultsto keep the defaults; return a different map to replace.DriftQueryEvent.argsis always populated, so hooks can read bind parameters without turning oncaptureArgsglobally.defaultDbSystemNameis public. Maps Drift'sSqlDialect(sqlite, postgres, mariadb) to the OTel canonicaldb.system.name. Falls back toother_sql(the Stable OTel catch-all) for any other dialect. Users who want a specific name for an unregistered dialect (e.g.duckdb) override throughdbSystemResolver.extractOperationNameis public. Pulls out the leading SQL keyword (used forrunCustomoperation detection). Skips leading whitespace and--line comments. Returnsnullfor empty, whitespace-only, or non-alphabetic statements.namespaceconstructor parameter emits OTel-specdb.namespace(Stable) on every event when non-null. One value per interceptor. Users with dynamic namespaces (multi-tenant routing etc.) either spin up one interceptor per database or emitdb.namespacethrough thefields:hook orwithLogContext.tableResolverpopulatesdb.collection.name. No built-in default. Wire it to your own table-extraction strategy to keep per-statement cardinality from blowing up dashboards. README notes that SQL-regex extraction is the OTel spec's non-preferred path: the spec wants the value from query metadata, not from parsing query text. Drift'sQueryInterceptordoesn't expose the AST, so the spec's preferred path isn't reachable from inside the interceptor. README describes an alternative: binddb.collection.namethroughwithLogContextat the call site.dbSystemResolveroverrides the dialect-to-system mapping. Returningnullfalls back todefaultDbSystemName.levelResolver: one hook that overrides level for any event. Gets the typedDriftLogEventand any caught error. Returningnullfalls back toqueryLevel(queries / batches),transactionLevel(transactions), orLevel.error(error path).slowQueryThreshold's warn-bump still stacks on top.skipLogpredicate to drop logs for high-frequency statements (PRAGMA, health pings). The query still runs. Batches and transactions always log; raise the handler'sminLevelabovetransactionLevel/queryLevelto silence those. TheLogsuffix flags thatskipLogis narrower thanloq_shelf'sskip:, which bypasses the entire middleware (no zone context binding either).loq_driftcan't bypass the query itself.slowQueryThresholdto flag and bump up queries / batches that cross a duration: addsslow: trueand makes sure the level is at leastwarn. Theslow: trueflag goes on both success and error paths for queries and batches; the warn-bump itself only applies on success (the error path is already atLevel.erroror higher).captureArgsopt-in to emit bound parameters in the OTel-spec indexed shape:db.query.parameter.<n>(0-based, one attribute per position). Status is Development / Opt-In in the spec, so what it means could narrow in future minor versions. Off by default since parameters often carry user-identifying values. The spec also says don't emit parameter attributes for batches, and we follow that: batches never carry them regardless ofcaptureArgs. No built-in arg redactor since positional bind args carry no schema signal, so the interceptor can't know which positions are sensitive. Two documented strategies:- Coarse: leave
captureArgs: falsein production (no parameter fields at all). For "args present but masked", stripdb.query.parameter.*keys from thefields:hook. - Fine-grained: override individual
db.query.parameter.<n>keys from thefields:hook by pattern-matching onDriftQueryEvent. Direct per-position override, no list rebuilding.event.args(the raw list) is always available on the event regardless ofcaptureArgs.
- Coarse: leave
queryLevel,transactionLevel, andlifecycleLeveldefaults for the three event categories (Level.debugfor queries/batches;Level.tracefor transactions and database lifecycle).- Eleven message overrides, one per event variant
(
queryCompleteMessage,queryErrorMessage,batchCompleteMessage,batchErrorMessage,transactionBeginMessage,transactionCommitMessage,transactionRollbackMessage,transactionErrorMessage,databaseOpenMessage,databaseCloseMessage,databaseLifecycleErrorMessage). - README notes that
loq_driftdoesn't bind anything to the zone on its own (unlikeloq_shelf's middleware). Drift dispatches transaction bodies and queries from above the interceptor's call frame, so there's no callback we can wrap. Users who want per-transaction correlation wrap the block themselves withwithLogContext(recipe in README). - Database lifecycle instrumentation: the first successful
ensureOpenper interceptor emits adatabase openedrecord; everycloseemits adatabase closedrecord. Errors on either path emit a separatedatabase lifecycle failedrecord atLevel.error. New event typeDriftLifecycleEventjoins the sealedDriftLogEventfamily (withoperation: 'OPEN' | 'CLOSE'). Default levelLevel.trace(set throughlifecycleLevel); message overrides throughdatabaseOpenMessage,databaseCloseMessage,databaseLifecycleErrorMessage. - Tracing integration through chained
QueryInterceptor: README has the pattern (a customTracingInterceptorwrappingLoqDriftInterceptorthrough Drift'sinterceptWithchain), plus a runnable example atexample/tracing_example.dart. Trace context flows from the outer tracing layer into log records throughwithLogContext. No API surface needed inloq_driftitself, no coupling to a specific tracer SDK. - Test suite in three layers:
- Unit tests against a
_FakeExecutor(100% line coverage onlib/src/). Fast; runs in the defaultdart test. - SQLite integration suite at
test/integration/real_sqlite_test.dartrunning against real in-memory SQLite throughNativeDatabase.memory(). Catches behavior the synthetic fake can't model: real exception types, row counts returned by sqlite, batch internals, lifecycle open/close. Runs in the defaultdart test. - Postgres smoke suite at
test/integration/postgres_test.dartrunning against real postgres throughdrift_postgres. Confirms that drift's postgres adapter dispatches throughQueryInterceptorthe same way sqlite's does, and that the dialect-specific branch (loq.db.affected_rowsinstead ofloq.db.last_insert_rowidon INSERT) holds against the real adapter. Tagged@Tags(['postgres'])and skipped by default (throughdart_test.yaml) so local devs without postgres don't see failures. CI runs them as a separate step with apostgres:16service container.
- Unit tests against a