diff --git a/.github/workflows/talker_drift_logger.yaml b/.github/workflows/talker_drift_logger.yaml new file mode 100644 index 00000000..609a91cd --- /dev/null +++ b/.github/workflows/talker_drift_logger.yaml @@ -0,0 +1,21 @@ +name: talker_drift_logger + +on: + push: + paths: + - "packages/talker_drift_logger/**" + - ".github/workflows/talker_drift_logger.yaml" + pull_request: + paths: + - "packages/talker_drift_logger/**" + - ".github/workflows/talker_drift_logger.yaml" +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/test.yaml + with: + package: talker_drift_logger + sdk: dart + chrome: false diff --git a/README.md b/README.md index 18aaea9c..1480dab1 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Talker is designed for any level of customization.
| [talker_chopper_logger](https://github.com/Frezyx/talker/tree/master/packages/talker_chopper_logger) | [![Pub](https://img.shields.io/pub/v/talker_chopper_logger.svg?style=flat-square)](https://pub.dev/packages/talker_chopper_logger) | Best logger for [Chopper](https://pub.dev/packages/chopper) http calls | | [talker_http_logger](https://github.com/Frezyx/talker/tree/master/packages/talker_http_logger) | [![Pub](https://img.shields.io/pub/v/talker_http_logger.svg?style=flat-square)](https://pub.dev/packages/talker_http_logger) | Best logger for [http](https://pub.dev/packages/http) package | | [talker_grpc_logger](https://github.com/Frezyx/talker/tree/master/packages/talker_grpc_logger) | [![Pub](https://img.shields.io/pub/v/talker_grpc_logger.svg?style=flat-square)](https://pub.dev/packages/talker_grpc_logger) | Best logger for [grpc](https://pub.dev/packages/grpc) package | +| [talker_drift_logger](https://github.com/Frezyx/talker/tree/master/packages/talker_drift_logger) | [![Pub](https://img.shields.io/pub/v/talker_drift_logger.svg?style=flat-square)](https://pub.dev/packages/talker_drift_logger) | Best logger for [Drift](https://pub.dev/packages/drift) database | ## Table of contents @@ -128,7 +129,52 @@ Talker is designed for any level of customization.
- [Getting Started](#getting-started-4) - [Using with Talker](#using-with-talker-3) - [Token obfuscation](#token-obfuscation) -- [Crashlytics integration](#crashlytics-integration) + +## Talker Drift Logger + +Lightweight, simple and pretty solution for logging Drift database operations using [Drift](https://pub.dev/packages/drift). + +This is how SQL statements and results will look in the console with Talker formatting and colors. + +### Getting Started +Follow these steps to use this package + +### Add dependency +```yaml +dependencies: + talker_drift_logger: ^5.0.0 +``` + +### Usage +Attach an interceptor to Drift executor via `interceptWith`: + +```dart +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:talker_drift_logger/talker_drift_logger.dart'; + +final executor = NativeDatabase.memory().interceptWith( + TalkerDriftLogger( + settings: const TalkerDriftLoggerSettings( + printArgs: true, + printResults: true, + ), + ), +); +``` + +### Using with Talker +You can add your Talker instance for `TalkerDriftLogger` if your application already uses Talker. +In this case, all logs and errors will fall into your unified tracking system and appear in Talker UI filters. + +```dart +final talker = Talker(); +final executor = NativeDatabase('app.db').interceptWith( + TalkerDriftLogger(talker: talker), +); +``` + +## Crashlytics integration - [Features list](#features-list) - [Coverage](#coverage) - [Additional information](#additional-information) diff --git a/packages/talker/lib/src/settings.dart b/packages/talker/lib/src/settings.dart index 3a4dbaa2..a021f60b 100644 --- a/packages/talker/lib/src/settings.dart +++ b/packages/talker/lib/src/settings.dart @@ -27,6 +27,13 @@ final _defaultTitles = { TalkerKey.riverpodDispose: 'riverpod-dispose', TalkerKey.riverpodFail: 'riverpod-fail', + /// Drift section + TalkerKey.driftError: 'drift-error', + TalkerKey.driftQuery: 'drift-query', + TalkerKey.driftResult: 'drift-result', + TalkerKey.driftTransaction: 'drift-transaction', + TalkerKey.driftBatch: 'drift-batch', + /// Flutter section TalkerKey.route: 'route', }; @@ -58,6 +65,13 @@ final _defaultColors = { TalkerKey.riverpodDispose: AnsiPen()..xterm(198), TalkerKey.riverpodFail: AnsiPen()..red(), + /// Drift section + TalkerKey.driftError: AnsiPen()..red(), + TalkerKey.driftQuery: AnsiPen()..xterm(219), + TalkerKey.driftResult: AnsiPen()..xterm(46), + TalkerKey.driftTransaction: AnsiPen()..xterm(135), + TalkerKey.driftBatch: AnsiPen()..xterm(49), + /// Flutter section TalkerKey.route: AnsiPen()..xterm(135), }; diff --git a/packages/talker/lib/src/talker_key.dart b/packages/talker/lib/src/talker_key.dart index f634d6eb..7bf730fc 100644 --- a/packages/talker/lib/src/talker_key.dart +++ b/packages/talker/lib/src/talker_key.dart @@ -36,6 +36,13 @@ abstract class TalkerKey { static const grpcResponse = 'grpc-response'; static const grpcError = 'grpc-error'; + /// Drift section + static const driftQuery = 'drift-query'; + static const driftResult = 'drift-result'; + static const driftError = 'drift-error'; + static const driftTransaction = 'drift-transaction'; + static const driftBatch = 'drift-batch'; + /// Flutter section static const route = 'route'; diff --git a/packages/talker_drift_logger/.gitignore b/packages/talker_drift_logger/.gitignore new file mode 100644 index 00000000..27f7b8ce --- /dev/null +++ b/packages/talker_drift_logger/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies diff --git a/packages/talker_drift_logger/.metadata b/packages/talker_drift_logger/.metadata new file mode 100644 index 00000000..7b724eb7 --- /dev/null +++ b/packages/talker_drift_logger/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: ffccd96b62ee8cec7740dab303538c5fc26ac543 + channel: stable + +project_type: package diff --git a/packages/talker_drift_logger/CHANGELOG.md b/packages/talker_drift_logger/CHANGELOG.md new file mode 100644 index 00000000..6ccf27d6 --- /dev/null +++ b/packages/talker_drift_logger/CHANGELOG.md @@ -0,0 +1,6 @@ +## 5.0.0 +- Initial release of `talker_drift_logger` +- Drift `QueryInterceptor` with query/result/error/transaction/batch logs +- Pretty JSON output, args/result printing with obfuscation and limits +- Customization via `TalkerDriftLoggerSettings` +- Tests and example diff --git a/packages/talker_drift_logger/LICENSE b/packages/talker_drift_logger/LICENSE new file mode 100644 index 00000000..17a8f2a6 --- /dev/null +++ b/packages/talker_drift_logger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Stanislav Ilin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/talker_drift_logger/README.md b/packages/talker_drift_logger/README.md new file mode 100644 index 00000000..a678ac0e --- /dev/null +++ b/packages/talker_drift_logger/README.md @@ -0,0 +1,90 @@ +# talker_drift_logger +Lightweight and customizable [Drift](https://pub.dev/packages/drift) SQL logger on [talker](https://pub.dev/packages/talker) base.
+[Talker](https://github.com/Frezyx/talker) - Advanced exception handling and logging for dart/flutter applications 🚀 + +

+ GitHub + codecov + Pub + License: MIT +

+ +## Getting started + +### Add dependency +```yaml +dependencies: + talker_drift_logger: ^5.0.0 +``` + +### Usage +Attach `TalkerDriftLogger` via Drift interceptors and that's it: + +```dart +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:talker_drift_logger/talker_drift_logger.dart'; + +final talker = Talker(); + +final executor = NativeDatabase.memory().interceptWith( + TalkerDriftLogger( + talker: talker, + settings: const TalkerDriftLoggerSettings( + printArgs: true, + printResults: true, + ), + ), +); + +// If you're using a GeneratedDatabase subclass: +final db = MyDatabase(executor); +``` + +You can also wrap an existing `DatabaseConnection` or use `runWithInterceptor` for scoped logging. + +## Settings + +`TalkerDriftLoggerSettings` gives you full control: + +- enabled (default: true) +- logLevel (default: LogLevel.debug) +- printArgs / printResults +- resultRowLimit / resultMaxChars +- printTransaction / printBatch +- obfuscateColumns (case-insensitive) +- obfuscatePatterns (Pattern-based masking) +- queryPen / resultPen / errorPen / transactionPen / batchPen +- statementFilter / errorFilter +- resultPrinter / argsPrinter + +Example: +```dart +TalkerDriftLogger( + talker: talker, + settings: const TalkerDriftLoggerSettings( + printArgs: true, + printResults: true, + resultRowLimit: 50, + resultMaxChars: 2000, + obfuscateColumns: {'password', 'token'}, + ), +); +``` + +## Using with Talker +Provide your Talker instance if your app already uses Talker. In this case, all logs will go into your unified logging pipeline and UI (filters). + +```dart +final talker = Talker(); +final executor = NativeDatabase('app.db').interceptWith(TalkerDriftLogger(talker: talker)); +``` + +## Notes + +- Transactions (`begin`, `commit`, `rollback`) and batched statements are logged as well (configurable). +- This package prints pretty JSON, masks sensitive data, and limits result sizes to keep console clean. + +## Additional information +The project is under development and ready for your pull-requests and issues 👍
+Thank you for support ❤️ diff --git a/packages/talker_drift_logger/analysis_options.yaml b/packages/talker_drift_logger/analysis_options.yaml new file mode 100644 index 00000000..46ffaf4b --- /dev/null +++ b/packages/talker_drift_logger/analysis_options.yaml @@ -0,0 +1,16 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment to customize rules +# linter: +# rules: +# - always_use_package_imports + +# analyzer: +# exclude: +# - test/**/goldens/** diff --git a/packages/talker_drift_logger/example/lib/main.dart b/packages/talker_drift_logger/example/lib/main.dart new file mode 100644 index 00000000..a3f3006b --- /dev/null +++ b/packages/talker_drift_logger/example/lib/main.dart @@ -0,0 +1,39 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_drift_logger/talker_drift_logger.dart'; + +// Run: dart run bin/main.dart +Future main() async { + final talker = Talker(); + + final executor = NativeDatabase.memory().interceptWith( + TalkerDriftLogger( + talker: talker, + settings: const TalkerDriftLoggerSettings( + printArgs: true, + printResults: true, + ), + ), + ); + + final db = _DummyDb(executor); + + await db.customStatement( + 'CREATE TABLE users (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT);'); + await db.customStatement("INSERT INTO users (name) VALUES ('Alice')"); + await db.customStatement("INSERT INTO users (name) VALUES ('Bob')"); + + final rows = await db.customSelect('SELECT * FROM users').get(); + talker.info('Selected ${rows.length} users'); +} + +class _DummyDb extends GeneratedDatabase { + _DummyDb(QueryExecutor e) : super(e); + @override + int get schemaVersion => 1; + @override + Iterable> get allTables => const []; + @override + List get allSchemaEntities => const []; +} diff --git a/packages/talker_drift_logger/example/pubspec.lock b/packages/talker_drift_logger/example/pubspec.lock new file mode 100644 index 00000000..6d4509dd --- /dev/null +++ b/packages/talker_drift_logger/example/pubspec.lock @@ -0,0 +1,132 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + drift: + dependency: "direct main" + description: + name: drift + sha256: "6aaea757f53bb035e8a3baedf3d1d53a79d6549a6c13d84f7546509da9372c7c" + url: "https://pub.dev" + source: hosted + version: "2.28.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + talker: + dependency: "direct main" + description: + name: talker + sha256: "2d742b54e5cda58b7d386cd2d95088c3429ef273b2a0869dec552fe02601367a" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + talker_drift_logger: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "5.0.0" + talker_logger: + dependency: transitive + description: + name: talker_logger + sha256: "4f06d46db664c11cf4d629378da661f2c75aee629e3ef898dbc3cf44a0c41cd7" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" +sdks: + dart: ">=3.7.0 <4.0.0" diff --git a/packages/talker_drift_logger/example/pubspec.yaml b/packages/talker_drift_logger/example/pubspec.yaml new file mode 100644 index 00000000..1f712aa3 --- /dev/null +++ b/packages/talker_drift_logger/example/pubspec.yaml @@ -0,0 +1,17 @@ +name: talker_drift_logger_example +publish_to: "none" +version: 1.0.0 + +environment: + sdk: '>=2.17.0 <4.0.0' + +dependencies: + talker: ^5.0.0 + talker_drift_logger: + path: .. + drift: ^2.28.1 + sqlite3: ^2.9.0 + +# This is a pure Dart console example +executables: + talker_drift_logger_example: main diff --git a/packages/talker_drift_logger/lib/drift_logs.dart b/packages/talker_drift_logger/lib/drift_logs.dart new file mode 100644 index 00000000..32d20add --- /dev/null +++ b/packages/talker_drift_logger/lib/drift_logs.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; + +import 'package:talker/talker.dart'; +import 'package:talker_drift_logger/talker_drift_logger_settings.dart'; + +const _encoder = JsonEncoder.withIndent(' '); +const _hiddenValue = '*****'; + +String _truncate(String input, int? maxChars) { + if (maxChars == null) return input; + if (input.length <= maxChars) return input; + return input.substring(0, maxChars) + '...'; +} + +String _maskPatterns(String value, Set patterns) { + var v = value; + for (final p in patterns) { + v = v.replaceAll(p, _hiddenValue); + } + return v; +} + +List _maskArgs( + List args, TalkerDriftLoggerSettings settings) { + if (args.isEmpty) return args; + return args.map((e) { + if (e is String) return _maskPatterns(e, settings.obfuscatePatterns); + return e; + }).toList(growable: false); +} + +List> _maskRows( + List> rows, + TalkerDriftLoggerSettings settings, +) { + if (rows.isEmpty) return rows; + final lowerColumns = + settings.obfuscateColumns.map((e) => e.toLowerCase()).toSet(); + return rows.map((row) { + final mapped = {}; + row.forEach((key, value) { + final lowerKey = key.toLowerCase(); + if (lowerColumns.contains(lowerKey)) { + mapped[key] = _hiddenValue; + } else if (value is String) { + mapped[key] = _maskPatterns(value, settings.obfuscatePatterns); + } else { + mapped[key] = value; + } + }); + return mapped; + }).toList(growable: false); +} + +String _prettyArgs(List args, TalkerDriftLoggerSettings settings) { + if (settings.argsPrinter != null) return settings.argsPrinter!(args); + try { + final masked = _maskArgs(args, settings); + return _encoder.convert(masked); + } catch (_) { + return args.toString(); + } +} + +String _prettyRows( + List> rows, + TalkerDriftLoggerSettings settings, +) { + if (settings.resultPrinter != null) return settings.resultPrinter!(rows); + try { + final masked = _maskRows(rows, settings); + return _encoder.convert(masked); + } catch (_) { + return rows.toString(); + } +} + +class DriftQueryLog extends TalkerLog { + DriftQueryLog( + String statement, { + required this.args, + required this.settings, + }) : super(statement); + + final List args; + final TalkerDriftLoggerSettings settings; + + @override + AnsiPen get pen => settings.queryPen ?? (AnsiPen()..xterm(219)); + + @override + String get key => TalkerKey.driftQuery; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage( + {TimeFormat timeFormat = TimeFormat.timeAndSeconds}) { + var msg = '[$title] $message'; + if (settings.printArgs && args.isNotEmpty) { + msg += '\nArgs: ${_prettyArgs(args, settings)}'; + } + return msg; + } +} + +class DriftResultLog extends TalkerLog { + DriftResultLog( + String statement, { + required this.settings, + this.durationMs, + this.rowCount, + this.rows, + this.affected, + }) : super(statement); + + final TalkerDriftLoggerSettings settings; + final int? durationMs; + final int? rowCount; + final List>? rows; + final int? affected; + + @override + AnsiPen get pen => settings.resultPen ?? (AnsiPen()..xterm(46)); + + @override + String get key => TalkerKey.driftResult; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage( + {TimeFormat timeFormat = TimeFormat.timeAndSeconds}) { + var msg = '[$title] $message'; + msg += '\nStatus: ok'; + if (durationMs != null) { + msg += '\nTime: $durationMs ms'; + } + if (affected != null) { + msg += '\nAffected: $affected'; + } + if (rowCount != null) { + msg += '\nRows: $rowCount'; + } + if (settings.printResults && rows != null && rows!.isNotEmpty) { + var toShow = rows!; + if (settings.resultRowLimit != null && + toShow.length > settings.resultRowLimit!) { + toShow = toShow.sublist(0, settings.resultRowLimit!); + } + final pretty = _prettyRows(toShow, settings); + msg += '\nData: ' + _truncate(pretty, settings.resultMaxChars); + } + return msg; + } +} + +class DriftErrorLog extends TalkerLog { + DriftErrorLog( + String statement, { + required this.settings, + required this.dbError, + this.args = const [], + this.durationMs, + }) : super(statement); + + final TalkerDriftLoggerSettings settings; + final Object dbError; + final List args; + final int? durationMs; + + @override + AnsiPen get pen => settings.errorPen ?? (AnsiPen()..red()); + + @override + String get key => TalkerKey.driftError; + + @override + LogLevel get logLevel => LogLevel.error; + + @override + String generateTextMessage( + {TimeFormat timeFormat = TimeFormat.timeAndSeconds}) { + var msg = '[$title] $message'; + if (durationMs != null) { + msg += '\nTime: $durationMs ms'; + } + msg += '\nMessage: ${dbError.toString()}'; + if (settings.printArgs && args.isNotEmpty) { + msg += '\nArgs: ${_prettyArgs(args, settings)}'; + } + return msg; + } +} + +class DriftTransactionLog extends TalkerLog { + DriftTransactionLog( + String action, { + required this.settings, + this.durationMs, + }) : super(action); + + final TalkerDriftLoggerSettings settings; + final int? durationMs; + + @override + AnsiPen get pen => settings.transactionPen ?? (AnsiPen()..xterm(135)); + + @override + String get key => TalkerKey.driftTransaction; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage( + {TimeFormat timeFormat = TimeFormat.timeAndSeconds}) { + var msg = '[$title] $message'; + if (durationMs != null) { + msg += '\nTime: $durationMs ms'; + } + return msg; + } +} + +class DriftBatchLog extends TalkerLog { + DriftBatchLog( + String message, { + required this.settings, + }) : super(message); + + final TalkerDriftLoggerSettings settings; + + @override + AnsiPen get pen => settings.batchPen ?? (AnsiPen()..xterm(49)); + + @override + String get key => TalkerKey.driftBatch; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage( + {TimeFormat timeFormat = TimeFormat.timeAndSeconds}) { + return '[$title] $message'; + } +} diff --git a/packages/talker_drift_logger/lib/talker_drift_logger.dart b/packages/talker_drift_logger/lib/talker_drift_logger.dart new file mode 100644 index 00000000..26c1e037 --- /dev/null +++ b/packages/talker_drift_logger/lib/talker_drift_logger.dart @@ -0,0 +1,4 @@ +library talker_drift_logger; + +export 'talker_drift_logger_interceptor.dart'; +export 'talker_drift_logger_settings.dart'; diff --git a/packages/talker_drift_logger/lib/talker_drift_logger_interceptor.dart b/packages/talker_drift_logger/lib/talker_drift_logger_interceptor.dart new file mode 100644 index 00000000..4cdcf296 --- /dev/null +++ b/packages/talker_drift_logger/lib/talker_drift_logger_interceptor.dart @@ -0,0 +1,316 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_drift_logger/drift_logs.dart'; +import 'package:talker_drift_logger/talker_drift_logger_settings.dart'; + +/// Drift SQL logger on Talker base +class TalkerDriftLogger extends QueryInterceptor { + TalkerDriftLogger({ + Talker? talker, + this.settings = const TalkerDriftLoggerSettings(), + }) { + _talker = talker ?? Talker(); + _talker.settings.registerKeys(const [ + TalkerKey.driftQuery, + TalkerKey.driftResult, + TalkerKey.driftError, + TalkerKey.driftTransaction, + TalkerKey.driftBatch, + ]); + } + + late final Talker _talker; + + /// Settings and customization + final TalkerDriftLoggerSettings settings; + + Future _run( + String description, + FutureOr Function() operation, { + void Function(int durationMs)? onDone, + void Function(Object error, int durationMs)? onError, + }) async { + final sw = Stopwatch()..start(); + try { + final res = await operation(); + sw.stop(); + onDone?.call(sw.elapsedMilliseconds); + return res; + } catch (e) { + sw.stop(); + onError?.call(e, sw.elapsedMilliseconds); + rethrow; + } + } + + bool _accept(String statement, List args) { + if (!settings.enabled) return false; + final filter = settings.statementFilter; + if (filter == null) return true; + try { + return filter(statement, args); + } catch (e, st) { + // Surface filter configuration issues for easier debugging + _talker.handle(e, st, 'Error in Drift statementFilter'); + return false; + } + } + + bool _acceptError(Object error) { + if (!settings.enabled) return false; + final filter = settings.errorFilter; + if (filter == null) return true; + try { + return filter(error); + } catch (e, st) { + // Surface filter configuration issues for easier debugging + _talker.handle(e, st, 'Error in Drift errorFilter'); + return false; + } + } + + @override + TransactionExecutor beginTransaction(QueryExecutor parent) { + if (settings.enabled && settings.printTransaction) { + _talker.logCustom(DriftTransactionLog('begin', settings: settings)); + } + return super.beginTransaction(parent); + } + + @override + Future commitTransaction(TransactionExecutor inner) { + if (!settings.enabled || !settings.printTransaction) { + return super.commitTransaction(inner); + } + return _run( + 'commit', + () => super.commitTransaction(inner), + onDone: (ms) => _talker.logCustom( + DriftTransactionLog('commit', settings: settings, durationMs: ms), + ), + onError: (err, ms) { + if (_acceptError(err)) { + _talker.logCustom( + DriftErrorLog('commit', + settings: settings, dbError: err, durationMs: ms), + ); + } + }, + ); + } + + @override + Future rollbackTransaction(TransactionExecutor inner) { + if (!settings.enabled || !settings.printTransaction) { + return super.rollbackTransaction(inner); + } + return _run( + 'rollback', + () => super.rollbackTransaction(inner), + onDone: (ms) => _talker.logCustom( + DriftTransactionLog('rollback', settings: settings, durationMs: ms), + ), + onError: (err, ms) { + if (_acceptError(err)) { + _talker.logCustom( + DriftErrorLog('rollback', + settings: settings, dbError: err, durationMs: ms), + ); + } + }, + ); + } + + @override + Future runBatched( + QueryExecutor executor, BatchedStatements statements) { + if (settings.enabled && settings.printBatch) { + _talker.logCustom( + DriftBatchLog('batch with ${statements.statements.length} statements', + settings: settings), + ); + } + return super.runBatched(executor, statements); + } + + @override + Future runInsert( + QueryExecutor executor, + String statement, + List args, + ) { + final accepted = _accept(statement, args); + if (accepted) { + _talker + .logCustom(DriftQueryLog(statement, args: args, settings: settings)); + } + return _run( + statement, + () => executor.runInsert(statement, args), + onDone: (ms) { + if (accepted) { + // Using affected to store inserted row id (for visibility) + _talker.logCustom( + DriftResultLog(statement, settings: settings, durationMs: ms), + ); + } + }, + onError: (err, ms) { + if (accepted && _acceptError(err)) { + _talker.logCustom( + DriftErrorLog(statement, + settings: settings, dbError: err, args: args, durationMs: ms), + ); + } + }, + ); + } + + @override + Future runUpdate( + QueryExecutor executor, + String statement, + List args, + ) { + final accepted = _accept(statement, args); + if (accepted) { + _talker + .logCustom(DriftQueryLog(statement, args: args, settings: settings)); + } + return _run( + statement, + () => executor.runUpdate(statement, args), + onDone: (ms) => accepted + ? _talker.logCustom( + DriftResultLog( + statement, + settings: settings, + durationMs: ms, + ), + ) + : null, + onError: (err, ms) { + if (accepted && _acceptError(err)) { + _talker.logCustom( + DriftErrorLog(statement, + settings: settings, dbError: err, args: args, durationMs: ms), + ); + } + }, + ); + } + + @override + Future runDelete( + QueryExecutor executor, + String statement, + List args, + ) { + final accepted = _accept(statement, args); + if (accepted) { + _talker + .logCustom(DriftQueryLog(statement, args: args, settings: settings)); + } + return _run( + statement, + () => executor.runDelete(statement, args), + onDone: (ms) => accepted + ? _talker.logCustom( + DriftResultLog( + statement, + settings: settings, + durationMs: ms, + ), + ) + : null, + onError: (err, ms) { + if (accepted && _acceptError(err)) { + _talker.logCustom( + DriftErrorLog(statement, + settings: settings, dbError: err, args: args, durationMs: ms), + ); + } + }, + ); + } + + @override + Future runCustom( + QueryExecutor executor, + String statement, + List args, + ) { + final accepted = _accept(statement, args); + if (accepted) { + _talker + .logCustom(DriftQueryLog(statement, args: args, settings: settings)); + } + return _run( + statement, + () => executor.runCustom(statement, args), + onDone: (ms) => accepted + ? _talker.logCustom( + DriftResultLog( + statement, + settings: settings, + durationMs: ms, + ), + ) + : null, + onError: (err, ms) { + if (accepted && _acceptError(err)) { + _talker.logCustom( + DriftErrorLog(statement, + settings: settings, dbError: err, args: args, durationMs: ms), + ); + } + }, + ); + } + + @override + Future>> runSelect( + QueryExecutor executor, + String statement, + List args, + ) async { + final accepted = _accept(statement, args); + if (accepted) { + _talker + .logCustom(DriftQueryLog(statement, args: args, settings: settings)); + } + final sw = Stopwatch()..start(); + try { + final rows = await executor.runSelect(statement, args); + sw.stop(); + if (accepted) { + _talker.logCustom( + DriftResultLog( + statement, + settings: settings, + durationMs: sw.elapsedMilliseconds, + rowCount: rows.length, + rows: rows, + ), + ); + } + return rows; + } catch (err) { + sw.stop(); + if (accepted && _acceptError(err)) { + _talker.logCustom( + DriftErrorLog( + statement, + settings: settings, + dbError: err, + args: args, + durationMs: sw.elapsedMilliseconds, + ), + ); + } + rethrow; + } + } +} diff --git a/packages/talker_drift_logger/lib/talker_drift_logger_settings.dart b/packages/talker_drift_logger/lib/talker_drift_logger_settings.dart new file mode 100644 index 00000000..5df4631d --- /dev/null +++ b/packages/talker_drift_logger/lib/talker_drift_logger_settings.dart @@ -0,0 +1,113 @@ +import 'package:talker/talker.dart' show AnsiPen, LogLevel; + +/// Settings and customization for TalkerDriftLogger +class TalkerDriftLoggerSettings { + const TalkerDriftLoggerSettings({ + this.enabled = true, + this.logLevel = LogLevel.debug, + this.printArgs = true, + this.printResults = true, + this.resultRowLimit = 50, + this.resultMaxChars = 2000, + this.printTransaction = true, + this.printBatch = true, + this.obfuscateColumns = const {}, + this.obfuscatePatterns = const {}, + this.queryPen, + this.resultPen, + this.errorPen, + this.transactionPen, + this.batchPen, + this.statementFilter, + this.errorFilter, + this.resultPrinter, + this.argsPrinter, + }); + + /// Enable/disable logging + final bool enabled; + + /// Common log level for non-error logs + final LogLevel logLevel; + + /// Print SQL arguments + final bool printArgs; + + /// Print SELECT results + final bool printResults; + + /// Limit number of rows printed for SELECT results (null - unlimited) + final int? resultRowLimit; + + /// Limit maximum characters printed for results (null - unlimited) + final int? resultMaxChars; + + /// Log begin/commit/rollback + final bool printTransaction; + + /// Log runBatched + final bool printBatch; + + /// Columns to obfuscate in result rows (case-insensitive) + final Set obfuscateColumns; + + /// Patterns to obfuscate in args / results stringified values + final Set obfuscatePatterns; + + /// Custom console colors + final AnsiPen? queryPen; + final AnsiPen? resultPen; + final AnsiPen? errorPen; + final AnsiPen? transactionPen; + final AnsiPen? batchPen; + + /// Filters / formatters + final bool Function(String statement, List args)? statementFilter; + final bool Function(Object error)? errorFilter; + final String Function(List> rows)? resultPrinter; + final String Function(List args)? argsPrinter; + + TalkerDriftLoggerSettings copyWith({ + bool? enabled, + LogLevel? logLevel, + bool? printArgs, + bool? printResults, + int? resultRowLimit, + int? resultMaxChars, + bool? printTransaction, + bool? printBatch, + Set? obfuscateColumns, + Set? obfuscatePatterns, + AnsiPen? queryPen, + AnsiPen? resultPen, + AnsiPen? errorPen, + AnsiPen? transactionPen, + AnsiPen? batchPen, + bool Function(String statement, List args)? statementFilter, + bool Function(Object error)? errorFilter, + String Function(List> rows)? resultPrinter, + String Function(List args)? argsPrinter, + }) { + return TalkerDriftLoggerSettings( + enabled: enabled ?? this.enabled, + logLevel: logLevel ?? this.logLevel, + printArgs: printArgs ?? this.printArgs, + printResults: printResults ?? this.printResults, + resultRowLimit: resultRowLimit ?? this.resultRowLimit, + resultMaxChars: resultMaxChars ?? this.resultMaxChars, + printTransaction: printTransaction ?? this.printTransaction, + printBatch: printBatch ?? this.printBatch, + obfuscateColumns: obfuscateColumns ?? this.obfuscateColumns, + obfuscatePatterns: obfuscatePatterns ?? this.obfuscatePatterns, + queryPen: queryPen ?? this.queryPen, + resultPen: resultPen ?? this.resultPen, + errorPen: errorPen ?? this.errorPen, + transactionPen: transactionPen ?? this.transactionPen, + batchPen: batchPen ?? this.batchPen, + statementFilter: statementFilter ?? this.statementFilter, + errorFilter: errorFilter ?? this.errorFilter, + resultPrinter: resultPrinter ?? this.resultPrinter, + argsPrinter: argsPrinter ?? this.argsPrinter, + ); + } +} diff --git a/packages/talker_drift_logger/pubspec.yaml b/packages/talker_drift_logger/pubspec.yaml new file mode 100644 index 00000000..45a66db0 --- /dev/null +++ b/packages/talker_drift_logger/pubspec.yaml @@ -0,0 +1,26 @@ +name: talker_drift_logger +description: Lightweight and customizable Drift SQL logger on talker base +version: 5.0.0 + +homepage: https://github.com/Frezyx/talker +repository: https://github.com/Frezyx/talker +issue_tracker: https://github.com/Frezyx/talker/issues + +topics: + - database + - drift + - sqlite + - logging + - log + +environment: + sdk: ">=2.15.0 <4.0.0" + +dependencies: + talker: ^5.0.0 + drift: ^2.17.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.23.1 + sqlite3: ^2.4.0 diff --git a/packages/talker_drift_logger/pubspec_overrides.yaml b/packages/talker_drift_logger/pubspec_overrides.yaml new file mode 100644 index 00000000..952518c7 --- /dev/null +++ b/packages/talker_drift_logger/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +dependency_overrides: + talker: + path: + ../talker diff --git a/packages/talker_drift_logger/test/interceptor_test.dart b/packages/talker_drift_logger/test/interceptor_test.dart new file mode 100644 index 00000000..cd694e02 --- /dev/null +++ b/packages/talker_drift_logger/test/interceptor_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:talker/talker.dart'; +import 'package:test/test.dart'; +import 'package:talker_drift_logger/talker_drift_logger.dart'; + +// Minimal database to open the executor properly +class _DummyDb extends GeneratedDatabase { + _DummyDb(QueryExecutor e) : super(e); + + @override + int get schemaVersion => 1; + + @override + Iterable> get allTables => const []; + + @override + List get allSchemaEntities => const []; +} + +void main() { + group('TalkerDriftLogger', () { + test('logs query and result for SELECT', () async { + final talker = Talker(); + final logs = []; + final sub = talker.stream.listen(logs.add); + + final executor = NativeDatabase.memory() + .interceptWith(TalkerDriftLogger(talker: talker)); + final db = _DummyDb(executor); + + // schema + await db.customStatement( + 'CREATE TABLE users (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT);'); + + // insert + await db.customStatement("INSERT INTO users (name) VALUES ('Alice')"); + + // select + final rows = await db.customSelect('SELECT * FROM users').get(); + expect(rows, isNotEmpty); + + // Allow stream to emit + await Future.delayed(const Duration(milliseconds: 5)); + + // There must be at least one query and one result log + final hasQuery = + logs.whereType().any((e) => e.key == 'drift-query'); + final hasResult = + logs.whereType().any((e) => e.key == 'drift-result'); + expect(hasQuery, isTrue); + expect(hasResult, isTrue); + + await sub.cancel(); + }); + + test('logs error on invalid select', () async { + final talker = Talker(); + final logs = []; + final sub = talker.stream.listen(logs.add); + final executor = NativeDatabase.memory() + .interceptWith(TalkerDriftLogger(talker: talker)); + final db = _DummyDb(executor); + + try { + await db.customSelect('SELECT * FROM missing_table').get(); + fail('Expected an error'); + } catch (_) { + // expected + } + + // Allow stream to emit + await Future.delayed(const Duration(milliseconds: 5)); + + final hasError = + logs.whereType().any((e) => e.key == 'drift-error'); + expect(hasError, isTrue); + + await sub.cancel(); + }); + }); +} diff --git a/packages/talker_flutter/lib/src/ui/theme/talker_screen_theme.dart b/packages/talker_flutter/lib/src/ui/theme/talker_screen_theme.dart index ea4ea276..12b61091 100644 --- a/packages/talker_flutter/lib/src/ui/theme/talker_screen_theme.dart +++ b/packages/talker_flutter/lib/src/ui/theme/talker_screen_theme.dart @@ -70,6 +70,13 @@ final _defaultColors = { TalkerKey.blocClose: const Color(0xFFFF005F), TalkerKey.blocCreate: const Color.fromARGB(255, 120, 230, 129), + /// Drift section + TalkerKey.driftError: const Color.fromARGB(255, 239, 83, 80), + TalkerKey.driftQuery: const Color(0xFFF602C1), + TalkerKey.driftResult: const Color(0xFF26FF3C), + TalkerKey.driftTransaction: const Color(0xFFAF5FFF), + TalkerKey.driftBatch: const Color(0xFF56FEA8), + /// Flutter section TalkerKey.route: const Color(0xFFAF5FFF), }; diff --git a/packages/talker_flutter/pubspec.yaml b/packages/talker_flutter/pubspec.yaml index 0d247768..f7541b13 100644 --- a/packages/talker_flutter/pubspec.yaml +++ b/packages/talker_flutter/pubspec.yaml @@ -13,8 +13,8 @@ topics: - ui environment: - sdk: '>=3.6.0 <4.0.0' - flutter: '>=1.17.0' + sdk: ">=3.6.0 <4.0.0" + flutter: ">=1.17.0" dependencies: flutter: @@ -26,7 +26,6 @@ dependencies: share_plus: ^11.0.0 web: ^1.1.0 - dev_dependencies: flutter_test: sdk: flutter