diff --git a/lib/api/xmly_client.dart b/lib/api/xmly_client.dart index 0964aba..a84c4c5 100644 --- a/lib/api/xmly_client.dart +++ b/lib/api/xmly_client.dart @@ -370,6 +370,7 @@ class XmlyClient { AppLogLevel.debug, 'api', 'XmlyClient.request parameters', + localOnly: true, data: { 'method': method, 'origin': origin, @@ -379,7 +380,7 @@ class XmlyClient { }, ); } - return AppLog.instance.runWithErrorScope(() async { + return HttpLogOperation.run('api_request', () async { AppLog.instance.trace( "lib/api/xmly_client.dart::XmlyClient.request.closure#1", ); @@ -709,7 +710,9 @@ class XmlyClient { throw failure; } final code = '${payload['ret'] ?? 0}'; + diagnostic.apiCode = int.tryParse(code); if (code != (origin == searchOrigin ? '200' : '0')) { + diagnostic.apiRejected = true; if (code == '429') _rateLimit(origin, const Duration(seconds: 30)); if (code == '401') { if (authenticated) _expire(); @@ -2011,6 +2014,7 @@ class XmlyClient { AppLogLevel.debug, 'api', 'XmlyClient.resolve parameters', + localOnly: true, data: { 'trackId': trackId, 'quality': quality ?? this.quality, @@ -2019,7 +2023,7 @@ class XmlyClient { }, ); } - return AppLog.instance.runWithErrorScope(() { + return HttpLogOperation.run('audio_resolve', () { AppLog.instance.trace( "lib/api/xmly_client.dart::XmlyClient.resolve.closure#1", ); @@ -2056,7 +2060,7 @@ class XmlyClient { AppLog.instance.trace( "lib/api/xmly_client.dart::XmlyClient.cachedPrepaidAudio", ); - return AppLog.instance.runWithErrorScope(() { + return HttpLogOperation.run('cached_audio', () { AppLog.instance.trace( "lib/api/xmly_client.dart::XmlyClient.cachedPrepaidAudio.closure#1", ); diff --git a/lib/api/xmly_experience.dart b/lib/api/xmly_experience.dart index eac3ea3..ff8a132 100644 --- a/lib/api/xmly_experience.dart +++ b/lib/api/xmly_experience.dart @@ -1,3 +1,8 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:uuid/uuid.dart'; + import '../models/xmly_models.dart'; import 'xmly_client.dart'; import "../services/app_log.dart"; @@ -114,6 +119,98 @@ class AlbumIntroductionData { final HomeSection recommendations; } +/// Only the adjacent, bounded page summaries escape a membership scan. The +/// HMAC key never leaves this object, so album markers cannot link scans. +class _SubscriptionPagination { + _SubscriptionPagination() + : _markers = Hmac(sha256, utf8.encode(const Uuid().v4())) { + AppLog.instance.trace( + 'lib/api/xmly_experience.dart::_SubscriptionPagination.new', + ); + } + + final Hmac _markers; + Map? _previous, _current; + + void observe({ + required int page, + required int pageSize, + required Object? ret, + required Object? hasMore, + required List rows, + required Set seen, + }) { + AppLog.instance.trace( + 'lib/api/xmly_experience.dart::_SubscriptionPagination.observe', + ); + + final unique = {}; + final sequence = []; + var invalidIds = 0, added = 0; + for (final row in rows) { + final value = row['albumId']; + final text = value is String || value is int ? '$value' : ''; + final match = RegExp(r'^(?:xmly:)?([1-9][0-9]{0,18})$').firstMatch(text); + if (match == null) { + invalidIds++; + sequence.add(null); + continue; + } + final id = match[1]!; + if (unique.add(id) && !seen.contains(id)) added++; + sequence.add( + _markers.convert(utf8.encode(id)).toString().substring(0, 16), + ); + } + _previous = _current; + _current = Map.unmodifiable({ + 'page': page, + 'pageSize': pageSize, + // XmlyClient.request only returns after checking HTTP status == 200. + 'httpStatus': 200, + 'ret': ret is int + ? ret + : ret is String + ? int.tryParse(ret) + : null, + 'hasMore': hasMore is bool ? hasMore : null, + 'resultCount': rows.length, + 'uniqueCount': unique.length, + 'duplicateCount': rows.length - invalidIds - unique.length, + 'invalidIdCount': invalidIds, + 'accumulatedBefore': seen.length, + 'newCount': added, + 'accumulatedAfter': seen.length + added, + // The endpoint allows at most 50 rows; preserve their order for comparison. + 'albumMarkers': List.unmodifiable(sequence), + }); + } + + XmlyException failure(String reason, String code, String message) { + AppLog.instance.trace( + 'lib/api/xmly_experience.dart::_SubscriptionPagination.failure', + ); + + return XmlyException( + code, + message, + diagnostics: Map.unmodifiable({ + 'pagination': Map.unmodifiable({ + 'list': 'subscriptions', + 'reason': reason, + if (_previous != null) 'previousPage': _previous, + if (_current != null) 'currentPage': _current, + }), + }), + fingerprint: List.unmodifiable([ + 'xmplus', + 'subscription_pagination', + reason, + ]), + ); + } +} + /// Lite client surfaces and explicit official subscription/favorite mutations. /// No audio admission, billing, history writes, or credential-bearing caches. class XmlyExperience { @@ -205,6 +302,7 @@ class XmlyExperience { ); final seen = {}; + final subscriptionPages = favorite ? null : _SubscriptionPagination(); for (var page = 1; page <= 10000; page++) { final Iterable ids; final bool more; @@ -218,7 +316,11 @@ class XmlyExperience { }); more = result.hasMore; } else { - final result = await _subscriptions(page: page); + final result = await _subscriptions( + page: page, + diagnostics: subscriptionPages, + seen: seen, + ); ids = result.items.map((item) { AppLog.instance.trace( "lib/api/xmly_experience.dart::XmlyExperience._loadMembership.closure#2", @@ -230,10 +332,24 @@ class XmlyExperience { final before = seen.length; seen.addAll(ids); if ((ids.isNotEmpty || more) && seen.length == before) { + if (subscriptionPages != null) { + throw subscriptionPages.failure( + 'no_progress', + 'repeated_page', + '列表分页未前进,请刷新后重试', + ); + } throw const XmlyException('repeated_page', '列表分页未前进,请刷新后重试'); } if (!more) return seen; } + if (subscriptionPages != null) { + throw subscriptionPages.failure( + 'page_limit', + 'page_limit', + '列表超出读取范围,无法确认状态', + ); + } throw const XmlyException('page_limit', '列表超出读取范围,无法确认状态'); } @@ -485,6 +601,8 @@ class XmlyExperience { Future _subscriptions({ int page = 1, int pageSize = 30, + _SubscriptionPagination? diagnostics, + Set seen = const {}, }) async { AppLog.instance.trace( "lib/api/xmly_experience.dart::XmlyExperience._subscriptions", @@ -492,17 +610,32 @@ class XmlyExperience { _requireAccount(); _paging(page, pageSize); - final data = await _get( + final response = await client.request( + 'GET', + XmlyClient.mobile, '/fsubscribe-web/subscribe/comprehensive/rank/$_stamp', - {'pageId': page, 'sign': 2, 'size': pageSize}, + query: {'pageId': page, 'sign': 2, 'size': pageSize}, + ); + if (response['data'] is! Map) throw _invalid; + final data = response['data'] as Map; + final rows = _list(data['albumResults'], maximum: pageSize); + final pages = diagnostics ?? _SubscriptionPagination(); + pages.observe( + page: page, + pageSize: pageSize, + ret: response['ret'], + hasMore: data['hasMore'], + rows: rows, + seen: seen, ); final items = []; final ids = {}; - for (final raw in _list(data['albumResults'], maximum: pageSize)) { + for (final raw in rows) { final row = xmlyMap(raw); final album = _album(row); - if (!ids.add(album.id)) - throw const XmlyException('repeated_page', '订阅列表重复,请刷新后重试'); + if (!ids.add(album.id)) { + throw pages.failure('duplicate_ids', 'repeated_page', '订阅列表重复,请刷新后重试'); + } final unread = _integer(row['unreadNum']); if (unread == null || unread < 0) throw _invalid; items.add( @@ -515,7 +648,9 @@ class XmlyExperience { ); } final more = _more(data); - if (more && items.isEmpty) throw _invalid; + if (more && items.isEmpty) { + throw pages.failure('empty_page', _invalid.code, _invalid.message); + } return SubscriptionPage(items: items, hasMore: more); } diff --git a/lib/api/xmly_login.dart b/lib/api/xmly_login.dart index 75cc975..2f72e83 100644 --- a/lib/api/xmly_login.dart +++ b/lib/api/xmly_login.dart @@ -209,6 +209,7 @@ class XmlyLogin { AppLogLevel.debug, 'api', 'XmlyLogin._request parameters', + localOnly: true, data: { 'method': method, 'path': path, @@ -217,7 +218,7 @@ class XmlyLogin { }, ); } - return AppLog.instance.runWithErrorScope(() async { + return HttpLogOperation.run('login_request', () async { AppLog.instance.trace( "lib/api/xmly_login.dart::XmlyLogin._request.closure#1", ); @@ -355,6 +356,12 @@ class XmlyLogin { } } final transportFailure = retryableTransport(error) || timedOut; + if (error is XmlyLoginError && + error.upstreamCode != null && + diagnostic.status == 200) { + diagnostic.apiCode = error.upstreamCode; + diagnostic.apiRejected = true; + } if (error is! XmlyException && error is! FormatException) { diagnostic.transportError = '$error'; } diff --git a/lib/common/widgets/logged_network_image.dart b/lib/common/widgets/logged_network_image.dart index e217e55..7150526 100644 --- a/lib/common/widgets/logged_network_image.dart +++ b/lib/common/widgets/logged_network_image.dart @@ -72,7 +72,12 @@ class LoggedNetworkImage extends ImageProvider { AppLog.instance.trace( "lib/common/widgets/logged_network_image.dart::LoggedNetworkImage._loadAsync.begin", ); - return HttpLogAttempt(account: 'image', method: 'GET', url: url) + return HttpLogAttempt( + account: 'image', + method: 'GET', + url: url, + reportFailure: false, + ) ..requestHeaders = visibleHeaders ..requestHeaderAvailability = '显式图片请求头;底层 HTTP 库补充的请求头不可见' ..responseIsBinary = true; diff --git a/lib/models/xmly_models.dart b/lib/models/xmly_models.dart index 9245d0a..467c2e9 100644 --- a/lib/models/xmly_models.dart +++ b/lib/models/xmly_models.dart @@ -11,11 +11,15 @@ class XmlyException implements Exception { final String code, message; final Duration? retryAfter; final bool definitive; + final Map? diagnostics; + final List? fingerprint; const XmlyException( this.code, this.message, { this.retryAfter, this.definitive = false, + this.diagnostics, + this.fingerprint, }); @override String toString() { diff --git a/lib/services/album_cache.dart b/lib/services/album_cache.dart index 57847d7..62739f6 100644 --- a/lib/services/album_cache.dart +++ b/lib/services/album_cache.dart @@ -766,6 +766,7 @@ class AlbumCache extends ChangeNotifier { final bytes = BytesBuilder(copy: false); diagnostic = HttpLogAttempt( + reportFailure: false, account: '公共专辑封面(无账号)', method: 'GET', url: '$uri', diff --git a/lib/services/app_log.dart b/lib/services/app_log.dart index 0d2be20..3d40cd5 100644 --- a/lib/services/app_log.dart +++ b/lib/services/app_log.dart @@ -135,6 +135,10 @@ class AppLog { bool errorWasRecorded(Object error) => _currentErrorScope?.errors?.contains(error) ?? false; + /// A shared producer can deliver one recorded occurrence to another active + /// consumer. This does not suppress future invocations using the same error. + void inheritRecordedError(Object error) => _currentErrorScope?.record(error); + void linkError(Object replacement, Object original) { final scope = _currentErrorScope; if (scope != null && (scope.errors?.contains(original) ?? false)) { @@ -314,6 +318,7 @@ class AppLog { StackTrace? stackTrace, bool networkError = false, bool promptUser = false, + bool localOnly = false, String? requestId, }) { if (_inside || _closed) return; @@ -340,11 +345,13 @@ class AppLog { _pendingBytes += bytes.length; if (_persistenceError != null) _boundTail(); final scope = _currentErrorScope; - if (error != null && level.index >= AppLogLevel.error.index) { + if (!localOnly && + error != null && + level.index >= AppLogLevel.error.index) { scope?.record(error); } final callback = sink; - if (callback != null && category != 'method') { + if (!localOnly && callback != null && category != 'method') { try { final result = callback( AppLogRecord( diff --git a/lib/services/audio_cache.dart b/lib/services/audio_cache.dart index 2ab2d2d..60493d8 100644 --- a/lib/services/audio_cache.dart +++ b/lib/services/audio_cache.dart @@ -38,6 +38,7 @@ class AudioCache { Completer? _interruptedWait; int _producerEpoch = 0; Object? _failure; + bool _failureReported = false; StackTrace? _failureStack; bool _closed = false, _paused = false, _background = false; bool _validated = false, _ranges = true; @@ -343,7 +344,10 @@ class AudioCache { if (_closed) throw const XmlyException('cache_closed', '缓存已关闭'); if (_paused) throw const XmlyException('cache_paused', '下载已暂停'); - if (_failure != null) Error.throwWithStackTrace(_failure!, _failureStack!); + if (_failure != null) { + if (_failureReported) AppLog.instance.inheritRecordedError(_failure!); + Error.throwWithStackTrace(_failure!, _failureStack!); + } } void _checkProducer(int epoch) { @@ -393,6 +397,7 @@ class AudioCache { if (!_ranges && !complete) _validated = false; _paused = false; _failure = null; + _failureReported = false; _failureStack = null; } } @@ -422,7 +427,7 @@ class AudioCache { Future _produce(int epoch) { AppLog.instance.trace("lib/services/audio_cache.dart::AudioCache._produce"); - return AppLog.instance.runWithErrorScope(() async { + return HttpLogOperation.run('audio_download', () async { AppLog.instance.trace( "lib/services/audio_cache.dart::AudioCache._produce.closure#1", ); @@ -489,7 +494,11 @@ class AudioCache { 'media_unauthorized', 'rate_limited', }.contains(error.code)); - if (!expected) { + final reported = + !_closed && + !_paused && + HttpLogOperation.reportFailure(error, stack, promptUser: !expected); + if (!expected && !reported) { if (!AppLog.instance.errorWasRecorded(error)) { AppLog.instance.write( AppLogLevel.error, @@ -511,6 +520,7 @@ class AudioCache { ? const XmlyException('cache_paused', '下载已暂停') : error; AppLog.instance.linkError(_failure!, error); + _failureReported = AppLog.instance.errorWasRecorded(error); _failureStack = stack; for (final waiter in _waiting.values) { waiter.completeError(_failure!, stack); @@ -521,7 +531,7 @@ class AudioCache { _running = null; _signal(); } - }); + }, independent: true); } Future _refreshAuthorization(int epoch) async { diff --git a/lib/services/error_reporting.dart b/lib/services/error_reporting.dart index 11c2558..ab7bf09 100644 --- a/lib/services/error_reporting.dart +++ b/lib/services/error_reporting.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../models/xmly_models.dart'; import 'app_log.dart'; // Deliberately excluded from method tracing: telemetry must not trace itself. @@ -335,11 +336,13 @@ void _setTags(Scope scope, AppLogRecord record) { Map _diagnostics(AppLogRecord record) { final data = record.values['data']; + final error = record.error; final fields = data is Map ? Map.from(data) : const {}; return _bounded({ // Insertion order determines priority under the shared 64 KiB budget. + if (error is XmlyException) ...?error.diagnostics, 'sessionId': record.values['sessionId'], if (record.requestId != null) 'requestId': record.requestId, if (record.errorScopeId != null) 'errorScopeId': record.errorScopeId, @@ -409,6 +412,23 @@ SentryEvent? _beforeSend(SentryEvent event, Hint hint) { if (details is FlutterErrorDetails && details.silent) return null; final diagnostics = global?.diagnostics ?? hint.get('xmplus.diagnostics'); if (diagnostics != null) event.contexts['xmplus'] = diagnostics; + final error = event.throwable; + if (error is XmlyException) { + final attached = error.diagnostics; + if (attached != null) { + final existing = event.contexts['xmplus']; + event.contexts['xmplus'] = _bounded({ + ...attached, + if (existing is Map) ...Map.from(existing), + }, _diagnosticBytes * 3 ~/ 4); + } + final fingerprint = error.fingerprint; + if (fingerprint != null && fingerprint.isNotEmpty) { + // Do not include the default stack-based key: async wrapper frames + // must not split the same pagination protection into different groups. + event.fingerprint = fingerprint; + } + } event.tags = { ...?event.tags, 'platform': Platform.operatingSystem, diff --git a/lib/services/http_log_attempt.dart b/lib/services/http_log_attempt.dart index 61472ff..1344e95 100644 --- a/lib/services/http_log_attempt.dart +++ b/lib/services/http_log_attempt.dart @@ -1,8 +1,14 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:uuid/uuid.dart'; import 'app_log.dart'; +import 'http_log_operation.dart'; + +export 'http_log_operation.dart'; /// One observation per actual HTTP attempt, including redirects and retries. class HttpLogAttempt { @@ -10,15 +16,21 @@ class HttpLogAttempt { required this.account, required this.method, required this.url, + this.reportFailure = true, }) { AppLog.instance.trace( "lib/services/http_log_attempt.dart::HttpLogAttempt.new", ); + _operation = reportFailure ? HttpLogOperation.current : null; + _attempt = _operation?.startAttempt(); } final DateTime timestamp = DateTime.now().toUtc(); final String requestId = const Uuid().v4(); final String account, method; + final bool reportFailure; + late final HttpLogOperation? _operation; + late final int? _attempt; final Stopwatch _elapsed = Stopwatch()..start(); String url; String requestHeaderAvailability = @@ -27,6 +39,8 @@ class HttpLogAttempt { String requestBody = '(无请求体)'; String Function()? requestBodyCapture; int? status; + int? apiCode; + bool apiRejected = false; String? transportError; Map responseHeaders = {}; List Function()? responseBytes; @@ -52,6 +66,7 @@ class HttpLogAttempt { 'HTTP request', requestId: requestId, data: _requestData(), + localOnly: !reportFailure || _operation != null, ); } @@ -69,6 +84,7 @@ class HttpLogAttempt { 'HTTP response', requestId: requestId, data: _responseData(captureBinary: false), + localOnly: !reportFailure || _operation != null, ); } @@ -84,6 +100,15 @@ class HttpLogAttempt { if (_failed || _cancelled) return Future.value(); _failed = true; if (status != null) succeeded(); + _operation?.failedAttempt( + _attempt!, + error, + kind: _failureKind(error), + promptUser: promptUser, + status: status, + apiCode: apiCode, + ); + final localOnly = !reportFailure || _operation != null; AppLog.instance.write( AppLogLevel.error, 'network', @@ -93,11 +118,35 @@ class HttpLogAttempt { error: error, stackTrace: stackTrace, networkError: true, - promptUser: promptUser, + promptUser: !localOnly && promptUser, + localOnly: localOnly, ); return AppLog.instance.flush(); } + String _failureKind(Object error) { + AppLog.instance.trace( + 'lib/services/http_log_attempt.dart::HttpLogAttempt._failureKind', + ); + if (apiRejected) return 'api'; + if (error is TimeoutException || + error is DioException && + const { + DioExceptionType.connectionTimeout, + DioExceptionType.sendTimeout, + DioExceptionType.receiveTimeout, + }.contains(error.type)) + return 'timeout'; + if (error is SocketException || + error is HandshakeException || + error is HttpException || + error is DioException && error.type != DioExceptionType.badResponse) { + return 'transport'; + } + if (status != null && (status! < 200 || status! >= 400)) return 'http'; + return 'validation'; + } + void cancelled([Object? reason]) { AppLog.instance.trace( "lib/services/http_log_attempt.dart::HttpLogAttempt.cancelled", @@ -116,6 +165,7 @@ class HttpLogAttempt { 'elapsedMs': _elapsed.elapsedMilliseconds, if (reason != null) 'reason': '$reason', }, + localOnly: !reportFailure || _operation != null, ); } @@ -151,6 +201,7 @@ class HttpLogAttempt { final data = { ..._requestData(), 'status': status, + if (apiCode != null) 'apiCode': apiCode, 'responseHeaders': responseHeaders, 'elapsedMs': _elapsed.elapsedMilliseconds, 'completeness': completeness, diff --git a/lib/services/http_log_operation.dart b/lib/services/http_log_operation.dart new file mode 100644 index 0000000..43f275d --- /dev/null +++ b/lib/services/http_log_operation.dart @@ -0,0 +1,168 @@ +import 'dart:async'; +import 'dart:collection'; + +import '../models/xmly_models.dart'; +import 'app_log.dart'; + +/// An awaited operation owns retries and authorization refreshes. Only its +/// terminal failure is reported; attempt bodies remain in the local log. +class HttpLogOperation { + HttpLogOperation._(this.name) { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation._', + ); + } + + static final _key = Object(); + static const _retainedFailures = 8; + final String name; + final _failures = ListQueue>(); + int _attemptCount = 0, _failureCount = 0; + bool _active = true, _reported = false; + bool _promptUser = true; + + static HttpLogOperation? get current { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.current.get', + ); + final operation = Zone.current[_key] as HttpLogOperation?; + return operation?._active == true ? operation : null; + } + + static Future run( + String name, + Future Function() action, { + bool independent = false, + }) { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.run', + ); + if (!independent && current != null) { + return AppLog.instance.runWithErrorScope(action); + } + final operation = HttpLogOperation._(name); + return AppLog.instance.runWithErrorScope(() { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.run.closure#1', + ); + return runZoned(() async { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.run.closure#1.closure#1', + ); + try { + return await action(); + } catch (error, stack) { + operation._reportFailure(error, stack); + rethrow; + } finally { + operation._active = false; + operation._failures.clear(); + } + }, zoneValues: {_key: operation}); + }); + } + + int startAttempt() { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.startAttempt', + ); + return ++_attemptCount; + } + + void failedAttempt( + int attempt, + Object error, { + required String kind, + required bool promptUser, + int? status, + int? apiCode, + }) { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.failedAttempt', + ); + if (!_active) return; + _failureCount++; + _promptUser = promptUser; + if (_failures.length == _retainedFailures) _failures.removeFirst(); + _failures.add( + Map.unmodifiable({ + 'attempt': attempt, + 'kind': kind, + 'exceptionType': '${error.runtimeType}', + if (status != null) 'httpStatus': status, + if (apiCode != null) 'apiCode': apiCode, + }), + ); + } + + /// For producers which deliver a terminal failure through stored state or a + /// stream instead of throwing it from the operation's Future. + static bool reportFailure( + Object error, + StackTrace stack, { + bool promptUser = true, + }) { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation.reportFailure', + ); + return current?._reportFailure(error, stack, promptUser: promptUser) ?? + false; + } + + bool _reportFailure( + Object error, + StackTrace stack, { + bool promptUser = true, + }) { + AppLog.instance.trace( + 'lib/services/http_log_operation.dart::HttpLogOperation._reportFailure', + ); + if (!_active || _failureCount == 0) return false; + if (error is XmlyException && + const { + 'client_closed', + 'request_cancelled', + 'cache_closed', + 'cache_paused', + 'cache_source_changed', + 'closed', + 'challenge_invalid', + }.contains(error.code)) { + return true; + } + if (_reported || AppLog.instance.errorWasRecorded(error)) return true; + _reported = true; + final kind = _failures.last['kind']; + AppLog.instance.write( + AppLogLevel.error, + kind == 'api' ? 'api' : 'network', + 'HTTP operation failed', + data: { + 'httpOperation': { + 'operation': name, + 'outcome': 'failed', + 'attemptCount': _attemptCount, + 'failedAttemptCount': _failureCount, + 'failures': _failures.toList(growable: false), + if (_failureCount > _failures.length) + 'omittedFailures': _failureCount - _failures.length, + }, + }, + error: error, + stackTrace: stack, + networkError: kind == 'transport' || kind == 'timeout', + promptUser: + promptUser && + _promptUser && + !(error is XmlyException && + const { + 'login_required', + 'login_expired', + 'permission_denied', + 'rate_limited', + 'media_unauthorized', + }.contains(error.code)), + ); + return true; + } +} diff --git a/lib/services/media_processor.dart b/lib/services/media_processor.dart index 9c9b77d..78178c1 100644 --- a/lib/services/media_processor.dart +++ b/lib/services/media_processor.dart @@ -1235,7 +1235,12 @@ class MediaProcessor { diagnostic = null; final bytes = []; diagnostic = - HttpLogAttempt(account: '公共嵌入封面(无账号)', method: 'GET', url: '$uri') + HttpLogAttempt( + account: '公共嵌入封面(无账号)', + method: 'GET', + url: '$uri', + reportFailure: false, + ) ..responseBytes = (() { AppLog.instance.trace( "lib/services/media_processor.dart::MediaProcessor._cover.closure#3", diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index f619632..1e5be17 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -380,7 +380,7 @@ class UpdateService extends ChangeNotifier { AppLog.instance.trace( "lib/services/update_service.dart::UpdateService.check", ); - return AppLog.instance.runWithErrorScope(() async { + return HttpLogOperation.run('update_check', () async { AppLog.instance.trace( "lib/services/update_service.dart::UpdateService.check.closure#1", ); @@ -561,7 +561,8 @@ class UpdateService extends ChangeNotifier { "lib/services/update_service.dart::UpdateService._recordFailure", ); - if (!AppLog.instance.errorWasRecorded(failure)) { + if (!HttpLogOperation.reportFailure(failure, stack) && + !AppLog.instance.errorWasRecorded(failure)) { AppLog.instance.write( AppLogLevel.error, 'update',