diff --git a/.gitignore b/.gitignore index 34361af..f7b9b5f 100644 --- a/.gitignore +++ b/.gitignore @@ -86,5 +86,6 @@ Thumbs.db .env.local # Package specific +.cookies/ *.tar.gz *.zip diff --git a/CHANGELOG.md b/CHANGELOG.md index d290b57..2564746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,3 +43,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Authentication strategy support. - API request options customization. - API envelope for structured responses. +# Unreleased + +- Added compact structured `REQ`, `RES`, `ERR`, `RETRY`, `CACHE`, and `AUTH` + logging with total operation durations and network/cache source metadata. +- Added `ApiLoggingLevel`, immutable `ApiCallLogOptions`, typed log event + subclasses, callback logger support, and configurable body formatting. +- Added mandatory bounded redaction before custom logger callbacks, including + nested credentials, signed URLs, multipart uploads, binary data, streams, + long values, and cyclic objects. +- Added client-only per-request logging options without changing transport, + serialization, cache, retry, cookie, or authentication semantics. +- Preserved legacy `ApiLoggingConfig.enabled`, logging booleans, `ApiLogLevel`, + `ApiLogEvent`, and `ApiLogger` compatibility. diff --git a/README.md b/README.md index 94533ee..595ae00 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ await FlutterApiBridge.configure( defaultTtl: Duration(minutes: 5), ), retry: const ApiRetryConfig(maxAttempts: 3), + logging: const ApiLoggingConfig( + level: ApiLoggingLevel.basic, + showDuration: true, + showRequestId: false, + prettyPrintBodies: true, + ), clientIdentity: () => const ApiClientIdentity( applicationName: 'RiderescueDriver', applicationVersion: '2.4.1', @@ -153,12 +159,14 @@ All requests accept common options for: - idempotency keys; - cache tag/path invalidation; - operation IDs for diagnostics. +- client-only per-call logging controls. ```dart const ApiRequestOptions( headers: {'X-Tenant': 'company-7'}, cookies: {'preview': 'enabled'}, idempotencyKey: 'booking-create-123', + log: ApiCallLogOptions(enabled: false), ); ``` @@ -177,10 +185,108 @@ endpoint orchestration remains the generated package or application's concern. ## Logging -The bridge emits structured `ApiLogEvent` values with request IDs, operation -IDs, attempt numbers, durations, connection/session metadata, and cache/retry -events. Sensitive headers, cookies, tokens, passwords, secrets, OTP values, -and authentication codes are redacted. +The default logger emits compact, production-friendly summaries. Request and +response bodies, headers, query parameters, cookies, complete request IDs, +session IDs, and stack traces are not printed by default. + +```text +REQ: user_auth.getCurrentAuthBootstrap · GET · /v1/user/auth/bootstrap + ↳ attempt=1 +RES: user_auth.getCurrentAuthBootstrap · GET · /v1/user/auth/bootstrap · 200 · 184ms + ↳ source=network +ERR: booking.createBooking · POST · /v1/bookings · 422 · 197ms + ↳ code=validation_failed · message="Vehicle is required" +RETRY: user_auth.refreshSession · attempt 2/3 · in 800ms + ↳ reason=connection_timeout +CACHE: jobs.listAvailableJobs · hit + ↳ source=disk · age=42s +AUTH: authenticated + ↳ reason=credentials_restored +``` + +### Global configuration + +`ApiLoggingLevel` controls package verbosity: + +- `none`: no package logs; +- `errors`: terminal request and important authentication failures only; +- `basic`: concise request, response, error, retry, cache, and auth events; +- `detailed`: basic events plus metadata explicitly enabled below. + +```dart +const ApiLoggingConfig( + level: ApiLoggingLevel.detailed, + logRequestHeaders: false, + logRequestBody: false, + logResponseHeaders: false, + logResponseBody: false, + logQueryParameters: false, + logCookies: false, + showDuration: true, + showRequestId: false, + prettyPrintBodies: true, + useAnsi: false, +); +``` + +`enabled: false` remains supported for compatibility. Prefer +`level: ApiLoggingLevel.none` in new code. `ApiLogLevel` remains the severity +attached to a structured event; it is separate from `ApiLoggingLevel`. + +### Per-operation debugging + +`ApiCallLogOptions` is immutable and stored only in `ApiRequestOptions`. The +shared caller reads it locally; it is never serialized into headers, query +parameters, request bodies, cookies, or DTOs. + +```dart +const options = ApiRequestOptions( + log: ApiCallLogOptions( + requestBody: true, + responseBody: true, + ), +); +``` + +Disable one operation without changing global configuration: + +```dart +const options = ApiRequestOptions( + log: ApiCallLogOptions(enabled: false), +); +``` + +Generated packages should expose `ApiCallLogOptions? log` on each operation +and forward it into request options. Existing generated calls remain valid +because the argument is optional. Generator/template integration is tracked in +[issue #15](https://github.com/alidantech-org/flutter_api_bridge/issues/15); +this runtime repository intentionally does not patch generated operations. + +### Redaction guarantees + +Mandatory centralized redaction runs before every logger callback, including +custom sinks. Explicit body/header/cookie flags never bypass it. Matching is +case-insensitive across snake_case, kebab-case, and camelCase variants and +includes passwords, tokens, authorization, cookies, secrets, API keys, OTPs, +PINs, credentials, and signed URL parameters. + +The redactor also: + +- truncates long strings and large collections; +- limits recursive depth and detects cycles; +- strips URL user information and redacts sensitive query parameters; +- summarizes binary data and streams; +- reports multipart file counts and sizes without reading or printing bytes; +- prevents redaction or logger failures from affecting API requests. + +Production applications should use `basic` or `errors`. Enable bodies only for +the smallest possible scope and disable them again after diagnosis. + +### Custom sinks + +The runtime emits typed `ApiRequestLogEvent`, `ApiResponseLogEvent`, +`ApiErrorLogEvent`, `ApiRetryLogEvent`, `ApiCacheLogEvent`, and +`ApiAuthLogEvent` values through the existing `ApiLogger` abstraction. ```dart class AppApiLogger implements ApiLogger { @@ -188,11 +294,16 @@ class AppApiLogger implements ApiLogger { @override void log(ApiLogEvent event) { - // Forward safe structured events to the application's logger. + // Metadata has already passed mandatory redaction. + // Forward it to logging, logger, Crashlytics, Sentry, or another sink. } } ``` +For callback-style integrations, use `CallbackApiLogger`. The default +`DeveloperApiLogger` uses `ApiLogFormatter`; consumers can also format events +directly with the formatter. ANSI output is off by default. + ## Legacy `Server` `Server.init()` remains as a default-connection compatibility wrapper. New diff --git a/example/lib/main.dart b/example/lib/main.dart index 699d8b5..da3a6cc 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,6 +7,11 @@ Future main() async { await Server.init( baseUrl: 'https://jsonplaceholder.typicode.com', authStrategy: const CookieStrategy(), + logging: const ApiLoggingConfig( + level: ApiLoggingLevel.basic, + showDuration: true, + showRequestId: false, + ), ); runApp(const MyApp()); diff --git a/lib/client/api/api_request_options.dart b/lib/client/api/api_request_options.dart index 862f8f4..7040efa 100644 --- a/lib/client/api/api_request_options.dart +++ b/lib/client/api/api_request_options.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../config/api_bridge_config.dart'; +import '../logging/api_logger.dart'; /// Shared request controls available on every generated operation. class ApiRequestOptions { @@ -13,6 +14,7 @@ class ApiRequestOptions { this.idempotencyKey, this.cancelToken, this.operationId, + this.log, this.invalidateCacheTags = const [], this.invalidateCachePaths = const [], this.clearActiveSessionCache = false, @@ -26,6 +28,7 @@ class ApiRequestOptions { final String? idempotencyKey; final CancelToken? cancelToken; final String? operationId; + final ApiCallLogOptions? log; final List invalidateCacheTags; final List invalidateCachePaths; final bool clearActiveSessionCache; @@ -40,6 +43,8 @@ class ApiRequestOptions { String? idempotencyKey, CancelToken? cancelToken, String? operationId, + ApiCallLogOptions? log, + bool clearLog = false, List? invalidateCacheTags, List? invalidateCachePaths, bool? clearActiveSessionCache, @@ -53,6 +58,7 @@ class ApiRequestOptions { idempotencyKey: idempotencyKey ?? this.idempotencyKey, cancelToken: cancelToken ?? this.cancelToken, operationId: operationId ?? this.operationId, + log: clearLog ? null : log ?? this.log, invalidateCacheTags: invalidateCacheTags ?? this.invalidateCacheTags, invalidateCachePaths: invalidateCachePaths ?? this.invalidateCachePaths, clearActiveSessionCache: @@ -70,6 +76,7 @@ class ApiGetRequestOptions extends ApiRequestOptions { super.idempotencyKey, super.cancelToken, super.operationId, + super.log, super.invalidateCacheTags, super.invalidateCachePaths, super.clearActiveSessionCache, @@ -104,6 +111,8 @@ class ApiGetRequestOptions extends ApiRequestOptions { String? idempotencyKey, CancelToken? cancelToken, String? operationId, + ApiCallLogOptions? log, + bool clearLog = false, List? invalidateCacheTags, List? invalidateCachePaths, bool? clearActiveSessionCache, @@ -126,6 +135,7 @@ class ApiGetRequestOptions extends ApiRequestOptions { idempotencyKey: idempotencyKey ?? this.idempotencyKey, cancelToken: cancelToken ?? this.cancelToken, operationId: operationId ?? this.operationId, + log: clearLog ? null : log ?? this.log, invalidateCacheTags: invalidateCacheTags ?? this.invalidateCacheTags, invalidateCachePaths: invalidateCachePaths ?? this.invalidateCachePaths, clearActiveSessionCache: @@ -150,6 +160,7 @@ class ApiUploadRequestOptions extends ApiRequestOptions { super.idempotencyKey, super.cancelToken, super.operationId, + super.log, super.invalidateCacheTags, super.invalidateCachePaths, super.clearActiveSessionCache, @@ -169,6 +180,8 @@ class ApiUploadRequestOptions extends ApiRequestOptions { String? idempotencyKey, CancelToken? cancelToken, String? operationId, + ApiCallLogOptions? log, + bool clearLog = false, List? invalidateCacheTags, List? invalidateCachePaths, bool? clearActiveSessionCache, @@ -183,6 +196,7 @@ class ApiUploadRequestOptions extends ApiRequestOptions { idempotencyKey: idempotencyKey ?? this.idempotencyKey, cancelToken: cancelToken ?? this.cancelToken, operationId: operationId ?? this.operationId, + log: clearLog ? null : log ?? this.log, invalidateCacheTags: invalidateCacheTags ?? this.invalidateCacheTags, invalidateCachePaths: invalidateCachePaths ?? this.invalidateCachePaths, clearActiveSessionCache: diff --git a/lib/client/auth/api_auth.dart b/lib/client/auth/api_auth.dart index ed0273e..aab584c 100644 --- a/lib/client/auth/api_auth.dart +++ b/lib/client/auth/api_auth.dart @@ -293,22 +293,26 @@ class ApiAuth { } _current = next; _changes.add(next); - if (_logging.enabled) { + final options = _logging.resolve(); + final importantFailure = next.status == AuthSessionStatus.expired; + if (!options.enabled || + options.level == ApiLoggingLevel.errors && !importantFailure) { + return; + } + try { _logger.log( - ApiLogEvent( - level: next.status == AuthSessionStatus.expired - ? ApiLogLevel.warning - : ApiLogLevel.info, - type: ApiLogEventType.auth, - message: 'Authentication state changed', + ApiAuthLogEvent( + level: importantFailure ? ApiLogLevel.warning : ApiLogLevel.info, timestamp: next.changedAt, - data: { + options: options, + data: options.redactor.redact({ 'status': next.status.name, - if (next.sessionId != null) 'sessionId': next.sessionId, if (next.reason != null) 'reason': next.reason, - }, + }) as Map, ), ); + } catch (_) { + // Diagnostics must never affect authentication state changes. } } } diff --git a/lib/client/logging/api_logger.dart b/lib/client/logging/api_logger.dart index aefe38b..75f07ad 100644 --- a/lib/client/logging/api_logger.dart +++ b/lib/client/logging/api_logger.dart @@ -1,7 +1,17 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; import 'dart:developer' as developer; +import 'dart:typed_data'; +import 'package:dio/dio.dart'; + +/// Severity retained for backward compatibility with custom [ApiLogger]s. enum ApiLogLevel { debug, info, warning, error } +/// Controls which package events are emitted. +enum ApiLoggingLevel { none, errors, basic, detailed } + enum ApiLogEventType { request, response, @@ -12,6 +22,60 @@ enum ApiLogEventType { lifecycle, } +/// Client-only logging controls for one request. +class ApiCallLogOptions { + const ApiCallLogOptions({ + this.enabled = true, + this.requestHeaders = false, + this.requestBody = false, + this.responseHeaders = false, + this.responseBody = false, + this.queryParameters = false, + this.cookies = false, + }); + + final bool enabled; + final bool requestHeaders; + final bool requestBody; + final bool responseHeaders; + final bool responseBody; + final bool queryParameters; + final bool cookies; +} + +class ApiResolvedLogOptions { + const ApiResolvedLogOptions({ + required this.enabled, + required this.level, + required this.requestHeaders, + required this.requestBody, + required this.responseHeaders, + required this.responseBody, + required this.queryParameters, + required this.cookies, + required this.showDuration, + required this.showRequestId, + required this.prettyPrintBodies, + required this.useAnsi, + required this.redactor, + }); + + final bool enabled; + final ApiLoggingLevel level; + final bool requestHeaders; + final bool requestBody; + final bool responseHeaders; + final bool responseBody; + final bool queryParameters; + final bool cookies; + final bool showDuration; + final bool showRequestId; + final bool prettyPrintBodies; + final bool useAnsi; + final ApiLogRedactor redactor; +} + +/// Structured package event delivered to [ApiLogger] implementations. class ApiLogEvent { const ApiLogEvent({ required this.level, @@ -25,9 +89,14 @@ class ApiLogEvent { this.requestId, this.operationId, this.attempt, + this.maxAttempts, + this.retryDelay, + this.source, + this.code, this.data = const {}, this.error, this.stackTrace, + this.options, }); final ApiLogLevel level; @@ -41,9 +110,118 @@ class ApiLogEvent { final String? requestId; final String? operationId; final int? attempt; + final int? maxAttempts; + final Duration? retryDelay; + final String? source; + final String? code; final Map data; final Object? error; final StackTrace? stackTrace; + final ApiResolvedLogOptions? options; +} + +class ApiRequestLogEvent extends ApiLogEvent { + const ApiRequestLogEvent({ + required super.timestamp, + required super.method, + required super.path, + required super.operationId, + required super.requestId, + required super.attempt, + required super.maxAttempts, + required super.options, + super.data, + }) : super( + level: ApiLogLevel.debug, + type: ApiLogEventType.request, + message: 'API request', + ); +} + +class ApiResponseLogEvent extends ApiLogEvent { + const ApiResponseLogEvent({ + required super.timestamp, + required super.method, + required super.path, + required super.operationId, + required super.requestId, + required super.statusCode, + required super.duration, + required super.source, + required super.options, + super.attempt, + super.data, + }) : super( + level: ApiLogLevel.info, + type: ApiLogEventType.response, + message: 'API response', + ); +} + +class ApiErrorLogEvent extends ApiLogEvent { + const ApiErrorLogEvent({ + required super.timestamp, + required super.method, + required super.path, + required super.operationId, + required super.requestId, + required super.duration, + required super.options, + super.statusCode, + super.attempt, + super.code, + super.data, + super.error, + super.stackTrace, + }) : super( + level: ApiLogLevel.error, + type: ApiLogEventType.failure, + message: 'API request failed', + ); +} + +class ApiRetryLogEvent extends ApiLogEvent { + const ApiRetryLogEvent({ + required super.timestamp, + required super.operationId, + required super.attempt, + required super.maxAttempts, + required super.retryDelay, + required super.options, + super.requestId, + super.code, + super.data, + }) : super( + level: ApiLogLevel.warning, + type: ApiLogEventType.retry, + message: 'API request will retry', + ); +} + +class ApiCacheLogEvent extends ApiLogEvent { + const ApiCacheLogEvent({ + required super.timestamp, + required super.operationId, + required super.source, + required super.options, + super.data, + }) : super( + level: ApiLogLevel.debug, + type: ApiLogEventType.cache, + message: 'API cache event', + ); +} + +class ApiAuthLogEvent extends ApiLogEvent { + const ApiAuthLogEvent({ + required super.timestamp, + required super.options, + required super.data, + super.level = ApiLogLevel.info, + }) : super( + type: ApiLogEventType.auth, + message: 'Authentication state changed', + ); } abstract interface class ApiLogger { @@ -52,64 +230,114 @@ abstract interface class ApiLogger { void log(ApiLogEvent event); } +typedef ApiLogSink = void Function(ApiLogEvent event); + +class CallbackApiLogger implements ApiLogger { + const CallbackApiLogger(this.sink); + + final ApiLogSink sink; + + @override + void log(ApiLogEvent event) => sink(event); +} + class DeveloperApiLogger implements ApiLogger { - const DeveloperApiLogger({this.name = 'flutter_api_bridge'}); + const DeveloperApiLogger({ + this.name = 'flutter_api_bridge', + this.formatter = const ApiLogFormatter(), + }); final String name; + final ApiLogFormatter formatter; @override void log(ApiLogEvent event) { - const redactor = ApiLogRedactor(ApiLoggingConfig.defaultSensitiveKeys); - final safeData = redactor.redact(event.data); - final details = [ - event.type.name, - if (event.operationId != null) event.operationId!, - if (event.method != null) event.method!, - if (event.path != null) event.path!, - if (event.statusCode != null) 'HTTP ${event.statusCode}', - if (event.duration != null) '${event.duration!.inMilliseconds}ms', - if (event.attempt != null) 'attempt ${event.attempt}', - if (event.requestId != null) 'request ${event.requestId}', - if (safeData is Map && safeData.isNotEmpty) safeData.toString(), - ].join(' · '); - - developer.log( - details.isEmpty ? event.message : '${event.message} | $details', - name: name, - level: switch (event.level) { - ApiLogLevel.debug => 500, - ApiLogLevel.info => 800, - ApiLogLevel.warning => 900, - ApiLogLevel.error => 1000, - }, - error: event.error, - stackTrace: event.stackTrace, - time: event.timestamp, - ); + try { + developer.log( + formatter.format(event), + name: name, + level: switch (event.level) { + ApiLogLevel.debug => 500, + ApiLogLevel.info => 800, + ApiLogLevel.warning => 900, + ApiLogLevel.error => 1000, + }, + time: event.timestamp, + ); + } catch (_) { + // Diagnostics must never affect a request. + } } } class ApiLoggingConfig { const ApiLoggingConfig({ this.enabled = true, + this.level = ApiLoggingLevel.basic, this.logRequestHeaders = false, this.logRequestBody = false, + this.logResponseHeaders = false, this.logResponseBody = false, + this.logQueryParameters = false, + this.logCookies = false, + this.showDuration = true, + this.showRequestId = false, + this.prettyPrintBodies = true, + this.useAnsi = false, this.sensitiveKeys = defaultSensitiveKeys, + this.maxDepth = 6, + this.maxCollectionItems = 30, + this.maxStringLength = 500, }); final bool enabled; + final ApiLoggingLevel level; final bool logRequestHeaders; final bool logRequestBody; + final bool logResponseHeaders; final bool logResponseBody; + final bool logQueryParameters; + final bool logCookies; + final bool showDuration; + final bool showRequestId; + final bool prettyPrintBodies; + final bool useAnsi; final Set sensitiveKeys; + final int maxDepth; + final int maxCollectionItems; + final int maxStringLength; + + ApiResolvedLogOptions resolve([ApiCallLogOptions? call]) { + final callEnabled = call?.enabled ?? true; + return ApiResolvedLogOptions( + enabled: enabled && level != ApiLoggingLevel.none && callEnabled, + level: level, + requestHeaders: call?.requestHeaders ?? logRequestHeaders, + requestBody: call?.requestBody ?? logRequestBody, + responseHeaders: call?.responseHeaders ?? logResponseHeaders, + responseBody: call?.responseBody ?? logResponseBody, + queryParameters: call?.queryParameters ?? logQueryParameters, + cookies: call?.cookies ?? logCookies, + showDuration: showDuration, + showRequestId: showRequestId, + prettyPrintBodies: prettyPrintBodies, + useAnsi: useAnsi, + redactor: ApiLogRedactor( + sensitiveKeys, + maxDepth: maxDepth, + maxCollectionItems: maxCollectionItems, + maxStringLength: maxStringLength, + ), + ); + } static const Set defaultSensitiveKeys = { 'authorization', - 'proxy-authorization', + 'proxyauthorization', 'cookie', - 'set-cookie', + 'setcookie', 'password', + 'passwordconfirmation', 'currentpassword', 'newpassword', 'confirmpassword', @@ -117,43 +345,241 @@ class ApiLoggingConfig { 'accesstoken', 'refreshtoken', 'idtoken', + 'sessiontoken', 'apikey', - 'api-key', - 'x-api-key', 'secret', 'clientsecret', 'otp', - 'code', + 'pin', + 'signature', + 'credential', }; } +class ApiLogFormatter { + const ApiLogFormatter(); + + String format(ApiLogEvent event) { + final options = event.options; + final redactor = options?.redactor ?? + const ApiLogRedactor(ApiLoggingConfig.defaultSensitiveKeys); + final data = redactor.redact(event.data); + final safeData = + data is Map ? data : const {}; + final summary = switch (event.type) { + ApiLogEventType.request => _request(event), + ApiLogEventType.response => _response(event, options), + ApiLogEventType.failure => _error(event, safeData, options), + ApiLogEventType.retry => _retry(event), + ApiLogEventType.cache => _cache(event, safeData), + ApiLogEventType.auth => _auth(safeData), + ApiLogEventType.lifecycle => 'LIFECYCLE: ${event.message}', + }; + final metadata = _metadata(event, safeData, options); + if (metadata.isEmpty) return summary; + final compact = metadata.where((line) => !line.contains('\n')).toList(); + final expanded = metadata.where((line) => line.contains('\n')).toList(); + final lines = [ + if (compact.isNotEmpty) ' ↳ ${compact.join(' · ')}', + ...expanded.map((line) => ' ↳ $line'), + ]; + return '$summary\n${lines.join('\n')}'; + } + + String _request(ApiLogEvent event) => + 'REQ: ${_operation(event)} · ${event.method ?? '-'} · ${event.path ?? '-'}'; + + String _response(ApiLogEvent event, ApiResolvedLogOptions? options) { + final duration = options?.showDuration != false && event.duration != null + ? ' · ${event.duration!.inMilliseconds}ms' + : ''; + return 'RES: ${_operation(event)} · ${event.method ?? '-'} · ${event.path ?? '-'} · ${event.statusCode ?? '-'}$duration'; + } + + String _error( + ApiLogEvent event, + Map data, + ApiResolvedLogOptions? options, + ) { + final duration = options?.showDuration != false && event.duration != null + ? ' · ${event.duration!.inMilliseconds}ms' + : ''; + return 'ERR: ${_operation(event)} · ${event.method ?? '-'} · ${event.path ?? '-'} · ${event.statusCode ?? '-'}$duration'; + } + + String _retry(ApiLogEvent event) { + final attempt = event.attempt ?? 1; + final total = event.maxAttempts ?? attempt; + final delay = event.retryDelay?.inMilliseconds ?? 0; + return 'RETRY: ${_operation(event)} · attempt $attempt/$total · in ${delay}ms'; + } + + String _cache(ApiLogEvent event, Map data) => + 'CACHE: ${_operation(event)} · ${data['cache'] ?? 'hit'}'; + + String _auth(Map data) => + 'AUTH: ${data['status'] ?? 'unknown'}'; + + List _metadata( + ApiLogEvent event, + Map data, + ApiResolvedLogOptions? options, + ) { + final output = []; + if (event.type == ApiLogEventType.request && event.attempt != null) { + output.add('attempt=${event.attempt}'); + } + if (event.source != null) output.add('source=${event.source}'); + if (options?.showRequestId == true && event.requestId != null) { + output.add('request=${_shorten(event.requestId!)}'); + } + if (event.code != null) output.add('code=${event.code}'); + for (final entry in data.entries) { + if (entry.key == 'status' || + event.type == ApiLogEventType.cache && entry.key == 'cache' || + entry.key == 'body' && entry.value == null) { + continue; + } + output.add('${entry.key}=${_render(entry.value, options)}'); + } + return output; + } + + String _render(Object? value, ApiResolvedLogOptions? options) { + if (value is Map || value is Iterable) { + try { + if (options?.prettyPrintBodies == true) { + return const JsonEncoder.withIndent(' ') + .convert(value) + .replaceAll('\n', '\n '); + } + return jsonEncode(value); + } catch (_) { + return value.toString(); + } + } + final text = value?.toString() ?? 'null'; + return text.contains(RegExp(r'\s')) ? jsonEncode(text) : text; + } + + String _operation(ApiLogEvent event) => + event.operationId?.trim().isNotEmpty == true + ? event.operationId!.trim() + : 'unknown'; + + String _shorten(String value) => + value.length <= 12 ? value : '${value.substring(0, 12)}...'; +} + class ApiLogRedactor { - const ApiLogRedactor(this.sensitiveKeys); + const ApiLogRedactor( + this.sensitiveKeys, { + this.maxDepth = 6, + this.maxCollectionItems = 30, + this.maxStringLength = 500, + }); final Set sensitiveKeys; + final int maxDepth; + final int maxCollectionItems; + final int maxStringLength; - Object? redact(Object? value) { - if (value is Map) { + Object? redact(Object? value) => _redact( + value, + depth: 0, + seen: HashSet.identity(), + ); + + Object? _redact( + Object? value, { + required int depth, + required Set seen, + }) { + if (value == null || value is num || value is bool) return value; + if (value is String) return _safeString(value); + if (value is Uri) return _safeUri(value).toString(); + if (value is Uint8List || value is ByteBuffer || value is ByteData) { + final length = value is Uint8List ? value.length : null; + return '[BINARY${length == null ? '' : ' $length bytes'}]'; + } + if (value is Stream) return '[STREAM]'; + if (value is MultipartFile) { return { - for (final entry in value.entries) - entry.key.toString(): _isSensitive(entry.key.toString()) - ? '[REDACTED]' - : redact(entry.value), + 'type': 'file', + 'filename': + value.filename == null ? null : _safeString(value.filename!), + 'size': value.length, + }; + } + if (value is FormData) { + return { + 'type': 'multipart', + 'fields': value.fields.length, + 'files': value.files.length, + 'size': value.files + .fold(0, (total, entry) => total + entry.value.length), }; } - if (value is Iterable) { - return value.map(redact).toList(growable: false); + if (depth >= maxDepth) return '[MAX_DEPTH]'; + if (!seen.add(value)) return '[CIRCULAR]'; + try { + if (value is Map) { + final output = {}; + var count = 0; + for (final entry in value.entries) { + if (count >= maxCollectionItems) { + output['...'] = '[TRUNCATED ${value.length - count} items]'; + break; + } + final key = entry.key.toString(); + output[key] = _isSensitive(key) + ? '[REDACTED]' + : _redact(entry.value, depth: depth + 1, seen: seen); + count += 1; + } + return output; + } + if (value is Iterable) { + if (value is List) return '[BINARY ${value.length} bytes]'; + final output = []; + final iterator = value.iterator; + while (output.length < maxCollectionItems && iterator.moveNext()) { + output.add(_redact(iterator.current, depth: depth + 1, seen: seen)); + } + if (iterator.moveNext()) output.add('[TRUNCATED]'); + return output; + } + return _safeString(value.toString()); + } finally { + seen.remove(value); } - return value; } bool _isSensitive(String key) { - final normalized = key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9-]'), ''); + final normalized = _normalizeKey(key); return sensitiveKeys.any((candidate) { - final normalizedCandidate = - candidate.toLowerCase().replaceAll(RegExp(r'[^a-z0-9-]'), ''); - return normalized == normalizedCandidate || - normalized.contains(normalizedCandidate); + final expected = _normalizeKey(candidate); + return normalized == expected || normalized.contains(expected); }); } + + String _normalizeKey(String key) => + key.toLowerCase().replaceAll(RegExp('[^a-z0-9]'), ''); + + String _safeString(String value) { + final uri = Uri.tryParse(value); + final safe = + uri != null && uri.hasScheme ? _safeUri(uri).toString() : value; + if (safe.length <= maxStringLength) return safe; + return '${safe.substring(0, maxStringLength)}...[TRUNCATED]'; + } + + Uri _safeUri(Uri uri) { + if (!uri.hasQuery) return uri.replace(userInfo: ''); + final query = {}; + for (final entry in uri.queryParameters.entries) { + query[entry.key] = _isSensitive(entry.key) ? '[REDACTED]' : entry.value; + } + return uri.replace(userInfo: '', queryParameters: query); + } } diff --git a/lib/client/runtime/api_connection.dart b/lib/client/runtime/api_connection.dart index ef1fa93..d131c97 100644 --- a/lib/client/runtime/api_connection.dart +++ b/lib/client/runtime/api_connection.dart @@ -24,9 +24,7 @@ class ApiConnection { required this.auth, required this.cache, required this.cookies, - required ApiClientIdentity? identity, - }) : _dio = dio, - _identity = identity; + }) : _dio = dio; final String key; final ApiBridgeConfig config; @@ -34,7 +32,6 @@ class ApiConnection { final ApiAuth auth; final ApiCache cache; final ApiCookieManager cookies; - final ApiClientIdentity? _identity; /// Escape hatch for legacy integrations. Generated packages should use /// [execute] so caching, retries, diagnostics, and invalidation remain active. @@ -110,7 +107,6 @@ class ApiConnection { auth: auth, cache: cache, cookies: cookieManager, - identity: identity, ); final restored = await auth.initialize(); await cache.startSession( @@ -157,11 +153,20 @@ class ApiConnection { Future> execute(ApiRequest request) async { _ensureActive(); - if (request is GetRequest) return _executeGet(request); - return _executeMutation(request); + final context = _ExecutionLogContext( + requestId: _nextRequestId(), + startedAt: DateTime.now().toUtc(), + options: config.logging.resolve(request.options?.log), + ); + _logRequest(request, context); + if (request is GetRequest) return _executeGet(request, context); + return _executeMutation(request, context); } - Future> _executeGet(GetRequest request) async { + Future> _executeGet( + GetRequest request, + _ExecutionLogContext context, + ) async { final options = request.getOptions; final policy = !request.cache || !config.cache.enabled ? ApiCachePolicy.disabled @@ -180,15 +185,29 @@ class ApiConnection { allowStale: policy == ApiCachePolicy.cacheOnly, ); if (cached != null) { - return _parse( + final result = _parse( request: request, responseBody: cached.data, statusCode: 200, meta: _cacheMetadata(request, cached), ); + _logResponse( + request, + context, + statusCode: 200, + source: 'cache', + data: { + 'cache': 'hit', + 'age': + '${DateTime.now().toUtc().difference(cached.storedAt).inSeconds}s', + 'cacheSource': cached.source.name, + if (context.options.responseBody) 'body': cached.data, + }, + ); + return result; } if (policy == ApiCachePolicy.cacheOnly) { - return ApiError( + final result = ApiError( message: 'No cached data is available', error: 'cache_miss', meta: ApiResultMetadata( @@ -196,10 +215,17 @@ class ApiConnection { operationId: request.operationId, ), ); + _logError( + request, + context, + code: 'cache_miss', + message: result.message, + ); + return result; } } - final network = await _network(request); + final network = await _network(request, context); if (network.result is ApiSuccess) { if (policy != ApiCachePolicy.disabled) { final success = network.result as ApiSuccess; @@ -210,6 +236,7 @@ class ApiConnection { tags: options?.cacheTags ?? const [], ); } + _logNetworkSuccess(request, context, network); return network.result; } @@ -223,17 +250,7 @@ class ApiConnection { config.cache.allowStaleOnNetworkError, ); if (cached != null) { - _log( - ApiLogLevel.warning, - ApiLogEventType.cache, - 'Using cached data after network failure', - request: request, - data: { - 'source': cached.source.name, - 'stale': cached.isStale, - }, - ); - return _parse( + final result = _parse( request: request, responseBody: cached.data, statusCode: 200, @@ -250,35 +267,50 @@ class ApiConnection { expiresAt: cached.expiresAt, ), ); + _logResponse( + request, + context, + statusCode: 200, + source: 'cache', + data: { + 'cache': 'hit', + 'fallback': 'network_failure', + 'stale': cached.isStale, + 'cacheSource': cached.source.name, + if (context.options.responseBody) 'body': cached.data, + }, + ); + return result; } } + _logNetworkError(request, context, network); return network.result; } - Future> _executeMutation(ApiRequest request) async { - final network = await _network(request); + Future> _executeMutation( + ApiRequest request, + _ExecutionLogContext context, + ) async { + final network = await _network(request, context); if (network.result is ApiSuccess) { await _applyPostSuccessInvalidation(request.options); + _logNetworkSuccess(request, context, network); + } else { + _logNetworkError(request, context, network); } return network.result; } - Future<_NetworkResult> _network(ApiRequest request) async { - final requestId = _nextRequestId(); + Future<_NetworkResult> _network( + ApiRequest request, + _ExecutionLogContext context, + ) async { + final requestId = context.requestId; final maxAttempts = _maxAttempts(request); DioException? lastException; for (var attempt = 1; attempt <= maxAttempts; attempt += 1) { - final startedAt = DateTime.now().toUtc(); try { - _log( - ApiLogLevel.debug, - ApiLogEventType.request, - 'API request', - request: request, - requestId: requestId, - attempt: attempt, - ); final response = await _send(request, requestId, attempt); final result = _parse( request: request, @@ -292,19 +324,11 @@ class ApiConnection { attempt: attempt, ), ); - _log( - ApiLogLevel.info, - ApiLogEventType.response, - 'API response', - request: request, - requestId: requestId, - statusCode: response.statusCode, - duration: DateTime.now().toUtc().difference(startedAt), - attempt: attempt, - ); return _NetworkResult( result: result, responseBody: response.data, + response: response, + attempt: attempt, ); } on DioException catch (error) { lastException = error; @@ -312,31 +336,18 @@ class ApiConnection { unawaited(auth.expire(reason: 'http_401')); } final canRetry = attempt < maxAttempts && _shouldRetry(request, error); - _log( - canRetry ? ApiLogLevel.warning : ApiLogLevel.error, - canRetry ? ApiLogEventType.retry : ApiLogEventType.failure, - canRetry ? 'API request will retry' : 'API request failed', - request: request, - requestId: requestId, - statusCode: error.response?.statusCode, - duration: DateTime.now().toUtc().difference(startedAt), - attempt: attempt, - error: error, - stackTrace: error.stackTrace, - ); if (!canRetry) break; - await Future.delayed(_retryDelay(error, attempt)); - } catch (error, stackTrace) { - _log( - ApiLogLevel.error, - ApiLogEventType.failure, - 'Unexpected API failure', - request: request, - requestId: requestId, - attempt: attempt, - error: error, - stackTrace: stackTrace, + final delay = _retryDelay(error, attempt); + _logRetry( + request, + context, + attempt: attempt + 1, + maxAttempts: maxAttempts, + delay: delay, + reason: _retryReason(error), ); + await Future.delayed(delay); + } catch (error) { return _NetworkResult( result: ApiError( message: 'An unexpected error occurred', @@ -348,6 +359,7 @@ class ApiConnection { attempt: attempt, ), ), + attempt: attempt, ); } } @@ -356,6 +368,8 @@ class ApiConnection { return _NetworkResult( exception: lastException, responseBody: lastException?.response?.data, + response: lastException?.response, + attempt: maxAttempts, result: ApiError( message: _extractMessage( raw, @@ -466,15 +480,7 @@ class ApiConnection { raw: raw, meta: meta, ); - } catch (error, stackTrace) { - _log( - ApiLogLevel.error, - ApiLogEventType.failure, - 'Failed to parse API response', - request: request, - error: error, - stackTrace: stackTrace, - ); + } catch (error) { return ApiError( message: message.isEmpty ? 'Failed to parse response' : message, error: 'Failed to parse response: $error', @@ -622,45 +628,244 @@ class ApiConnection { return error?.message ?? error?.type.name ?? 'request_failed'; } - void _log( - ApiLogLevel level, - ApiLogEventType type, - String message, { - ApiRequest? request, - String? requestId, + void _logRequest(ApiRequest request, _ExecutionLogContext context) { + final data = { + if (context.options.queryParameters && request.query != null) + 'query': buildDioQueryParameters(request.query), + if (context.options.requestHeaders && request.headers?.isNotEmpty == true) + 'headers': request.headers, + if (context.options.cookies && request.cookies?.isNotEmpty == true) + 'cookies': request.cookies, + if (context.options.requestBody) 'body': _requestBodyForLog(request), + }; + _emit( + ApiRequestLogEvent( + timestamp: context.startedAt, + method: _method(request), + path: request.fullPath, + operationId: request.operationId, + requestId: context.requestId, + attempt: 1, + maxAttempts: _maxAttempts(request), + options: context.options, + data: _safeLogData(context.options, data), + ), + ); + } + + void _logNetworkSuccess( + ApiRequest request, + _ExecutionLogContext context, + _NetworkResult network, + ) { + _logResponse( + request, + context, + statusCode: network.response?.statusCode ?? + (network.result as ApiSuccess).statusCode, + source: 'network', + attempt: network.attempt, + data: { + if (context.options.responseHeaders && network.response != null) + 'headers': network.response!.headers.map, + if (context.options.responseBody) 'body': network.responseBody, + }, + ); + } + + void _logResponse( + ApiRequest request, + _ExecutionLogContext context, { + required int statusCode, + required String source, + int? attempt, + Map data = const {}, + }) { + _emit( + ApiResponseLogEvent( + timestamp: DateTime.now().toUtc(), + method: _method(request), + path: request.fullPath, + operationId: request.operationId, + requestId: context.requestId, + statusCode: statusCode, + duration: DateTime.now().toUtc().difference(context.startedAt), + source: source, + attempt: attempt, + options: context.options, + data: _safeLogData(context.options, data), + ), + ); + } + + void _logNetworkError( + ApiRequest request, + _ExecutionLogContext context, + _NetworkResult network, + ) { + final errorResult = + network.result is ApiError ? network.result as ApiError : null; + final raw = asResponseMap(network.responseBody); + final message = errorResult?.message ?? + _extractMessage(raw, fallback: 'Request failed'); + final code = _errorCode(network.exception, raw, errorResult?.error); + _logError( + request, + context, + statusCode: errorResult?.statusCode ?? network.response?.statusCode, + attempt: network.attempt, + code: code, + message: message, + data: { + if (context.options.responseHeaders && network.response != null) + 'headers': network.response!.headers.map, + if (context.options.responseBody) 'body': network.responseBody, + }, + ); + } + + void _logError( + ApiRequest request, + _ExecutionLogContext context, { + required String code, + required String message, int? statusCode, - Duration? duration, int? attempt, Map data = const {}, - Object? error, - StackTrace? stackTrace, }) { - if (!config.logging.enabled) return; - config.logger.log( - ApiLogEvent( - level: level, - type: type, - message: message, + _emit( + ApiErrorLogEvent( timestamp: DateTime.now().toUtc(), - method: request == null ? null : _method(request), - path: request?.fullPath, + method: _method(request), + path: request.fullPath, + operationId: request.operationId, + requestId: context.requestId, statusCode: statusCode, - duration: duration, - requestId: requestId, - operationId: request?.operationId, + duration: DateTime.now().toUtc().difference(context.startedAt), attempt: attempt, - data: { - 'connection': key, - 'sessionId': auth.current.sessionId, - if (_identity != null) 'client': _identity.applicationName, - ...data, - }, - error: error, - stackTrace: stackTrace, + code: code, + options: context.options, + data: _safeLogData( + context.options, + {'message': message, ...data}, + ), ), ); } + void _logRetry( + ApiRequest request, + _ExecutionLogContext context, { + required int attempt, + required int maxAttempts, + required Duration delay, + required String reason, + }) { + _emit( + ApiRetryLogEvent( + timestamp: DateTime.now().toUtc(), + operationId: request.operationId, + requestId: context.requestId, + attempt: attempt, + maxAttempts: maxAttempts, + retryDelay: delay, + options: context.options, + data: _safeLogData( + context.options, + {'reason': reason}, + ), + ), + ); + } + + Object? _requestBodyForLog(ApiRequest request) { + if (request is UploadRequest) { + return { + 'type': 'multipart', + 'fields': request.fields, + 'files': request.files + .map( + (file) => { + 'field': file.field, + 'filename': file.filename, + 'size': file.bytes?.length ?? file.length, + 'source': file.path != null + ? 'path' + : file.bytes != null + ? 'bytes' + : 'stream', + }, + ) + .toList(growable: false), + }; + } + final body = switch (request) { + PostRequest value => value.body, + PutRequest value => value.body, + PatchRequest value => value.body, + DeleteRequest value => value.body, + _ => null, + }; + if (body == null || body is String || body is num || body is bool) { + return body; + } + try { + return normalizeBody(body); + } catch (_) { + return body; + } + } + + String _errorCode( + DioException? error, + Map? raw, + Object? resultError, + ) { + final code = raw?['code']; + if (code is String && code.trim().isNotEmpty) return code.trim(); + final rawError = raw?['error']; + if (rawError is String && rawError.trim().isNotEmpty) { + return rawError.trim(); + } + if (resultError is String && resultError.trim().isNotEmpty) { + return resultError.trim(); + } + return error?.type.name ?? 'request_failed'; + } + + String _retryReason(DioException error) { + final raw = asResponseMap(error.response?.data); + return _errorCode(error, raw, null); + } + + Map _safeLogData( + ApiResolvedLogOptions options, + Map data, + ) { + try { + final safe = options.redactor.redact(data); + return safe is Map + ? safe + : const {}; + } catch (_) { + return const {'diagnostic': '[REDACTION_FAILED]'}; + } + } + + void _emit(ApiLogEvent event) { + final options = event.options ?? config.logging.resolve(); + if (!options.enabled) return; + if (options.level == ApiLoggingLevel.errors && + event.type != ApiLogEventType.failure) { + return; + } + try { + config.logger.log(event); + } catch (_) { + // Diagnostics must never affect request execution. + } + } + Future dispose() async { if (_disposed) return; _disposed = true; @@ -723,9 +928,25 @@ class _NetworkResult { required this.result, this.responseBody, this.exception, + this.response, + this.attempt, }); final ApiResult result; final dynamic responseBody; final DioException? exception; + final Response? response; + final int? attempt; +} + +class _ExecutionLogContext { + const _ExecutionLogContext({ + required this.requestId, + required this.startedAt, + required this.options, + }); + + final String requestId; + final DateTime startedAt; + final ApiResolvedLogOptions options; } diff --git a/lib/client/upload/upload_provider.dart b/lib/client/upload/upload_provider.dart index 55d2eff..4824252 100644 --- a/lib/client/upload/upload_provider.dart +++ b/lib/client/upload/upload_provider.dart @@ -40,6 +40,7 @@ class UploadNotifier extends StateNotifier>> { idempotencyKey: original?.idempotencyKey, cancelToken: _cancelToken, operationId: original?.operationId, + log: original?.log, invalidateCacheTags: original?.invalidateCacheTags ?? const [], invalidateCachePaths: original?.invalidateCachePaths ?? const [], clearActiveSessionCache: original?.clearActiveSessionCache ?? false, diff --git a/test/structured_logging_test.dart b/test/structured_logging_test.dart new file mode 100644 index 0000000..0e0e45f --- /dev/null +++ b/test/structured_logging_test.dart @@ -0,0 +1,608 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_api_bridge/flutter_api_bridge.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory runtimeDirectory; + HttpOverrides? previousHttpOverrides; + + setUpAll(() async { + previousHttpOverrides = HttpOverrides.current; + HttpOverrides.global = _RealHttpOverrides(); + runtimeDirectory = await Directory.systemTemp.createTemp( + 'flutter_api_bridge_logging_', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => runtimeDirectory.path, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => switch (call.method) { + 'read' => null, + 'readAll' => {}, + 'containsKey' => false, + _ => null, + }, + ); + }); + + tearDownAll(() async { + await FlutterApiBridge.disposeAll(); + await Hive.close(); + HttpOverrides.global = previousHttpOverrides; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + null, + ); + if (await runtimeDirectory.exists()) { + await runtimeDirectory.delete(recursive: true); + } + }); + + const formatter = ApiLogFormatter(); + final basic = const ApiLoggingConfig().resolve(); + final withRequestId = const ApiLoggingConfig(showRequestId: true).resolve(); + final detailed = const ApiLoggingConfig( + level: ApiLoggingLevel.detailed, + logRequestBody: true, + logResponseBody: true, + ).resolve(); + final timestamp = DateTime.utc(2026, 1, 2, 3, 4, 5); + + group('structured formatter', () { + test('formats a compact request without request IDs by default', () { + final output = formatter.format( + ApiRequestLogEvent( + timestamp: timestamp, + method: 'GET', + path: '/v1/user/auth/bootstrap', + operationId: 'user_auth.getCurrentAuthBootstrap', + requestId: 'hkimxurjjl-123456', + attempt: 1, + maxAttempts: 3, + options: basic, + ), + ); + + expect( + output, + 'REQ: user_auth.getCurrentAuthBootstrap · GET · ' + '/v1/user/auth/bootstrap\n ↳ attempt=1', + ); + expect(output, isNot(contains('hkimxurjjl'))); + }); + + test('shortens request IDs only when enabled', () { + final output = formatter.format( + ApiRequestLogEvent( + timestamp: timestamp, + method: 'GET', + path: '/v1/jobs', + operationId: 'jobs.list', + requestId: '12345678901234567890', + attempt: 1, + maxAttempts: 1, + options: withRequestId, + ), + ); + + expect(output, contains('request=123456789012...')); + expect(output, isNot(contains('12345678901234567890'))); + }); + + test('formats network and cache responses with duration', () { + final network = formatter.format( + ApiResponseLogEvent( + timestamp: timestamp, + method: 'GET', + path: '/v1/jobs', + operationId: 'jobs.list', + requestId: 'request-1', + statusCode: 200, + duration: const Duration(milliseconds: 184), + source: 'network', + options: basic, + ), + ); + final cache = formatter.format( + ApiResponseLogEvent( + timestamp: timestamp, + method: 'GET', + path: '/v1/jobs', + operationId: 'jobs.list', + requestId: 'request-2', + statusCode: 200, + duration: const Duration(milliseconds: 8), + source: 'cache', + options: basic, + data: const {'cache': 'hit'}, + ), + ); + + expect( + network, startsWith('RES: jobs.list · GET · /v1/jobs · 200 · 184ms')); + expect(network, contains('source=network')); + expect(cache, contains('source=cache')); + expect(cache, contains('cache=hit')); + }); + + test('formats safe errors, retries, cache, and auth events', () { + final error = formatter.format( + ApiErrorLogEvent( + timestamp: timestamp, + method: 'POST', + path: '/v1/bookings', + operationId: 'booking.createBooking', + requestId: 'request-1', + statusCode: 422, + duration: const Duration(milliseconds: 197), + code: 'validation_failed', + options: basic, + data: const {'message': 'Vehicle is required'}, + ), + ); + final retry = formatter.format( + ApiRetryLogEvent( + timestamp: timestamp, + operationId: 'user_auth.refreshSession', + requestId: 'request-2', + attempt: 2, + maxAttempts: 3, + retryDelay: const Duration(milliseconds: 800), + options: basic, + data: const {'reason': 'connection_timeout'}, + ), + ); + final cache = formatter.format( + ApiCacheLogEvent( + timestamp: timestamp, + operationId: 'jobs.listAvailableJobs', + source: 'disk', + options: basic, + data: const {'cache': 'hit', 'age': '42s'}, + ), + ); + final auth = formatter.format( + ApiAuthLogEvent( + timestamp: timestamp, + options: basic, + data: const { + 'status': 'authenticated', + 'reason': 'credentials_restored', + }, + ), + ); + + expect( + error, + contains( + 'ERR: booking.createBooking · POST · /v1/bookings · 422 · 197ms')); + expect(error, contains('code=validation_failed')); + expect(error, contains('message="Vehicle is required"')); + expect( + retry, + startsWith( + 'RETRY: user_auth.refreshSession · attempt 2/3 · in 800ms')); + expect(retry, contains('reason=connection_timeout')); + expect(cache, startsWith('CACHE: jobs.listAvailableJobs · hit')); + expect(auth, 'AUTH: authenticated\n ↳ reason=credentials_restored'); + }); + + test('renders explicitly enabled bodies below summaries', () { + final output = formatter.format( + ApiRequestLogEvent( + timestamp: timestamp, + method: 'POST', + path: '/v1/bookings', + operationId: 'booking.createBooking', + requestId: 'request-1', + attempt: 1, + maxAttempts: 1, + options: detailed, + data: const { + 'body': { + 'serviceId': 'service_123', + 'password': 'never-print-this', + }, + }, + ), + ); + + expect(output, contains('body={')); + expect(output, contains('"serviceId": "service_123"')); + expect(output, contains('"password": "[REDACTED]"')); + expect(output, isNot(contains('never-print-this'))); + }); + }); + + group('configuration precedence', () { + test('safe defaults disable bodies and transport metadata', () { + const config = ApiLoggingConfig(); + final resolved = config.resolve(); + + expect(resolved.level, ApiLoggingLevel.basic); + expect(resolved.requestHeaders, isFalse); + expect(resolved.requestBody, isFalse); + expect(resolved.responseHeaders, isFalse); + expect(resolved.responseBody, isFalse); + expect(resolved.queryParameters, isFalse); + expect(resolved.cookies, isFalse); + expect(resolved.showRequestId, isFalse); + }); + + test('per-call options override global metadata defaults', () { + const config = ApiLoggingConfig( + level: ApiLoggingLevel.detailed, + logRequestHeaders: true, + logResponseHeaders: true, + ); + final resolved = config.resolve( + const ApiCallLogOptions(requestBody: true, responseBody: true), + ); + + expect(resolved.requestHeaders, isFalse); + expect(resolved.responseHeaders, isFalse); + expect(resolved.requestBody, isTrue); + expect(resolved.responseBody, isTrue); + }); + + test('none and per-call disabled suppress logging', () { + expect( + const ApiLoggingConfig(level: ApiLoggingLevel.none).resolve().enabled, + isFalse, + ); + expect( + const ApiLoggingConfig() + .resolve(const ApiCallLogOptions(enabled: false)) + .enabled, + isFalse, + ); + }); + + test('request options preserve client-only logging controls', () { + const callLog = ApiCallLogOptions(requestBody: true); + const options = ApiGetRequestOptions(log: callLog, cache: false); + final copy = options.copyWith(operationId: 'jobs.list'); + + expect(copy.log, same(callLog)); + expect(copy.operationId, 'jobs.list'); + expect(copy.cache, isFalse); + }); + }); + + group('mandatory redaction', () { + const redactor = ApiLogRedactor( + ApiLoggingConfig.defaultSensitiveKeys, + ); + const boundedRedactor = ApiLogRedactor( + ApiLoggingConfig.defaultSensitiveKeys, + maxDepth: 3, + maxCollectionItems: 2, + maxStringLength: 12, + ); + + test('redacts case and naming variants recursively', () { + final safe = redactor.redact({ + 'Authorization': 'Bearer secret', + 'refresh_token': 'refresh-secret', + 'profile': { + 'passwordConfirmation': 'password-secret', + 'name': 'Driver', + }, + }) as Map; + + expect(safe['Authorization'], '[REDACTED]'); + expect(safe['refresh_token'], '[REDACTED]'); + expect( + (safe['profile'] as Map)['passwordConfirmation'], + '[REDACTED]', + ); + }); + + test('redacts secrets embedded in URL query parameters', () { + final safe = redactor.redact( + 'https://example.com/file?signature=secret&name=report', + ); + + expect(safe, contains('signature=%5BREDACTED%5D')); + expect(safe, isNot(contains('signature=secret'))); + }); + + test('bounds long values, collections, depth, and cycles', () { + final cyclic = {}; + cyclic['self'] = cyclic; + final safe = boundedRedactor.redact({ + 'long': 'abcdefghijklmnopqrstuvwxyz', + 'items': ['one', 'two', 'three'], + }) as Map; + final cyclicSafe = redactor.redact(cyclic); + + expect(safe['long'], 'abcdefghijkl...[TRUNCATED]'); + expect((safe['items'] as List).last, '[TRUNCATED]'); + expect(cyclicSafe.toString(), contains('[CIRCULAR]')); + }); + + test('summarizes binary, streams, and multipart without bytes', () { + final form = FormData.fromMap({ + 'name': 'document', + 'file': MultipartFile.fromBytes( + [1, 2, 3, 4], + filename: 'document.pdf', + ), + }); + + expect(redactor.redact(Uint8List.fromList([1, 2, 3])), + '[BINARY 3 bytes]'); + expect(redactor.redact(const Stream>.empty()), '[STREAM]'); + final multipart = redactor.redact(form) as Map; + expect(multipart['type'], 'multipart'); + expect(multipart['files'], 1); + expect(multipart['size'], 4); + expect(multipart.toString(), isNot(contains('[1, 2, 3, 4]'))); + }); + }); + + group('runtime integration', () { + test('emits one request, concise retries, and one final response', + () async { + var attempts = 0; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + attempts += 1; + request.response.headers.contentType = ContentType.json; + if (attempts < 3) { + request.response.statusCode = HttpStatus.serviceUnavailable; + request.response.write('{"error":"temporarily_unavailable"}'); + } else { + request.response.write('{"message":"ok","value":1}'); + } + await request.response.close(); + }); + final logger = _RecordingLogger(); + final key = 'logging-retry-${server.port}'; + + try { + final connection = await FlutterApiBridge.configure( + key: key, + config: ApiBridgeConfig( + baseUri: Uri.parse('http://127.0.0.1:${server.port}'), + auth: const NoAuthStrategy(), + cookiesEnabled: false, + cache: const ApiCacheConfig(enabled: false), + retry: const ApiRetryConfig( + maxAttempts: 3, + baseDelay: Duration(milliseconds: 1), + maxDelay: Duration(milliseconds: 1), + ), + logger: logger, + ), + ); + final result = await connection.execute>( + GetRequest>( + endpoint: '/retry', + options: const ApiGetRequestOptions( + cache: false, + noAuth: true, + operationId: 'test.retry', + ), + fromJson: (json) => Map.from(json as Map), + ), + ); + + expect(result, isA>>()); + expect(attempts, 3); + expect(logger.ofType(ApiLogEventType.request), hasLength(1)); + expect(logger.ofType(ApiLogEventType.retry), hasLength(2)); + expect(logger.ofType(ApiLogEventType.response), hasLength(1)); + expect(logger.ofType(ApiLogEventType.failure), isEmpty); + final response = logger.ofType(ApiLogEventType.response).single; + expect(response.source, 'network'); + expect(response.attempt, 3); + } finally { + await FlutterApiBridge.removeConnection(key); + await server.close(force: true); + } + }); + + test('reports a cache hit as the final response source', () async { + var requests = 0; + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + requests += 1; + request.response.headers.contentType = ContentType.json; + request.response.write('{"message":"ok","value":1}'); + await request.response.close(); + }); + final logger = _RecordingLogger(); + final key = 'logging-cache-${server.port}'; + + try { + final connection = await FlutterApiBridge.configure( + key: key, + config: ApiBridgeConfig( + baseUri: Uri.parse('http://127.0.0.1:${server.port}'), + auth: const NoAuthStrategy(), + cookiesEnabled: false, + cache: const ApiCacheConfig( + defaultPolicy: ApiCachePolicy.cacheFirst, + ), + retry: const ApiRetryConfig(enabled: false), + logger: logger, + ), + ); + const request = GetRequest>( + endpoint: '/cached', + options: ApiGetRequestOptions( + noAuth: true, + operationId: 'test.cached', + ), + ); + + await connection.execute>(request); + await connection.execute>(request); + + expect(requests, 1); + final responses = logger.ofType(ApiLogEventType.response); + expect(responses, hasLength(2)); + expect(responses.first.source, 'network'); + expect(responses.last.source, 'cache'); + } finally { + await FlutterApiBridge.removeConnection(key); + await server.close(force: true); + } + }); + + test('custom sinks receive typed events with mandatory redaction', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) async { + await utf8.decoder.bind(request).join(); + request.response.headers.contentType = ContentType.json; + request.response.headers.add( + HttpHeaders.setCookieHeader, + 'access_token=response-secret', + ); + request.response.write( + '{"message":"ok","accessToken":"response-secret"}', + ); + await request.response.close(); + }); + final logger = _RecordingLogger(); + final key = 'logging-redaction-${server.port}'; + + try { + final connection = await FlutterApiBridge.configure( + key: key, + config: ApiBridgeConfig( + baseUri: Uri.parse('http://127.0.0.1:${server.port}'), + auth: const NoAuthStrategy(), + cookiesEnabled: false, + cache: const ApiCacheConfig(enabled: false), + retry: const ApiRetryConfig(enabled: false), + logger: logger, + ), + ); + await connection.execute>( + const PostRequest>( + endpoint: '/redaction', + body: { + 'email': 'driver@example.com', + 'password': 'request-secret', + }, + options: ApiRequestOptions( + noAuth: true, + operationId: 'test.redaction', + headers: { + 'Authorization': 'Bearer request-secret', + }, + cookies: { + 'sessionToken': 'cookie-secret', + }, + log: ApiCallLogOptions( + requestHeaders: true, + requestBody: true, + responseHeaders: true, + responseBody: true, + cookies: true, + ), + ), + ), + ); + + final requestEvent = logger.ofType(ApiLogEventType.request).single; + final responseEvent = logger.ofType(ApiLogEventType.response).single; + expect(requestEvent, isA()); + expect(responseEvent, isA()); + expect(requestEvent.data.toString(), contains('[REDACTED]')); + expect(responseEvent.data.toString(), contains('[REDACTED]')); + expect(requestEvent.data.toString(), isNot(contains('request-secret'))); + expect(requestEvent.data.toString(), isNot(contains('cookie-secret'))); + expect(responseEvent.data.toString(), isNot(contains('response-secret'))); + } finally { + await FlutterApiBridge.removeConnection(key); + await server.close(force: true); + } + }); + + test('logger exceptions never affect auth transitions', () async { + final auth = ApiAuth( + strategy: const NoAuthStrategy(), + context: AuthStrategyContext( + connectionKey: 'throwing-logger', + storageNamespace: 'throwing-logger', + cookies: ApiCookieManager.memory( + baseUri: Uri.parse('https://api.example.com'), + ), + secureStorage: _MemoryCredentialStorage(), + ), + logger: const _ThrowingLogger(), + ); + + await expectLater(auth.initialize(), completes); + await expectLater( + auth.initializeUserSession(sessionId: 'session-1'), + completes, + ); + expect(auth.current.status, AuthSessionStatus.authenticated); + await auth.dispose(); + }); + }); +} + +class _RecordingLogger implements ApiLogger { + final List events = []; + + @override + void log(ApiLogEvent event) => events.add(event); + + List ofType(ApiLogEventType type) => + events.where((event) => event.type == type).toList(growable: false); +} + +class _ThrowingLogger implements ApiLogger { + const _ThrowingLogger(); + + @override + void log(ApiLogEvent event) => throw StateError('logger failed'); +} + +class _MemoryCredentialStorage implements ApiCredentialStorage { + final Map _values = {}; + + @override + Future delete({required String key}) async => _values.remove(key); + + @override + Future read({required String key}) async => _values[key]; + + @override + Future write({required String key, required String? value}) async { + if (value == null) { + _values.remove(key); + } else { + _values[key] = value; + } + } +} + +class _RealHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) => + super.createHttpClient(context); +}