From 722c4ce787a9bd0b5d393e01f47ca609967dbe7c Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 27 Nov 2025 11:59:55 +0300 Subject: [PATCH] feat: add talker_graphql_logger package - Add TalkerGraphQLLink for logging GraphQL operations - Support for queries, mutations, and subscriptions - Request/response duration tracking - Variable obfuscation for sensitive fields (password, token, etc.) - Configurable logging settings - Custom pen colors for different log types - Request/response/error filtering - Add TalkerKey constants for GraphQL logs - Comprehensive test coverage (20 tests) - Example and documentation --- packages/talker/lib/src/talker_key.dart | 6 + packages/talker_graphql_logger/.gitignore | 46 +++ packages/talker_graphql_logger/CHANGELOG.md | 11 + packages/talker_graphql_logger/LICENSE | 21 + packages/talker_graphql_logger/README.md | 181 ++++++++ .../analysis_options.yaml | 31 ++ .../example/console_demo.dart | 196 +++++++++ .../example/lib/main.dart | 59 +++ .../example/pubspec.yaml | 15 + .../lib/src/graphql_logs.dart | 379 +++++++++++++++++ .../lib/src/talker_graphql_link.dart | 289 +++++++++++++ .../src/talker_graphql_logger_settings.dart | 146 +++++++ .../lib/talker_graphql_logger.dart | 3 + packages/talker_graphql_logger/pubspec.yaml | 28 ++ .../test/talker_graphql_logger_test.dart | 389 ++++++++++++++++++ 15 files changed, 1800 insertions(+) create mode 100644 packages/talker_graphql_logger/.gitignore create mode 100644 packages/talker_graphql_logger/CHANGELOG.md create mode 100644 packages/talker_graphql_logger/LICENSE create mode 100644 packages/talker_graphql_logger/README.md create mode 100644 packages/talker_graphql_logger/analysis_options.yaml create mode 100644 packages/talker_graphql_logger/example/console_demo.dart create mode 100644 packages/talker_graphql_logger/example/lib/main.dart create mode 100644 packages/talker_graphql_logger/example/pubspec.yaml create mode 100644 packages/talker_graphql_logger/lib/src/graphql_logs.dart create mode 100644 packages/talker_graphql_logger/lib/src/talker_graphql_link.dart create mode 100644 packages/talker_graphql_logger/lib/src/talker_graphql_logger_settings.dart create mode 100644 packages/talker_graphql_logger/lib/talker_graphql_logger.dart create mode 100644 packages/talker_graphql_logger/pubspec.yaml create mode 100644 packages/talker_graphql_logger/test/talker_graphql_logger_test.dart diff --git a/packages/talker/lib/src/talker_key.dart b/packages/talker/lib/src/talker_key.dart index 6f1651825..c697d9dc2 100644 --- a/packages/talker/lib/src/talker_key.dart +++ b/packages/talker/lib/src/talker_key.dart @@ -40,6 +40,12 @@ abstract class TalkerKey { static const grpcResponse = 'grpc-response'; static const grpcError = 'grpc-error'; + /// GraphQL section + static const graphqlRequest = 'graphql-request'; + static const graphqlResponse = 'graphql-response'; + static const graphqlError = 'graphql-error'; + static const graphqlSubscription = 'graphql-subscription'; + /// Flutter section static const route = 'route'; diff --git a/packages/talker_graphql_logger/.gitignore b/packages/talker_graphql_logger/.gitignore new file mode 100644 index 000000000..aa4aa36b0 --- /dev/null +++ b/packages/talker_graphql_logger/.gitignore @@ -0,0 +1,46 @@ +# 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 +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +pubspec.lock +pubspec_overrides.yaml diff --git a/packages/talker_graphql_logger/CHANGELOG.md b/packages/talker_graphql_logger/CHANGELOG.md new file mode 100644 index 000000000..4b960b79a --- /dev/null +++ b/packages/talker_graphql_logger/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 5.1.1 + +- Initial release +- Added `TalkerGraphQLLink` for logging GraphQL operations +- Support for queries, mutations, and subscriptions +- Configurable logging settings +- Request/response duration tracking +- Variable obfuscation for sensitive fields +- Custom pen colors for request/response/error logs diff --git a/packages/talker_graphql_logger/LICENSE b/packages/talker_graphql_logger/LICENSE new file mode 100644 index 000000000..17a8f2a62 --- /dev/null +++ b/packages/talker_graphql_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_graphql_logger/README.md b/packages/talker_graphql_logger/README.md new file mode 100644 index 000000000..100857576 --- /dev/null +++ b/packages/talker_graphql_logger/README.md @@ -0,0 +1,181 @@ +# talker_graphql_logger + +Lightweight and customizable GraphQL client logger on [talker](https://pub.dev/packages/talker) base. + +## Preview + +talker_graphql_logger preview + +## Getting started + +Add the following dependencies to your `pubspec.yaml`: + +```yaml +dependencies: + talker: ^5.1.1 + talker_graphql_logger: ^5.1.1 + graphql: ^5.2.3 # or gql packages +``` + +## Usage + +### Basic Setup + +```dart +import 'package:talker/talker.dart'; +import 'package:talker_graphql_logger/talker_graphql_logger.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:gql_http_link/gql_http_link.dart'; + +void main() { + // Create talker instance + final talker = Talker(); + + // Create TalkerGraphQLLink + final talkerLink = TalkerGraphQLLink(talker: talker); + + // Create HTTP link + final httpLink = HttpLink('https://api.example.com/graphql'); + + // Compose links + final link = Link.from([ + talkerLink, + httpLink, + ]); + + // Use the link with your GraphQL client +} +``` + +### With graphql_flutter package + +```dart +import 'package:graphql_flutter/graphql_flutter.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_graphql_logger/talker_graphql_logger.dart'; + +void main() async { + await initHiveForFlutter(); + + final talker = Talker(); + + final talkerLink = TalkerGraphQLLink(talker: talker); + final httpLink = HttpLink('https://api.example.com/graphql'); + + final link = Link.from([talkerLink, httpLink]); + + final client = GraphQLClient( + link: link, + cache: GraphQLCache(store: HiveStore()), + ); +} +``` + +## Customization + +### Settings + +```dart +final talkerLink = TalkerGraphQLLink( + talker: talker, + settings: TalkerGraphQLLoggerSettings( + // Enable/disable logging + enabled: true, + + // Log level for GraphQL logs + logLevel: LogLevel.debug, + + // Print variables in request logs + printVariables: true, + + // Print response data + printResponse: true, + + // Print GraphQL query string (can be large) + printQuery: false, + + // Maximum length of response data to print + responseMaxLength: 2000, + + // Fields to obfuscate in variables + obfuscateFields: {'password', 'token', 'apiKey'}, + + // Custom colors for different log types + requestPen: AnsiPen()..xterm(219), + responsePen: AnsiPen()..xterm(46), + errorPen: AnsiPen()..red(), + subscriptionPen: AnsiPen()..cyan(), + ), +); +``` + +### Filtering + +```dart +final talkerLink = TalkerGraphQLLink( + talker: talker, + settings: TalkerGraphQLLoggerSettings( + // Filter which requests to log + requestFilter: (request) { + // Don't log introspection queries + final operationName = request.operation.operationName; + return operationName != 'IntrospectionQuery'; + }, + + // Filter which responses to log + responseFilter: (request, response) { + return true; // Log all responses + }, + + // Filter which errors to log + errorFilter: (request, response, error) { + return true; // Log all errors + }, + ), +); +``` + +## Features + +- ✅ Logs queries, mutations, and subscriptions +- ✅ Request/response duration tracking +- ✅ Variable obfuscation for sensitive data +- ✅ Customizable log colors +- ✅ Request/response/error filtering +- ✅ Truncation for large responses +- ✅ GraphQL error details with path and locations +- ✅ Network error handling + +## Log Types + +The logger creates the following log types: + +| Log Type | TalkerKey | Description | +|----------|-----------|-------------| +| `GraphQLRequestLog` | `graphql-request` | Outgoing GraphQL request | +| `GraphQLResponseLog` | `graphql-response` | Successful response | +| `GraphQLErrorLog` | `graphql-error` | GraphQL or network errors | +| `GraphQLSubscriptionLog` | `graphql-subscription` | Subscription events | + +## Integration with TalkerFlutter + +Use with [talker_flutter](https://pub.dev/packages/talker_flutter) to view logs in a beautiful UI: + +```dart +import 'package:talker_flutter/talker_flutter.dart'; + +// Navigate to logs screen +Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TalkerScreen(talker: talker), + ), +); +``` + +## Additional information + +- [Talker documentation](https://github.com/Frezyx/talker) +- [GraphQL package](https://pub.dev/packages/graphql) +- [GQL packages](https://pub.dev/packages/gql) + +For issues and feature requests, please visit the [GitHub repository](https://github.com/Frezyx/talker/issues). diff --git a/packages/talker_graphql_logger/analysis_options.yaml b/packages/talker_graphql_logger/analysis_options.yaml new file mode 100644 index 000000000..55d594398 --- /dev/null +++ b/packages/talker_graphql_logger/analysis_options.yaml @@ -0,0 +1,31 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/talker_graphql_logger/example/console_demo.dart b/packages/talker_graphql_logger/example/console_demo.dart new file mode 100644 index 000000000..ad9afdbfe --- /dev/null +++ b/packages/talker_graphql_logger/example/console_demo.dart @@ -0,0 +1,196 @@ +// ignore_for_file: avoid_print + +import 'package:gql/language.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_graphql_logger/talker_graphql_logger.dart'; + +void main() async { + final talker = Talker(); + + print('=== TalkerGraphQLLogger Demo ===\n'); + + // Demo 1: Request Log + print('--- Request Log ---'); + final requestLog = GraphQLRequestLog( + 'GetUser', + request: Request( + operation: Operation( + document: parseString( + 'query GetUser(\$id: ID!) { user(id: \$id) { id name email } }', + ), + operationName: 'GetUser', + ), + variables: const {'id': '123'}, + ), + settings: const TalkerGraphQLLoggerSettings(), + ); + talker.logCustom(requestLog); + + print('\n--- Request Log with obfuscated password ---'); + final loginRequestLog = GraphQLRequestLog( + 'Login', + request: Request( + operation: Operation( + document: parseString('mutation Login { login }'), + operationName: 'Login', + ), + variables: const { + 'email': 'user@example.com', + 'password': 'super_secret_123', + 'token': 'abc123xyz', + }, + ), + settings: const TalkerGraphQLLoggerSettings( + obfuscateFields: {'password', 'token'}, + ), + ); + talker.logCustom(loginRequestLog); + + // Demo 2: Response Log + print('\n--- Response Log ---'); + final responseLog = GraphQLResponseLog( + 'GetUser', + request: Request( + operation: Operation( + document: parseString('query GetUser { user { id name } }'), + operationName: 'GetUser', + ), + variables: const {}, + ), + response: const Response( + response: {}, + data: { + 'user': { + 'id': '123', + 'name': 'John Doe', + 'email': 'john@example.com', + }, + }, + ), + durationMs: 142, + settings: const TalkerGraphQLLoggerSettings(), + ); + talker.logCustom(responseLog); + + // Demo 3: Error Log + print('\n--- Error Log (GraphQL Error) ---'); + final errorLog = GraphQLErrorLog( + 'GetUser', + request: Request( + operation: Operation( + document: parseString('query GetUser { user { id } }'), + operationName: 'GetUser', + ), + variables: const {'id': 'invalid'}, + ), + response: const Response( + response: {}, + data: null, + errors: [ + GraphQLError( + message: 'User not found', + path: ['user'], + locations: [ + ErrorLocation(line: 1, column: 15), + ], + ), + ], + ), + durationMs: 45, + settings: const TalkerGraphQLLoggerSettings(), + ); + talker.logCustom(errorLog); + + // Demo 4: Network Error Log + print('\n--- Error Log (Network Error) ---'); + final networkErrorLog = GraphQLErrorLog( + 'GetUser', + request: Request( + operation: Operation( + document: parseString('query GetUser { user { id } }'), + operationName: 'GetUser', + ), + variables: const {}, + ), + linkException: Exception('Connection refused'), + durationMs: 3000, + settings: const TalkerGraphQLLoggerSettings(), + ); + talker.logCustom(networkErrorLog); + + // Demo 5: Subscription Log + print('\n--- Subscription Log ---'); + final subscriptionLog = GraphQLSubscriptionLog( + 'OnNewMessage', + request: Request( + operation: Operation( + document: parseString( + 'subscription OnNewMessage { newMessage { id text } }', + ), + operationName: 'OnNewMessage', + ), + variables: const {}, + ), + response: const Response( + response: {}, + data: { + 'newMessage': { + 'id': '456', + 'text': 'Hello from subscription!', + }, + }, + ), + eventType: GraphQLSubscriptionEventType.data, + settings: const TalkerGraphQLLoggerSettings(), + ); + talker.logCustom(subscriptionLog); + + // Demo 6: Full flow with Link + print('\n--- Full Flow Demo (Link) ---'); + final talkerLink = TalkerGraphQLLink( + talker: talker, + settings: const TalkerGraphQLLoggerSettings( + printVariables: true, + printResponse: true, + ), + ); + + final mockLink = _MockLink(); + final link = talkerLink.concat(mockLink); + + await link + .request( + Request( + operation: Operation( + document: parseString( + 'mutation CreateUser(\$input: CreateUserInput!) { createUser(input: \$input) { id } }', + ), + operationName: 'CreateUser', + ), + variables: const { + 'input': { + 'name': 'Jane Doe', + 'email': 'jane@example.com', + }, + }, + ), + ) + .first; + + print('\n=== Demo Complete ==='); +} + +class _MockLink extends Link { + @override + Stream request(Request request, [NextLink? forward]) async* { + await Future.delayed(const Duration(milliseconds: 50)); + yield const Response( + response: {}, + data: { + 'createUser': {'id': '789'}, + }, + ); + } +} diff --git a/packages/talker_graphql_logger/example/lib/main.dart b/packages/talker_graphql_logger/example/lib/main.dart new file mode 100644 index 000000000..6f1f9405e --- /dev/null +++ b/packages/talker_graphql_logger/example/lib/main.dart @@ -0,0 +1,59 @@ +import 'package:gql/language.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_http_link/gql_http_link.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_graphql_logger/talker_graphql_logger.dart'; + +void main() async { + // Create talker instance + final talker = Talker(); + + // Create TalkerGraphQLLink with custom settings + final talkerLink = TalkerGraphQLLink( + talker: talker, + settings: const TalkerGraphQLLoggerSettings( + printVariables: true, + printResponse: true, + printQuery: false, // Query strings can be large + obfuscateFields: {'password', 'token', 'secret'}, + ), + ); + + // Create the HTTP link + final httpLink = HttpLink('https://api.spacex.land/graphql/'); + + // Compose links + final link = Link.from([talkerLink, httpLink]); + + // Example query + const query = r''' + query GetLaunches($limit: Int!) { + launchesPast(limit: $limit) { + mission_name + launch_date_utc + rocket { + rocket_name + } + } + } + '''; + + // Create request + final request = Request( + operation: Operation( + document: parseString(query), + operationName: 'GetLaunches', + ), + variables: const {'limit': 5}, + ); + + // Execute request + try { + await for (final response in link.request(request)) { + print('Got response: ${response.data}'); + } + } catch (e) { + print('Error: $e'); + } +} diff --git a/packages/talker_graphql_logger/example/pubspec.yaml b/packages/talker_graphql_logger/example/pubspec.yaml new file mode 100644 index 000000000..b49c4828c --- /dev/null +++ b/packages/talker_graphql_logger/example/pubspec.yaml @@ -0,0 +1,15 @@ +name: talker_graphql_logger_example +description: Example of talker_graphql_logger usage +publish_to: "none" + +environment: + sdk: ^3.2.6 + +dependencies: + gql: ^1.0.0 + gql_exec: ^1.0.0 + gql_http_link: ^1.0.0 + gql_link: ^1.0.0 + talker: ^5.1.1 + talker_graphql_logger: + path: ../ diff --git a/packages/talker_graphql_logger/lib/src/graphql_logs.dart b/packages/talker_graphql_logger/lib/src/graphql_logs.dart new file mode 100644 index 000000000..27ee4705f --- /dev/null +++ b/packages/talker_graphql_logger/lib/src/graphql_logs.dart @@ -0,0 +1,379 @@ +import 'dart:convert'; + +import 'package:gql/ast.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:talker/talker.dart'; + +import 'talker_graphql_logger_settings.dart'; + +const _encoder = JsonEncoder.withIndent(' '); +const _obfuscatedValue = '*****'; + +/// Returns the operation type from the request. +String _getOperationType(Request request) { + final definitions = request.operation.document.definitions; + for (final definition in definitions) { + if (definition is OperationDefinitionNode) { + switch (definition.type) { + case OperationType.query: + return 'Query'; + case OperationType.mutation: + return 'Mutation'; + case OperationType.subscription: + return 'Subscription'; + } + } + } + return 'Unknown'; +} + +/// Returns the operation name from the request. +String _getOperationName(Request request) { + final operationName = request.operation.operationName; + if (operationName != null && operationName.isNotEmpty) { + return operationName; + } + + final definitions = request.operation.document.definitions; + for (final definition in definitions) { + if (definition is OperationDefinitionNode) { + final name = definition.name?.value; + if (name != null) return name; + } + } + return 'Anonymous'; +} + +/// Obfuscates sensitive fields in the variables map. +Map _obfuscateVariables( + Map variables, + Set obfuscateFields, +) { + if (obfuscateFields.isEmpty) return variables; + + final result = {}; + final lowerCaseFields = obfuscateFields.map((f) => f.toLowerCase()).toSet(); + + for (final entry in variables.entries) { + if (lowerCaseFields.contains(entry.key.toLowerCase())) { + result[entry.key] = _obfuscatedValue; + } else if (entry.value is Map) { + result[entry.key] = _obfuscateVariables( + entry.value as Map, + obfuscateFields, + ); + } else { + result[entry.key] = entry.value; + } + } + + return result; +} + +/// {@template graphql_request_log} +/// A [TalkerLog] implementation that logs outgoing GraphQL request data. +/// +/// Includes: +/// - Operation name +/// - Operation type (Query/Mutation/Subscription) +/// - Variables (with optional obfuscation) +/// - Query string (optional) +/// {@endtemplate} +class GraphQLRequestLog extends TalkerLog { + /// {@macro graphql_request_log} + GraphQLRequestLog( + super.message, { + required this.request, + required this.settings, + }); + + /// The GraphQL request being logged. + final Request request; + + /// Logger settings. + final TalkerGraphQLLoggerSettings settings; + + @override + AnsiPen get pen => settings.requestPen ?? (AnsiPen()..xterm(219)); + + @override + String get key => TalkerKey.graphqlRequest; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage({ + TimeFormat timeFormat = TimeFormat.timeAndSeconds, + }) { + final operationType = _getOperationType(request); + final operationName = _getOperationName(request); + + var msg = '[$title] [$operationType] $operationName'; + + try { + final variables = request.variables; + if (settings.printVariables && variables.isNotEmpty) { + final obfuscated = _obfuscateVariables( + variables, + settings.obfuscateFields, + ); + final prettyVariables = _encoder.convert(obfuscated); + msg += '\nVariables: $prettyVariables'; + } + + if (settings.printQuery) { + final query = request.operation.document.toString(); + msg += '\nQuery: $query'; + } + } catch (_) { + // Ignore encoding errors + } + + return msg; + } +} + +/// {@template graphql_response_log} +/// A [TalkerLog] implementation that logs successful GraphQL responses. +/// +/// Includes: +/// - Operation name +/// - Operation type +/// - Duration +/// - Response data (optional, with length limit) +/// {@endtemplate} +class GraphQLResponseLog extends TalkerLog { + /// {@macro graphql_response_log} + GraphQLResponseLog( + super.message, { + required this.request, + required this.response, + required this.durationMs, + required this.settings, + }); + + /// The original GraphQL request. + final Request request; + + /// The GraphQL response. + final Response response; + + /// Duration of the request in milliseconds. + final int durationMs; + + /// Logger settings. + final TalkerGraphQLLoggerSettings settings; + + @override + AnsiPen get pen => settings.responsePen ?? (AnsiPen()..xterm(46)); + + @override + String get key => TalkerKey.graphqlResponse; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage({ + TimeFormat timeFormat = TimeFormat.timeAndSeconds, + }) { + final operationType = _getOperationType(request); + final operationName = _getOperationName(request); + + var msg = '[$title] [$operationType] $operationName'; + msg += '\nDuration: $durationMs ms'; + + try { + final data = response.data; + if (settings.printResponse && data != null) { + var prettyData = _encoder.convert(data); + if (prettyData.length > settings.responseMaxLength) { + prettyData = + '${prettyData.substring(0, settings.responseMaxLength)}... [truncated]'; + } + msg += '\nData: $prettyData'; + } + } catch (_) { + // Ignore encoding errors + } + + return msg; + } +} + +/// {@template graphql_error_log} +/// A [TalkerLog] implementation that logs GraphQL errors. +/// +/// Includes: +/// - Operation name +/// - Operation type +/// - Duration +/// - GraphQL errors (from response.errors) +/// - Link exception (network errors) +/// - Variables (for debugging) +/// {@endtemplate} +class GraphQLErrorLog extends TalkerLog { + /// {@macro graphql_error_log} + GraphQLErrorLog( + super.message, { + required this.request, + this.response, + this.linkException, + required this.durationMs, + required this.settings, + }); + + /// The original GraphQL request. + final Request request; + + /// The GraphQL response (may be null for network errors). + final Response? response; + + /// The link exception (for network errors). + final Object? linkException; + + /// Duration of the request in milliseconds. + final int durationMs; + + /// Logger settings. + final TalkerGraphQLLoggerSettings settings; + + @override + AnsiPen get pen => settings.errorPen ?? (AnsiPen()..red()); + + @override + String get key => TalkerKey.graphqlError; + + @override + LogLevel get logLevel => LogLevel.error; + + @override + String generateTextMessage({ + TimeFormat timeFormat = TimeFormat.timeAndSeconds, + }) { + final operationType = _getOperationType(request); + final operationName = _getOperationName(request); + + var msg = '[$title] [$operationType] $operationName'; + msg += '\nDuration: $durationMs ms'; + + // Log GraphQL errors + final errors = response?.errors; + if (errors != null && errors.isNotEmpty) { + msg += '\nGraphQL Errors:'; + for (final error in errors) { + msg += '\n - ${error.message}'; + final locations = error.locations; + if (locations != null && locations.isNotEmpty) { + final loc = locations.first; + msg += ' (line: ${loc.line}, column: ${loc.column})'; + } + final path = error.path; + if (path != null && path.isNotEmpty) { + msg += '\n Path: ${path.join(' > ')}'; + } + } + } + + // Log link exception + if (linkException != null) { + msg += '\nException: $linkException'; + } + + // Log variables for debugging + try { + final variables = request.variables; + if (settings.printVariables && variables.isNotEmpty) { + final obfuscated = _obfuscateVariables( + variables, + settings.obfuscateFields, + ); + final prettyVariables = _encoder.convert(obfuscated); + msg += '\nVariables: $prettyVariables'; + } + } catch (_) { + // Ignore encoding errors + } + + return msg; + } +} + +/// {@template graphql_subscription_log} +/// A [TalkerLog] implementation that logs GraphQL subscription events. +/// +/// Includes: +/// - Operation name +/// - Event type (data/error/done) +/// - Data preview (optional) +/// {@endtemplate} +class GraphQLSubscriptionLog extends TalkerLog { + /// {@macro graphql_subscription_log} + GraphQLSubscriptionLog( + super.message, { + required this.request, + this.response, + this.eventType = GraphQLSubscriptionEventType.data, + required this.settings, + }); + + /// The original GraphQL request. + final Request request; + + /// The GraphQL response (for data events). + final Response? response; + + /// The type of subscription event. + final GraphQLSubscriptionEventType eventType; + + /// Logger settings. + final TalkerGraphQLLoggerSettings settings; + + @override + AnsiPen get pen => settings.subscriptionPen ?? (AnsiPen()..cyan()); + + @override + String get key => TalkerKey.graphqlSubscription; + + @override + LogLevel get logLevel => settings.logLevel; + + @override + String generateTextMessage({ + TimeFormat timeFormat = TimeFormat.timeAndSeconds, + }) { + final operationName = _getOperationName(request); + + var msg = '[$title] [Subscription] $operationName'; + msg += '\nEvent: ${eventType.name}'; + + try { + final data = response?.data; + if (settings.printResponse && data != null) { + var prettyData = _encoder.convert(data); + if (prettyData.length > settings.responseMaxLength) { + prettyData = + '${prettyData.substring(0, settings.responseMaxLength)}... [truncated]'; + } + msg += '\nData: $prettyData'; + } + } catch (_) { + // Ignore encoding errors + } + + return msg; + } +} + +/// Types of subscription events. +enum GraphQLSubscriptionEventType { + /// New data received. + data, + + /// Error occurred. + error, + + /// Subscription completed. + done, +} diff --git a/packages/talker_graphql_logger/lib/src/talker_graphql_link.dart b/packages/talker_graphql_logger/lib/src/talker_graphql_link.dart new file mode 100644 index 000000000..ef6ce3a63 --- /dev/null +++ b/packages/talker_graphql_logger/lib/src/talker_graphql_link.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:gql/ast.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:talker/talker.dart'; + +import 'graphql_logs.dart'; +import 'talker_graphql_logger_settings.dart'; + +/// {@template talker_graphql_link} +/// A GraphQL [Link] implementation that logs GraphQL operations +/// using the [Talker] logger. +/// +/// This link captures: +/// - Request data (operation name, type, variables) +/// - Response data (with duration) +/// - GraphQL errors and network errors +/// - Subscription events +/// +/// Example usage: +/// ```dart +/// final talker = Talker(); +/// +/// final link = Link.from([ +/// TalkerGraphQLLink(talker: talker), +/// authLink, +/// HttpLink('https://api.example.com/graphql'), +/// ]); +/// +/// final client = GraphQLClient( +/// link: link, +/// cache: GraphQLCache(), +/// ); +/// ``` +/// {@endtemplate} +class TalkerGraphQLLink extends Link { + /// {@macro talker_graphql_link} + /// + /// If a [talker] instance is not provided, a default one will be created. + TalkerGraphQLLink({ + Talker? talker, + this.settings = const TalkerGraphQLLoggerSettings(), + }) { + _talker = talker ?? Talker(); + _talker.settings.registerKeys(const [ + TalkerKey.graphqlRequest, + TalkerKey.graphqlResponse, + TalkerKey.graphqlError, + TalkerKey.graphqlSubscription, + ]); + } + + late final Talker _talker; + + /// [TalkerGraphQLLink] settings and customization. + final TalkerGraphQLLoggerSettings settings; + + @override + Stream request(Request request, [NextLink? forward]) { + if (!settings.enabled) { + return forward!(request); + } + + // Check request filter + final accepted = settings.requestFilter?.call(request) ?? true; + if (!accepted) { + return forward!(request); + } + + final isSubscription = _isSubscription(request); + + if (isSubscription) { + return _handleSubscription(request, forward!); + } else { + return _handleOperation(request, forward!); + } + } + + /// Checks if the request is a subscription. + bool _isSubscription(Request request) { + final definitions = request.operation.document.definitions; + for (final definition in definitions) { + if (definition is OperationDefinitionNode) { + return definition.type == OperationType.subscription; + } + } + return false; + } + + /// Handles regular query/mutation operations. + Stream _handleOperation(Request request, NextLink forward) async* { + final startTime = DateTime.now(); + + // Log request + try { + _talker.logCustom( + GraphQLRequestLog( + _getOperationName(request), + request: request, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + + try { + await for (final response in forward(request)) { + final durationMs = DateTime.now().difference(startTime).inMilliseconds; + + // Check if response has errors + final hasErrors = + response.errors != null && response.errors!.isNotEmpty; + + if (hasErrors) { + // Check error filter + final errorAccepted = + settings.errorFilter?.call(request, response, null) ?? true; + if (errorAccepted) { + try { + _talker.logCustom( + GraphQLErrorLog( + _getOperationName(request), + request: request, + response: response, + durationMs: durationMs, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } + } else { + // Check response filter + final responseAccepted = + settings.responseFilter?.call(request, response) ?? true; + if (responseAccepted) { + try { + _talker.logCustom( + GraphQLResponseLog( + _getOperationName(request), + request: request, + response: response, + durationMs: durationMs, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } + } + + yield response; + } + } catch (e) { + final durationMs = DateTime.now().difference(startTime).inMilliseconds; + + // Check error filter + final errorAccepted = + settings.errorFilter?.call(request, null, e) ?? true; + if (errorAccepted) { + try { + _talker.logCustom( + GraphQLErrorLog( + _getOperationName(request), + request: request, + linkException: e, + durationMs: durationMs, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } + + rethrow; + } + } + + /// Handles subscription operations. + Stream _handleSubscription( + Request request, + NextLink forward, + ) async* { + // Log subscription start + try { + _talker.logCustom( + GraphQLRequestLog( + _getOperationName(request), + request: request, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + + try { + await for (final response in forward(request)) { + final hasErrors = + response.errors != null && response.errors!.isNotEmpty; + + if (hasErrors) { + try { + _talker.logCustom( + GraphQLSubscriptionLog( + _getOperationName(request), + request: request, + response: response, + eventType: GraphQLSubscriptionEventType.error, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } else { + try { + _talker.logCustom( + GraphQLSubscriptionLog( + _getOperationName(request), + request: request, + response: response, + eventType: GraphQLSubscriptionEventType.data, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } + + yield response; + } + + // Log subscription done + try { + _talker.logCustom( + GraphQLSubscriptionLog( + _getOperationName(request), + request: request, + eventType: GraphQLSubscriptionEventType.done, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + } catch (e) { + // Log subscription error + try { + _talker.logCustom( + GraphQLErrorLog( + _getOperationName(request), + request: request, + linkException: e, + durationMs: 0, + settings: settings, + ), + ); + } catch (_) { + // Ignore logging errors + } + + rethrow; + } + } + + /// Returns the operation name from the request. + String _getOperationName(Request request) { + final operationName = request.operation.operationName; + if (operationName != null && operationName.isNotEmpty) { + return operationName; + } + + final definitions = request.operation.document.definitions; + for (final definition in definitions) { + if (definition is OperationDefinitionNode) { + final name = definition.name?.value; + if (name != null) return name; + } + } + return 'Anonymous'; + } +} diff --git a/packages/talker_graphql_logger/lib/src/talker_graphql_logger_settings.dart b/packages/talker_graphql_logger/lib/src/talker_graphql_logger_settings.dart new file mode 100644 index 000000000..dd0e8e0cf --- /dev/null +++ b/packages/talker_graphql_logger/lib/src/talker_graphql_logger_settings.dart @@ -0,0 +1,146 @@ +import 'package:gql_exec/gql_exec.dart'; +import 'package:talker/talker.dart'; + +/// Callback for filtering GraphQL requests. +typedef GraphQLRequestFilter = bool Function(Request request); + +/// Callback for filtering GraphQL responses. +typedef GraphQLResponseFilter = + bool Function(Request request, Response response); + +/// Callback for filtering GraphQL errors. +typedef GraphQLErrorFilter = + bool Function(Request request, Response? response, Object? error); + +/// [TalkerGraphQLLink] settings and customization. +class TalkerGraphQLLoggerSettings { + const TalkerGraphQLLoggerSettings({ + this.enabled = true, + this.logLevel = LogLevel.debug, + this.printVariables = true, + this.printResponse = true, + this.printQuery = false, + this.responseMaxLength = 2000, + this.obfuscateFields = const {}, + this.requestPen, + this.responsePen, + this.errorPen, + this.subscriptionPen, + this.requestFilter, + this.responseFilter, + this.errorFilter, + }); + + /// Print GraphQL logger if true. + final bool enabled; + + /// LogLevel of all GraphQL logs. By default set as debug. + final LogLevel logLevel; + + /// Print variables in request log if true. + final bool printVariables; + + /// Print response data if true. + final bool printResponse; + + /// Print GraphQL query string if true. + /// Note: Query strings can be large, so this is disabled by default. + final bool printQuery; + + /// Maximum length of response data to print. + /// Data longer than this will be truncated. + final int responseMaxLength; + + /// Set of field names to obfuscate in variables. + /// Values of these fields will be replaced with '*****'. + /// Case-insensitive comparison. + final Set obfuscateFields; + + /// Field to set custom GraphQL request console logs color. + ///```dart + /// // Violet color + /// final violetPen = AnsiPen()..xterm(219); + /// + /// // Blue color + /// final bluePen = AnsiPen()..blue(); + ///``` + /// More details in [AnsiPen] docs. + final AnsiPen? requestPen; + + /// Field to set custom GraphQL response console logs color. + ///```dart + /// // Green color + /// final greenPen = AnsiPen()..xterm(46); + /// + /// // Blue color + /// final bluePen = AnsiPen()..blue(); + ///``` + /// More details in [AnsiPen] docs. + final AnsiPen? responsePen; + + /// Field to set custom GraphQL error console logs color. + ///```dart + /// // Red color + /// final redPen = AnsiPen()..red(); + /// + /// // Blue color + /// final bluePen = AnsiPen()..blue(); + ///``` + /// More details in [AnsiPen] docs. + final AnsiPen? errorPen; + + /// Field to set custom GraphQL subscription console logs color. + ///```dart + /// // Cyan color + /// final cyanPen = AnsiPen()..cyan(); + ///``` + /// More details in [AnsiPen] docs. + final AnsiPen? subscriptionPen; + + /// For request filtering. + /// You can add your custom logic to log only specific GraphQL requests. + final GraphQLRequestFilter? requestFilter; + + /// For response filtering. + /// You can add your custom logic to log only specific GraphQL responses. + final GraphQLResponseFilter? responseFilter; + + /// For error filtering. + /// You can add your custom logic to log only specific GraphQL errors. + final GraphQLErrorFilter? errorFilter; + + /// Creates a copy of this settings with the given fields replaced. + TalkerGraphQLLoggerSettings copyWith({ + bool? enabled, + LogLevel? logLevel, + bool? printVariables, + bool? printResponse, + bool? printQuery, + int? responseMaxLength, + Set? obfuscateFields, + AnsiPen? requestPen, + AnsiPen? responsePen, + AnsiPen? errorPen, + AnsiPen? subscriptionPen, + GraphQLRequestFilter? requestFilter, + GraphQLResponseFilter? responseFilter, + GraphQLErrorFilter? errorFilter, + }) { + return TalkerGraphQLLoggerSettings( + enabled: enabled ?? this.enabled, + logLevel: logLevel ?? this.logLevel, + printVariables: printVariables ?? this.printVariables, + printResponse: printResponse ?? this.printResponse, + printQuery: printQuery ?? this.printQuery, + responseMaxLength: responseMaxLength ?? this.responseMaxLength, + obfuscateFields: obfuscateFields ?? this.obfuscateFields, + requestPen: requestPen ?? this.requestPen, + responsePen: responsePen ?? this.responsePen, + errorPen: errorPen ?? this.errorPen, + subscriptionPen: subscriptionPen ?? this.subscriptionPen, + requestFilter: requestFilter ?? this.requestFilter, + responseFilter: responseFilter ?? this.responseFilter, + errorFilter: errorFilter ?? this.errorFilter, + ); + } +} diff --git a/packages/talker_graphql_logger/lib/talker_graphql_logger.dart b/packages/talker_graphql_logger/lib/talker_graphql_logger.dart new file mode 100644 index 000000000..81d6a8cd8 --- /dev/null +++ b/packages/talker_graphql_logger/lib/talker_graphql_logger.dart @@ -0,0 +1,3 @@ +export 'src/graphql_logs.dart'; +export 'src/talker_graphql_link.dart'; +export 'src/talker_graphql_logger_settings.dart'; diff --git a/packages/talker_graphql_logger/pubspec.yaml b/packages/talker_graphql_logger/pubspec.yaml new file mode 100644 index 000000000..c389ce083 --- /dev/null +++ b/packages/talker_graphql_logger/pubspec.yaml @@ -0,0 +1,28 @@ +name: talker_graphql_logger +description: > + Lightweight and customizable GraphQL client logger on talker base. + Logs queries, mutations, subscriptions with request/response details. +version: 5.1.1 +homepage: https://github.com/Frezyx/talker +repository: https://github.com/Frezyx/talker +issue_tracker: https://github.com/Frezyx/talker/issues + +topics: + - graphql + - logging + - log + - network + - flutter + +environment: + sdk: ^3.2.6 + +dependencies: + gql: ^1.0.0 + gql_exec: ^1.0.0 + gql_link: ^1.0.0 + talker: ^5.1.1 + +dev_dependencies: + lints: ^5.1.1 + test: ^1.24.0 diff --git a/packages/talker_graphql_logger/test/talker_graphql_logger_test.dart b/packages/talker_graphql_logger/test/talker_graphql_logger_test.dart new file mode 100644 index 000000000..de4979681 --- /dev/null +++ b/packages/talker_graphql_logger/test/talker_graphql_logger_test.dart @@ -0,0 +1,389 @@ +import 'package:gql/language.dart'; +import 'package:gql_exec/gql_exec.dart'; +import 'package:gql_link/gql_link.dart'; +import 'package:talker/talker.dart'; +import 'package:talker_graphql_logger/talker_graphql_logger.dart'; +import 'package:test/test.dart'; + +void main() { + group('TalkerGraphQLLoggerSettings', () { + test('should have correct default values', () { + const settings = TalkerGraphQLLoggerSettings(); + + expect(settings.enabled, isTrue); + expect(settings.logLevel, equals(LogLevel.debug)); + expect(settings.printVariables, isTrue); + expect(settings.printResponse, isTrue); + expect(settings.printQuery, isFalse); + expect(settings.responseMaxLength, equals(2000)); + expect(settings.obfuscateFields, isEmpty); + }); + + test('copyWith should override values correctly', () { + const settings = TalkerGraphQLLoggerSettings(); + final newSettings = settings.copyWith( + enabled: false, + printVariables: false, + obfuscateFields: {'password'}, + ); + + expect(newSettings.enabled, isFalse); + expect(newSettings.printVariables, isFalse); + expect(newSettings.obfuscateFields, contains('password')); + // Original values should be preserved + expect(newSettings.printResponse, isTrue); + expect(newSettings.responseMaxLength, equals(2000)); + }); + }); + + group('TalkerGraphQLLink', () { + late Talker talker; + late List logs; + + setUp(() { + logs = []; + talker = Talker(); + talker.stream.listen((log) => logs.add(log)); + }); + + tearDown(() { + talker.cleanHistory(); + }); + + Request createRequest({ + String query = 'query GetUser { user { id name } }', + String? operationName, + Map variables = const {}, + }) { + return Request( + operation: Operation( + document: parseString(query), + operationName: operationName, + ), + variables: variables, + ); + } + + test('should create instance with default talker', () { + final link = TalkerGraphQLLink(); + expect(link, isNotNull); + }); + + test('should create instance with custom talker', () { + final link = TalkerGraphQLLink(talker: talker); + expect(link, isNotNull); + }); + + test('should not log when disabled', () async { + final link = TalkerGraphQLLink( + talker: talker, + settings: const TalkerGraphQLLoggerSettings(enabled: false), + ); + + final terminalLink = MockTerminalLink(); + final composedLink = link.concat(terminalLink); + + final request = createRequest(); + await composedLink.request(request).first; + + expect(logs, isEmpty); + }); + + test('should log request and response', () async { + final link = TalkerGraphQLLink(talker: talker); + final terminalLink = MockTerminalLink(); + final composedLink = link.concat(terminalLink); + + final request = createRequest( + query: 'query GetUser(\$id: ID!) { user(id: \$id) { id name } }', + operationName: 'GetUser', + variables: {'id': '123'}, + ); + + await composedLink.request(request).first; + + // Wait for async logging + await Future.delayed(const Duration(milliseconds: 50)); + + expect(logs.length, equals(2)); + expect(logs[0], isA()); + expect(logs[1], isA()); + }); + + test('should log GraphQL errors', () async { + final link = TalkerGraphQLLink(talker: talker); + final terminalLink = MockTerminalLink( + response: const Response( + response: {}, + data: null, + errors: [ + GraphQLError(message: 'User not found'), + ], + ), + ); + final composedLink = link.concat(terminalLink); + + final request = createRequest(operationName: 'GetUser'); + await composedLink.request(request).first; + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(logs.length, equals(2)); + expect(logs[0], isA()); + expect(logs[1], isA()); + }); + + test('should filter requests', () async { + final link = TalkerGraphQLLink( + talker: talker, + settings: TalkerGraphQLLoggerSettings( + requestFilter: (request) { + return request.operation.operationName != 'IgnoreMe'; + }, + ), + ); + final terminalLink = MockTerminalLink(); + final composedLink = link.concat(terminalLink); + + // This request should be ignored + final ignoredRequest = createRequest(operationName: 'IgnoreMe'); + await composedLink.request(ignoredRequest).first; + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(logs, isEmpty); + + // This request should be logged + final loggedRequest = createRequest(operationName: 'LogMe'); + await composedLink.request(loggedRequest).first; + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(logs.length, equals(2)); + }); + }); + + group('GraphQLRequestLog', () { + test('should have correct key', () { + final log = GraphQLRequestLog( + 'TestOperation', + request: createSimpleRequest(), + settings: const TalkerGraphQLLoggerSettings(), + ); + + expect(log.key, equals(TalkerKey.graphqlRequest)); + }); + + test('should generate message with operation info', () { + final request = Request( + operation: Operation( + document: parseString('query GetUser { user { id } }'), + operationName: 'GetUser', + ), + variables: const {'id': '123'}, + ); + + final log = GraphQLRequestLog( + 'GetUser', + request: request, + settings: const TalkerGraphQLLoggerSettings(), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('[Query]')); + expect(message, contains('GetUser')); + expect(message, contains('Variables')); + expect(message, contains('"id": "123"')); + }); + + test('should obfuscate sensitive fields', () { + final request = Request( + operation: Operation( + document: parseString('mutation Login { login }'), + operationName: 'Login', + ), + variables: const { + 'email': 'test@test.com', + 'password': 'secret123', + }, + ); + + final log = GraphQLRequestLog( + 'Login', + request: request, + settings: const TalkerGraphQLLoggerSettings( + obfuscateFields: {'password'}, + ), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('"email": "test@test.com"')); + expect(message, contains('"password": "*****"')); + expect(message, isNot(contains('secret123'))); + }); + }); + + group('GraphQLResponseLog', () { + test('should have correct key', () { + final log = GraphQLResponseLog( + 'TestOperation', + request: createSimpleRequest(), + response: const Response(response: {}, data: {'user': 'test'}), + durationMs: 100, + settings: const TalkerGraphQLLoggerSettings(), + ); + + expect(log.key, equals(TalkerKey.graphqlResponse)); + }); + + test('should include duration in message', () { + final log = GraphQLResponseLog( + 'GetUser', + request: createSimpleRequest(), + response: const Response(response: {}, data: { + 'user': {'id': '1'} + }), + durationMs: 150, + settings: const TalkerGraphQLLoggerSettings(), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('Duration: 150 ms')); + }); + + test('should truncate long responses', () { + final longData = {'data': 'x' * 3000}; + + final log = GraphQLResponseLog( + 'GetData', + request: createSimpleRequest(), + response: Response(response: const {}, data: longData), + durationMs: 100, + settings: const TalkerGraphQLLoggerSettings(responseMaxLength: 100), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('[truncated]')); + }); + }); + + group('GraphQLErrorLog', () { + test('should have correct key', () { + final log = GraphQLErrorLog( + 'TestOperation', + request: createSimpleRequest(), + durationMs: 100, + settings: const TalkerGraphQLLoggerSettings(), + ); + + expect(log.key, equals(TalkerKey.graphqlError)); + }); + + test('should have error log level', () { + final log = GraphQLErrorLog( + 'TestOperation', + request: createSimpleRequest(), + durationMs: 100, + settings: const TalkerGraphQLLoggerSettings(), + ); + + expect(log.logLevel, equals(LogLevel.error)); + }); + + test('should include GraphQL errors in message', () { + final log = GraphQLErrorLog( + 'GetUser', + request: createSimpleRequest(), + response: const Response( + response: {}, + data: null, + errors: [ + GraphQLError( + message: 'User not found', + path: ['user'], + ), + ], + ), + durationMs: 50, + settings: const TalkerGraphQLLoggerSettings(), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('GraphQL Errors')); + expect(message, contains('User not found')); + expect(message, contains('Path: user')); + }); + + test('should include link exception in message', () { + final log = GraphQLErrorLog( + 'GetUser', + request: createSimpleRequest(), + linkException: Exception('Network error'), + durationMs: 50, + settings: const TalkerGraphQLLoggerSettings(), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('Exception')); + expect(message, contains('Network error')); + }); + }); + + group('GraphQLSubscriptionLog', () { + test('should have correct key', () { + final log = GraphQLSubscriptionLog( + 'TestSubscription', + request: createSimpleRequest(isSubscription: true), + settings: const TalkerGraphQLLoggerSettings(), + ); + + expect(log.key, equals(TalkerKey.graphqlSubscription)); + }); + + test('should include event type in message', () { + final log = GraphQLSubscriptionLog( + 'OnMessage', + request: createSimpleRequest(isSubscription: true), + eventType: GraphQLSubscriptionEventType.data, + settings: const TalkerGraphQLLoggerSettings(), + ); + + final message = log.generateTextMessage(); + + expect(message, contains('[Subscription]')); + expect(message, contains('Event: data')); + }); + }); +} + +Request createSimpleRequest({bool isSubscription = false}) { + final queryType = isSubscription ? 'subscription' : 'query'; + return Request( + operation: Operation( + document: parseString('$queryType Test { test }'), + operationName: 'Test', + ), + variables: const {}, + ); +} + +/// Mock terminal link for testing +class MockTerminalLink extends Link { + MockTerminalLink({ + Response? response, + }) : _response = + response ?? const Response(response: {}, data: {'success': true}); + + final Response _response; + + @override + Stream request(Request request, [NextLink? forward]) { + return Stream.value(_response); + } +}