From a082a786b08c8ee652167ba5eac1ea531f82c9a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:34:31 +0000 Subject: [PATCH 1/7] feat(replay): add app-event hook and network observation Provide a provider-neutral external_event hook with bounded parameter policies and a generic exactly-once network_call token, plus a tugboat_dio interceptor that maps Dio request lifecycles onto that token without importing Dio into core. Co-authored-by: Chinmay Kabi --- packages/tugboat/CHANGELOG.md | 23 + packages/tugboat/README.md | 34 +- packages/tugboat/example/pubspec.yaml | 2 +- packages/tugboat/lib/src/controller.dart | 250 ++++++- packages/tugboat/lib/src/external_event.dart | 311 ++++++++ packages/tugboat/lib/src/health.dart | 34 + .../tugboat/lib/src/network_observer.dart | 62 ++ packages/tugboat/lib/src/sdk_version.dart | 2 +- packages/tugboat/lib/src/tugboat.dart | 63 ++ packages/tugboat/lib/tugboat.dart | 15 + packages/tugboat/pubspec.yaml | 2 +- .../test/external_event_and_network_test.dart | 209 ++++++ .../test/replay/tugboat_health_test.dart | 7 + packages/tugboat_dio/CHANGELOG.md | 8 + packages/tugboat_dio/LICENSE | 667 ++++++++++++++++++ packages/tugboat_dio/README.md | 54 ++ packages/tugboat_dio/analysis_options.yaml | 1 + .../lib/src/tugboat_dio_interceptor.dart | 139 ++++ packages/tugboat_dio/lib/tugboat_dio.dart | 4 + packages/tugboat_dio/pubspec.yaml | 27 + .../test/tugboat_dio_interceptor_test.dart | 274 +++++++ pubspec.lock | 46 +- pubspec.yaml | 6 +- 23 files changed, 2218 insertions(+), 22 deletions(-) create mode 100644 packages/tugboat/lib/src/external_event.dart create mode 100644 packages/tugboat/lib/src/network_observer.dart create mode 100644 packages/tugboat/test/external_event_and_network_test.dart create mode 100644 packages/tugboat_dio/CHANGELOG.md create mode 100644 packages/tugboat_dio/LICENSE create mode 100644 packages/tugboat_dio/README.md create mode 100644 packages/tugboat_dio/analysis_options.yaml create mode 100644 packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart create mode 100644 packages/tugboat_dio/lib/tugboat_dio.dart create mode 100644 packages/tugboat_dio/pubspec.yaml create mode 100644 packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index 57cd583..db90fd4 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -1,3 +1,26 @@ +## 0.6.0 + +### Added + +- **Provider-neutral app-event hook** — `TugboatReplay.eventHook` records one + logical `external_event` on the evidence stream with a bounded parameter + policy (`namesOnly`, `allowList`, `transform`, or exploration-only + `allowAll`). Values are deep-copied at hook time; dormant/disabled calls are + safe no-ops. +- **Generic network observation** — `TugboatReplay.beginNetworkCall` exposes an + exactly-once token for method, safe route template, status, outcome, and + duration. No headers, queries, bodies, raw errors, or stack traces are + retained. +- **Evidence isolation** — external and network evidence stamp session identity + only and never inherit active exploration `actionId`, `relatedEventId`, or + target/state anchors. +- **Evidence health counters** — `TugboatSdkHealth.evidence` exposes bounded + accepted/dropped/duplicate-finish counts without retaining rejected raw + values. +- **`tugboat_dio` companion package** — Dio interceptor that maps request + lifecycle callbacks onto the core network token without importing Dio into + core. + ## 0.5.0 ### Breaking changes diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 4522b6f..69b3f7b 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -5,7 +5,7 @@ checkpoints around meaningful interactions, compact structural anchors, route transitions, scrolling evidence, and optional viewport semantic maps. Capture can be sent to the local exploration WebSocket, the HTTP collector, or both. -The current package version is `0.5.0`. Session JSON writers emit schema +The current package version is `0.6.0`. Session JSON writers emit schema version `9`; compatibility readers should accept versions `6` through `9`. Structural fingerprints use fingerprint schema version `6`. @@ -19,6 +19,38 @@ import 'package:tugboat/tugboat.dart'; The package requires Dart 3.9.2 or newer and Flutter 3.35.0 or newer. +### Optional Dio network evidence + +```yaml +dependencies: + tugboat_dio: ^0.6.0 +``` + +See `packages/tugboat_dio/README.md`. + +## App events and network observation + +Opt-in evidence hooks append to the active session without coupling to Amplitude, +Firebase, or a specific HTTP client: + +```dart +final appEvents = TugboatReplay.eventHook( + source: 'analytics', + parameterPolicy: TugboatParameterPolicy.allowList({'method', 'result'}), +); +appEvents.record('USER_LOGIN', parameters: {'method': 'email'}); + +final call = TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/blend/:blendId', // host-supplied template only +); +call.complete(statusCode: 200); +``` + +Both emit on `stream: evidence` and never inherit exploration `actionId` or UI +anchors. Parameter values are omitted unless an explicit policy allows them. +`allowAll` is an exploration escape hatch, not a production default. + ## Migrating to 0.5.0 This is a breaking release. Session JSON written by 0.5.0 uses schema version diff --git a/packages/tugboat/example/pubspec.yaml b/packages/tugboat/example/pubspec.yaml index 621d096..5ff6573 100644 --- a/packages/tugboat/example/pubspec.yaml +++ b/packages/tugboat/example/pubspec.yaml @@ -32,7 +32,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - tugboat: ^0.5.0 + tugboat: ^0.6.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index a3f244c..c91a758 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -11,9 +11,11 @@ import 'collector_http_sink.dart'; import 'coordinate_space.dart'; import 'debug_logging.dart'; import 'exploration_sink.dart'; +import 'external_event.dart'; import 'health.dart'; import 'interaction_transaction.dart'; import 'models.dart'; +import 'network_observer.dart'; import 'outbox/outbox.dart'; import 'outbox/outbox_sink.dart'; import 'replay_config.dart'; @@ -797,6 +799,13 @@ class TugboatReplayController extends ChangeNotifier { final Map _captureDiagnosticOutcomes = {}; int _captureDiagnosticTotal = 0; String? _lastCaptureDiagnosticOutcome; + static const int _maxEvidenceCount = 10000; + int _externalAccepted = 0; + int _externalDropped = 0; + int _networkAccepted = 0; + int _networkDropped = 0; + int _networkDuplicateFinishes = 0; + String? _lastEvidenceDropReason; bool _capturePumpScheduled = false; bool _skipCapture = false; bool _captureLifecycleActive = true; @@ -1245,6 +1254,14 @@ class TugboatReplayController extends ChangeNotifier { lastOutcome: _lastCaptureDiagnosticOutcome, outcomes: Map.unmodifiable(_captureDiagnosticOutcomes), ), + evidence: TugboatEvidenceHealth( + externalAccepted: _externalAccepted, + externalDropped: _externalDropped, + networkAccepted: _networkAccepted, + networkDropped: _networkDropped, + networkDuplicateFinishes: _networkDuplicateFinishes, + lastDropReason: _lastEvidenceDropReason, + ), truncated: _session?.truncated ?? false, recentFailures: List.unmodifiable(_recentFailures), ); @@ -1354,6 +1371,12 @@ class TugboatReplayController extends ChangeNotifier { _captureDiagnosticOutcomes.clear(); _captureDiagnosticTotal = 0; _lastCaptureDiagnosticOutcome = null; + _externalAccepted = 0; + _externalDropped = 0; + _networkAccepted = 0; + _networkDropped = 0; + _networkDuplicateFinishes = 0; + _lastEvidenceDropReason = null; if (!_disposed) notifyListeners(); final context = TugboatSinkSessionContext( @@ -4231,20 +4254,178 @@ class TugboatReplayController extends ChangeNotifier { } void _addEvent(TugboatEvent event) { + _appendEvent(event, inheritActionContext: true); + } + + /// Session-stamped evidence that must never inherit action/interaction + /// context (active [actionId], related interaction, or anchors). + void _appendEvidenceEvent(TugboatEvent event) { + _appendEvent(event, inheritActionContext: false); + } + + void _appendEvent(TugboatEvent event, {required bool inheritActionContext}) { final session = _session; if (session == null) return; - final enriched = event.withExplorationContext( - sessionId: session.id, - captureSessionId: session.id, - activationRequestId: session.activationRequestId ?? activationRequestId, - explorationRunId: _activeExplorationRunId ?? config.explorationRunId, - actionId: _activeActionId, + final enriched = event.copyWith( + sessionId: event.sessionId ?? session.id, + captureSessionId: event.captureSessionId ?? session.id, + activationRequestId: + event.activationRequestId ?? + session.activationRequestId ?? + activationRequestId, + explorationRunId: inheritActionContext + ? (event.explorationRunId ?? + _activeExplorationRunId ?? + config.explorationRunId) + : (event.explorationRunId ?? session.explorationRunId), + actionId: inheritActionContext + ? (event.actionId ?? _activeActionId) + : event.actionId, ); session.events.add(enriched); _sinkHub?.recordEvent(enriched); _trim(); } + /// Records one logical host app/analytics event onto the evidence stream. + /// + /// Safe no-op when capture is dormant. Never inherits action/interaction + /// context. Host failures inside policy transforms are swallowed. + void recordExternalEvent({ + required String name, + String? source, + Map? parameters, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly, + }) { + try { + if (_disposed || _session == null || _endSessionFuture != null) { + _noteEvidenceDrop(external: true, reason: 'no_active_session'); + return; + } + final boundedName = boundExternalLabel( + name, + TugboatParameterLimits.maxNameLength, + ); + if (boundedName == null) { + _noteEvidenceDrop(external: true, reason: 'invalid_name'); + return; + } + final boundedSource = boundExternalLabel( + source, + TugboatParameterLimits.maxSourceLength, + ); + final snapshot = snapshotExternalParameters( + policy: parameterPolicy, + parameters: parameters, + ); + final data = { + if (boundedSource != null) 'source': boundedSource, + 'name': boundedName, + 'parameterKeys': snapshot.parameterKeys, + if (snapshot.parameters != null) 'parameters': snapshot.parameters, + 'capture': snapshot.toCaptureMetadata(), + }; + _appendEvidenceEvent( + TugboatEvent( + id: _nextId('event'), + atMs: atMs, + type: 'external_event', + stream: TugboatEventStream.evidence, + data: data, + ), + ); + _externalAccepted = _clampEvidenceCount(_externalAccepted + 1); + } catch (_) { + _noteEvidenceDrop(external: true, reason: 'record_failed'); + } + } + + /// Begins observation of one logical network call. + /// + /// Returns a no-op token when dormant/disabled or when [route] is empty. + /// The recorded route must already be a safe host-supplied template. + TugboatNetworkCall beginNetworkCall({ + required String method, + required String route, + }) { + try { + if (_disposed || _session == null || _endSessionFuture != null) { + _noteEvidenceDrop(external: false, reason: 'no_active_session'); + return const TugboatNoOpNetworkCall(); + } + final normalizedMethod = normalizeNetworkMethod(method); + final normalizedRoute = normalizeNetworkRoute(route); + if (normalizedMethod == null || normalizedRoute == null) { + _noteEvidenceDrop(external: false, reason: 'invalid_route'); + return const TugboatNoOpNetworkCall(); + } + return _ActiveNetworkCall( + controller: this, + method: normalizedMethod, + route: normalizedRoute, + startedAtMs: atMs, + ); + } catch (_) { + _noteEvidenceDrop(external: false, reason: 'begin_failed'); + return const TugboatNoOpNetworkCall(); + } + } + + void _finishNetworkCall({ + required String method, + required String route, + required int startedAtMs, + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) { + try { + if (_disposed || _session == null || _endSessionFuture != null) { + _noteEvidenceDrop(external: false, reason: 'no_active_session'); + return; + } + final durationMs = (atMs - startedAtMs).clamp(0, 24 * 60 * 60 * 1000); + final data = { + 'method': method, + 'route': route, + if (statusCode != null) 'statusCode': statusCode, + 'outcome': outcome.wireName, + 'durationMs': durationMs, + if (attemptCount != null && attemptCount > 0) 'attemptCount': attemptCount, + }; + _appendEvidenceEvent( + TugboatEvent( + id: _nextId('event'), + atMs: atMs, + type: 'network_call', + stream: TugboatEventStream.evidence, + data: data, + ), + ); + _networkAccepted = _clampEvidenceCount(_networkAccepted + 1); + } catch (_) { + _noteEvidenceDrop(external: false, reason: 'finish_failed'); + } + } + + void _noteNetworkDuplicateFinish() { + _networkDuplicateFinishes = _clampEvidenceCount( + _networkDuplicateFinishes + 1, + ); + } + + void _noteEvidenceDrop({required bool external, required String reason}) { + if (external) { + _externalDropped = _clampEvidenceCount(_externalDropped + 1); + } else { + _networkDropped = _clampEvidenceCount(_networkDropped + 1); + } + _lastEvidenceDropReason = reason; + } + + int _clampEvidenceCount(int value) => + value > _maxEvidenceCount ? _maxEvidenceCount : value; + void setExplorationActionWindow({ required String explorationRunId, required String actionId, @@ -4390,6 +4571,63 @@ class TugboatReplayController extends ChangeNotifier { String _nextId(String prefix) => '$prefix-${_id++}'; } +class _ActiveNetworkCall implements TugboatNetworkCall { + _ActiveNetworkCall({ + required TugboatReplayController controller, + required this.method, + required this.route, + required this.startedAtMs, + }) : _controller = controller; + + final TugboatReplayController _controller; + final String method; + final String route; + final int startedAtMs; + bool _finished = false; + + @override + void complete({int? statusCode, int? attemptCount}) { + _finish( + outcome: TugboatNetworkOutcome.response, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } + + @override + void fail({ + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) { + _finish( + outcome: outcome, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } + + void _finish({ + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) { + if (_finished) { + _controller._noteNetworkDuplicateFinish(); + return; + } + _finished = true; + _controller._finishNetworkCall( + method: method, + route: route, + startedAtMs: startedAtMs, + outcome: outcome, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } +} + /// Adapts a session-owned factory sink to the legacy hub interface. class _FactorySinkAdapter implements TugboatCaptureSink { _FactorySinkAdapter(this._sink, this._context); diff --git a/packages/tugboat/lib/src/external_event.dart b/packages/tugboat/lib/src/external_event.dart new file mode 100644 index 0000000..cc62fab --- /dev/null +++ b/packages/tugboat/lib/src/external_event.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; + +/// Closed vocabulary for how external-event parameter values were retained. +abstract final class TugboatParameterCaptureValues { + static const namesOnly = 'names_only'; + static const allowList = 'allow_list'; + static const transform = 'transform'; + static const allowAll = 'allow_all'; +} + +/// Sentinel returned from a [TugboatParameterPolicy.transform] callback to omit +/// a parameter value without treating `null` as a drop. +class TugboatParameterDrop { + const TugboatParameterDrop._(); +} + +/// Policy controlling which external-event parameter values are retained. +/// +/// Parameter keys may be captured by default. Values are captured only through +/// an explicit allow-list, transform, or the deliberately named [allowAll] +/// exploration escape hatch. +class TugboatParameterPolicy { + const TugboatParameterPolicy._({ + required this.captureValues, + this.allowedKeys, + this.transform, + }); + + /// Record event name plus bounded parameter keys only. Default production + /// policy. + static const namesOnly = TugboatParameterPolicy._( + captureValues: TugboatParameterCaptureValues.namesOnly, + ); + + /// Preserve JSON-safe values only for the named keys. + static TugboatParameterPolicy allowList(Set keys) => + TugboatParameterPolicy._( + captureValues: TugboatParameterCaptureValues.allowList, + allowedKeys: Set.unmodifiable(keys), + ); + + /// Host callback returns the retained JSON-safe value, or [drop] to omit it. + /// Callback failures are caught and treated as drops. + static TugboatParameterPolicy transform( + Object? Function(String key, Object? value) transform, + ) => TugboatParameterPolicy._( + captureValues: TugboatParameterCaptureValues.transform, + transform: transform, + ); + + /// Exploration-only escape hatch that retains all JSON-safe values within + /// hard limits. Can capture feedback text, search terms, IDs, and other user + /// content. Do not use as the default production example. + static const allowAll = TugboatParameterPolicy._( + captureValues: TugboatParameterCaptureValues.allowAll, + ); + + /// Sentinel for transform callbacks. + static const drop = TugboatParameterDrop._(); + + final String captureValues; + final Set? allowedKeys; + final Object? Function(String key, Object? value)? transform; +} + +/// Hard limits applied when snapshotting external-event parameters. +abstract final class TugboatParameterLimits { + static const maxDepth = 4; + static const maxTopLevelKeys = 64; + static const maxCollectionItems = 256; + static const maxKeyLength = 128; + static const maxStringLength = 1024; + static const maxEncodedBytes = 16 * 1024; + static const maxNameLength = 256; + static const maxSourceLength = 128; +} + +/// Result of applying a [TugboatParameterPolicy] to a host parameter map. +class TugboatParameterSnapshot { + const TugboatParameterSnapshot({ + required this.parameterKeys, + required this.parameters, + required this.captureValues, + required this.truncated, + required this.droppedCount, + }); + + final List parameterKeys; + final Map? parameters; + final String captureValues; + final bool truncated; + final int droppedCount; + + Map toCaptureMetadata() => { + 'values': captureValues, + 'truncated': truncated, + 'droppedCount': droppedCount, + }; +} + +/// Snapshots and bounds host parameters into JSON-safe retained values. +TugboatParameterSnapshot snapshotExternalParameters({ + required TugboatParameterPolicy policy, + Map? parameters, +}) { + final raw = parameters; + if (raw == null || raw.isEmpty) { + return TugboatParameterSnapshot( + parameterKeys: const [], + parameters: null, + captureValues: policy.captureValues, + truncated: false, + droppedCount: 0, + ); + } + + var dropped = 0; + var truncated = false; + var collectionItems = 0; + final keys = []; + final retained = {}; + + final entries = raw.entries.take(TugboatParameterLimits.maxTopLevelKeys); + if (raw.length > TugboatParameterLimits.maxTopLevelKeys) { + truncated = true; + dropped += raw.length - TugboatParameterLimits.maxTopLevelKeys; + } + + for (final entry in entries) { + final key = entry.key; + if (key.isEmpty || key.length > TugboatParameterLimits.maxKeyLength) { + dropped += 1; + truncated = true; + continue; + } + keys.add(key); + + if (policy.captureValues == TugboatParameterCaptureValues.namesOnly) { + continue; + } + + if (policy.captureValues == TugboatParameterCaptureValues.allowList) { + final allowed = policy.allowedKeys; + if (allowed == null || !allowed.contains(key)) { + dropped += 1; + continue; + } + } + + Object? candidate = entry.value; + if (policy.captureValues == TugboatParameterCaptureValues.transform) { + final transform = policy.transform; + if (transform == null) { + dropped += 1; + continue; + } + try { + candidate = transform(key, entry.value); + } catch (_) { + dropped += 1; + continue; + } + if (identical(candidate, TugboatParameterPolicy.drop)) { + dropped += 1; + continue; + } + } + + final copied = _copyJsonSafe( + candidate, + depth: 1, + seen: {}, + dropped: (count) { + dropped += count; + truncated = true; + }, + onCollectionItem: () { + collectionItems += 1; + if (collectionItems > TugboatParameterLimits.maxCollectionItems) { + truncated = true; + return false; + } + return true; + }, + ); + if (copied == _unsupported) { + dropped += 1; + continue; + } + retained[key] = copied; + } + + Map? parametersOut; + if (policy.captureValues != TugboatParameterCaptureValues.namesOnly && + retained.isNotEmpty) { + parametersOut = Map.unmodifiable(retained); + final encodedLength = utf8.encode(jsonEncode(parametersOut)).length; + if (encodedLength > TugboatParameterLimits.maxEncodedBytes) { + // Drop values entirely when the bounded payload still exceeds the budget. + // Keys remain so the event stays observable without retaining oversize + // content. + truncated = true; + dropped += retained.length; + parametersOut = null; + } + } + + return TugboatParameterSnapshot( + parameterKeys: List.unmodifiable(keys), + parameters: parametersOut, + captureValues: policy.captureValues, + truncated: truncated, + droppedCount: dropped, + ); +} + +const Object _unsupported = Object(); + +Object? _copyJsonSafe( + Object? value, { + required int depth, + required Set seen, + required void Function(int count) dropped, + required bool Function() onCollectionItem, +}) { + if (value == null || value is bool) return value; + if (value is num) { + if (value.isFinite) return value; + dropped(1); + return _unsupported; + } + if (value is String) { + if (value.length <= TugboatParameterLimits.maxStringLength) return value; + dropped(1); + return _unsupported; + } + if (depth > TugboatParameterLimits.maxDepth) { + dropped(1); + return _unsupported; + } + if (value is Map) { + if (!seen.add(value)) { + dropped(1); + return _unsupported; + } + final out = {}; + for (final entry in value.entries) { + final key = entry.key; + if (key is! String || + key.isEmpty || + key.length > TugboatParameterLimits.maxKeyLength) { + dropped(1); + continue; + } + if (!onCollectionItem()) { + dropped(1); + break; + } + final copied = _copyJsonSafe( + entry.value, + depth: depth + 1, + seen: seen, + dropped: dropped, + onCollectionItem: onCollectionItem, + ); + if (identical(copied, _unsupported)) continue; + out[key] = copied; + } + seen.remove(value); + return out; + } + if (value is Iterable) { + if (!seen.add(value)) { + dropped(1); + return _unsupported; + } + final out = []; + for (final item in value) { + if (!onCollectionItem()) { + dropped(1); + break; + } + final copied = _copyJsonSafe( + item, + depth: depth + 1, + seen: seen, + dropped: dropped, + onCollectionItem: onCollectionItem, + ); + if (identical(copied, _unsupported)) continue; + out.add(copied); + } + seen.remove(value); + return out; + } + dropped(1); + return _unsupported; +} + +/// Host-facing callable for recording one logical app/analytics event. +abstract interface class TugboatEventHook { + void record(String name, {Map? parameters}); +} + +String? boundExternalLabel(String? value, int maxLength) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.length > maxLength) return trimmed.substring(0, maxLength); + return trimmed; +} diff --git a/packages/tugboat/lib/src/health.dart b/packages/tugboat/lib/src/health.dart index fdeec16..7d490e6 100644 --- a/packages/tugboat/lib/src/health.dart +++ b/packages/tugboat/lib/src/health.dart @@ -9,6 +9,7 @@ class TugboatSdkHealth { this.outbox, this.screenshots = const TugboatScreenshotBudgetHealth(), this.captureDiagnostics = const TugboatCaptureDiagnosticHealth(), + this.evidence = const TugboatEvidenceHealth(), this.truncated = false, this.recentFailures = const [], }); @@ -24,6 +25,9 @@ class TugboatSdkHealth { /// Bounded, privacy-safe capture outcome counts. Additive to screenshot /// budget health so existing consumers remain compatible. final TugboatCaptureDiagnosticHealth captureDiagnostics; + + /// Bounded counters for external app-event and network evidence. + final TugboatEvidenceHealth evidence; final bool truncated; final List recentFailures; @@ -36,11 +40,41 @@ class TugboatSdkHealth { if (outbox != null) 'outbox': outbox!.toJson(), 'screenshots': screenshots.toJson(), 'captureDiagnostics': captureDiagnostics.toJson(), + 'evidence': evidence.toJson(), 'truncated': truncated, 'recentFailures': recentFailures.map((f) => f.toJson()).toList(), }; } +/// Bounded counters for opt-in external and network evidence. Drop reasons use +/// a closed vocabulary and never retain rejected raw values or paths. +class TugboatEvidenceHealth { + const TugboatEvidenceHealth({ + this.externalAccepted = 0, + this.externalDropped = 0, + this.networkAccepted = 0, + this.networkDropped = 0, + this.networkDuplicateFinishes = 0, + this.lastDropReason, + }); + + final int externalAccepted; + final int externalDropped; + final int networkAccepted; + final int networkDropped; + final int networkDuplicateFinishes; + final String? lastDropReason; + + Map toJson() => { + 'externalAccepted': externalAccepted, + 'externalDropped': externalDropped, + 'networkAccepted': networkAccepted, + 'networkDropped': networkDropped, + 'networkDuplicateFinishes': networkDuplicateFinishes, + if (lastDropReason != null) 'lastDropReason': lastDropReason, + }; +} + /// Rolling capture-resolution evidence. Outcome names are a closed, /// non-sensitive taxonomy; no errors, pixels, labels, or stack traces appear /// here. diff --git a/packages/tugboat/lib/src/network_observer.dart b/packages/tugboat/lib/src/network_observer.dart new file mode 100644 index 0000000..482ae06 --- /dev/null +++ b/packages/tugboat/lib/src/network_observer.dart @@ -0,0 +1,62 @@ +/// Closed vocabulary for a logical network call's terminal outcome. +enum TugboatNetworkOutcome { + response, + networkError, + cancelled; + + String get wireName => switch (this) { + TugboatNetworkOutcome.response => 'response', + TugboatNetworkOutcome.networkError => 'network_error', + TugboatNetworkOutcome.cancelled => 'cancelled', + }; +} + +/// Hard limits for host-supplied network observation fields. +abstract final class TugboatNetworkLimits { + static const maxMethodLength = 16; + static const maxRouteLength = 256; +} + +/// Exactly-once observation token for one logical HTTP request. +/// +/// Adapters call [complete] or [fail] when response headers/status are +/// available. Further terminal calls are no-ops. +abstract interface class TugboatNetworkCall { + void complete({int? statusCode, int? attemptCount}); + + void fail({ + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }); +} + +/// No-op token used when Tugboat is dormant, disabled, or the route is empty. +class TugboatNoOpNetworkCall implements TugboatNetworkCall { + const TugboatNoOpNetworkCall(); + + @override + void complete({int? statusCode, int? attemptCount}) {} + + @override + void fail({ + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) {} +} + +String? normalizeNetworkMethod(String method) { + final trimmed = method.trim().toUpperCase(); + if (trimmed.isEmpty) return null; + if (trimmed.length > TugboatNetworkLimits.maxMethodLength) return null; + return trimmed; +} + +String? normalizeNetworkRoute(String? route) { + if (route == null) return null; + final trimmed = route.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.length > TugboatNetworkLimits.maxRouteLength) return null; + return trimmed; +} diff --git a/packages/tugboat/lib/src/sdk_version.dart b/packages/tugboat/lib/src/sdk_version.dart index 3544f9c..d7ee388 100644 --- a/packages/tugboat/lib/src/sdk_version.dart +++ b/packages/tugboat/lib/src/sdk_version.dart @@ -1,3 +1,3 @@ // Keep this in sync with packages/tugboat/pubspec.yaml. The SDK version test // reads pubspec.yaml directly so release bumps fail fast if this drifts. -const tugboatSdkVersion = '0.5.0'; +const tugboatSdkVersion = '0.6.0'; diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index 88bb429..235ca5a 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'capture_boundary.dart'; import 'capture_profile.dart'; import 'controller.dart'; +import 'external_event.dart'; import 'health.dart'; import 'input_capture.dart'; import 'lifecycle.dart'; +import 'network_observer.dart'; export 'capture_profile.dart' show TugboatCaptureProfile; export 'lifecycle.dart' show TugboatLifecycleState; @@ -134,6 +136,40 @@ class TugboatReplay { await _controller?.clearDurableOutbox(); } + /// Returns a provider-neutral hook that records logical app/analytics events. + /// + /// The hook resolves the active controller at [TugboatEventHook.record] time + /// so it never retains a stale session reference. Calls made while capture is + /// dormant, disabled, or ended are safe no-ops. + static TugboatEventHook eventHook({ + String? source, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly, + }) { + return _TugboatEventHook( + source: source, + parameterPolicy: parameterPolicy, + ); + } + + /// Begins observation of one logical network call. + /// + /// [route] must already be a safe host-supplied template such as + /// `/blend/:blendId`. Raw paths are never accepted as a fallback. Returns a + /// no-op token when Tugboat is dormant/disabled or [route] is empty. + static TugboatNetworkCall beginNetworkCall({ + required String method, + required String route, + }) { + try { + if (disabled) return const TugboatNoOpNetworkCall(); + final controller = _controller; + if (controller == null) return const TugboatNoOpNetworkCall(); + return controller.beginNetworkCall(method: method, route: route); + } catch (_) { + return const TugboatNoOpNetworkCall(); + } + } + /// Resets lifecycle state between tests. @visibleForTesting static void resetForTest() { @@ -144,6 +180,33 @@ class TugboatReplay { } } +class _TugboatEventHook implements TugboatEventHook { + _TugboatEventHook({ + required this.source, + required this.parameterPolicy, + }); + + final String? source; + final TugboatParameterPolicy parameterPolicy; + + @override + void record(String name, {Map? parameters}) { + try { + if (TugboatReplay.disabled) return; + final controller = TugboatReplay.controller; + if (controller == null) return; + controller.recordExternalEvent( + name: name, + source: source, + parameters: parameters, + parameterPolicy: parameterPolicy, + ); + } catch (_) { + // Host analytics must never fail because of Tugboat. + } + } +} + /// Observes one [Navigator] and reports transitions to the active controller. /// /// Install the root convenience instance via [TugboatReplay.navigatorObserver]. diff --git a/packages/tugboat/lib/tugboat.dart b/packages/tugboat/lib/tugboat.dart index 8ed8f3e..b895b59 100644 --- a/packages/tugboat/lib/tugboat.dart +++ b/packages/tugboat/lib/tugboat.dart @@ -28,12 +28,27 @@ export 'src/health.dart' TugboatSinkHealth, TugboatOutboxHealth, TugboatScreenshotBudgetHealth, + TugboatEvidenceHealth, TugboatSanitizedFailure; export 'src/lifecycle.dart' show TugboatLifecycleState, TugboatLifecycleNotifier; export 'src/interaction_transaction.dart' show tugboatDefaultReconciliationWindow; export 'src/models.dart'; +export 'src/external_event.dart' + show + TugboatEventHook, + TugboatParameterPolicy, + TugboatParameterCaptureValues, + TugboatParameterLimits, + TugboatParameterSnapshot, + TugboatParameterDrop; +export 'src/network_observer.dart' + show + TugboatNetworkCall, + TugboatNetworkOutcome, + TugboatNetworkLimits, + TugboatNoOpNetworkCall; export 'src/coordinate_space.dart' show tugboatCaptureCoordinateVersion, diff --git a/packages/tugboat/pubspec.yaml b/packages/tugboat/pubspec.yaml index 58b6b1c..ae6e343 100644 --- a/packages/tugboat/pubspec.yaml +++ b/packages/tugboat/pubspec.yaml @@ -1,7 +1,7 @@ name: tugboat description: >- Screenshot-based session replay with compact interaction anchors for Tugboat. -version: 0.5.0 +version: 0.6.0 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart new file mode 100644 index 0000000..3b17350 --- /dev/null +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/src/external_event.dart'; +import 'package:tugboat/tugboat.dart'; + +const _testConfig = TugboatReplayConfig( + profile: TugboatCaptureProfile.exploration, + settleDelay: Duration.zero, + interactionClaimWindow: Duration.zero, + enableGlobalPointerCapture: false, + capturePixelRatio: 1.0, +); + +Future _pumpCapture(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: const SizedBox.expand(), + ), + ); + await tester.pump(); +} + +void main() { + tearDown(TugboatReplay.resetForTest); + + testWidgets('external event records once on evidence stream', (tester) async { + await _pumpCapture(tester); + final hook = TugboatReplay.eventHook( + source: 'analytics', + parameterPolicy: TugboatParameterPolicy.allowList({'method'}), + ); + + final parameters = {'method': 'email', 'secret': 'nope'}; + hook.record('USER_LOGIN', parameters: parameters); + parameters['method'] = 'mutated'; + + final events = TugboatReplay.controller!.session!.events + .where((e) => e.type == 'external_event') + .toList(); + expect(events, hasLength(1)); + final event = events.single; + expect(event.stream, TugboatEventStream.evidence); + expect(event.isEnrichmentCandidate, isFalse); + expect(event.actionId, isNull); + expect(event.relatedEventId, isNull); + expect(event.stateAnchor, isNull); + expect(event.targetAnchor, isNull); + expect(event.data['source'], 'analytics'); + expect(event.data['name'], 'USER_LOGIN'); + expect(event.data['parameterKeys'], ['method', 'secret']); + expect(event.data['parameters'], {'method': 'email'}); + expect(event.data['capture'], { + 'values': 'allow_list', + 'truncated': false, + 'droppedCount': 1, + }); + }); + + testWidgets('names-only policy omits parameter values', (tester) async { + await _pumpCapture(tester); + TugboatReplay.eventHook().record( + 'SEARCH', + parameters: {'query': 'chicken soup'}, + ); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (e) => e.type == 'external_event', + ); + expect(event.data['parameterKeys'], ['query']); + expect(event.data.containsKey('parameters'), isFalse); + expect(event.data['capture'], { + 'values': 'names_only', + 'truncated': false, + 'droppedCount': 0, + }); + }); + + testWidgets('external event ignores active action window', (tester) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + controller.setExplorationActionWindow( + explorationRunId: 'run-1', + actionId: 'A-1', + ); + + TugboatReplay.eventHook(source: 'analytics').record('PING'); + final call = TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/blend/:blendId', + ); + call.complete(statusCode: 200); + + final external = controller.session!.events.singleWhere( + (e) => e.type == 'external_event', + ); + final network = controller.session!.events.singleWhere( + (e) => e.type == 'network_call', + ); + for (final event in [external, network]) { + expect(event.actionId, isNull); + expect(event.relatedEventId, isNull); + expect(event.stateAnchor, isNull); + expect(event.targetAnchor, isNull); + expect(event.stream, TugboatEventStream.evidence); + } + }); + + testWidgets('dormant hook and network calls are no-ops', (tester) async { + expect(() { + TugboatReplay.eventHook().record('X', parameters: {'a': 1}); + TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/x', + ).complete(statusCode: 200); + }, returnsNormally); + expect(TugboatReplay.controller, isNull); + }); + + testWidgets('transform failures drop values without throwing', ( + tester, + ) async { + await _pumpCapture(tester); + final hook = TugboatReplay.eventHook( + parameterPolicy: TugboatParameterPolicy.transform((key, value) { + if (key == 'boom') throw StateError('nope'); + if (key == 'skip') return TugboatParameterPolicy.drop; + return value; + }), + ); + + expect( + () => hook.record( + 'EVT', + parameters: {'ok': 1, 'boom': 'x', 'skip': 'y'}, + ), + returnsNormally, + ); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (e) => e.type == 'external_event', + ); + expect(event.data['parameters'], {'ok': 1}); + expect((event.data['capture'] as Map)['droppedCount'], 2); + }); + + testWidgets('network token finishes exactly once', (tester) async { + await _pumpCapture(tester); + final call = TugboatReplay.beginNetworkCall( + method: 'post', + route: '/cart/:cartId', + ); + call.complete(statusCode: 201); + call.fail(outcome: TugboatNetworkOutcome.networkError); + call.complete(statusCode: 500); + + final events = TugboatReplay.controller!.session!.events + .where((e) => e.type == 'network_call') + .toList(); + expect(events, hasLength(1)); + expect(events.single.data['method'], 'POST'); + expect(events.single.data['route'], '/cart/:cartId'); + expect(events.single.data['statusCode'], 201); + expect(events.single.data['outcome'], 'response'); + expect(events.single.data['durationMs'], isA()); + expect( + TugboatReplay.health.evidence.networkDuplicateFinishes, + greaterThanOrEqualTo(2), + ); + }); + + testWidgets('empty route returns no-op without event', (tester) async { + await _pumpCapture(tester); + final call = TugboatReplay.beginNetworkCall(method: 'GET', route: ' '); + call.complete(statusCode: 200); + expect( + TugboatReplay.controller!.session!.events.where( + (e) => e.type == 'network_call', + ), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkDropped, greaterThan(0)); + }); + + test('parameter snapshot deep-copies and bounds nested values', () { + final nested = { + 'a': { + 'b': { + 'c': { + 'd': {'e': 'too-deep'}, + }, + }, + }, + 'list': [1, 2, double.nan, Object()], + }; + final snapshot = snapshotExternalParameters( + policy: TugboatParameterPolicy.allowAll, + parameters: nested, + ); + nested['a'] = 'mutated'; + expect(snapshot.parameters!['a'], isA()); + expect(snapshot.truncated, isTrue); + expect(snapshot.droppedCount, greaterThan(0)); + final encoded = snapshot.parameters.toString(); + expect(encoded.contains('too-deep'), isFalse); + expect(encoded.contains('Object'), isFalse); + }); +} diff --git a/packages/tugboat/test/replay/tugboat_health_test.dart b/packages/tugboat/test/replay/tugboat_health_test.dart index 7933cf5..87e56f1 100644 --- a/packages/tugboat/test/replay/tugboat_health_test.dart +++ b/packages/tugboat/test/replay/tugboat_health_test.dart @@ -66,6 +66,13 @@ void main() { 'total': 0, 'outcomes': {}, }); + expect(json['evidence'], { + 'externalAccepted': 0, + 'externalDropped': 0, + 'networkAccepted': 0, + 'networkDropped': 0, + 'networkDuplicateFinishes': 0, + }); }); test('capture diagnostic health is bounded and sanitized', () { diff --git a/packages/tugboat_dio/CHANGELOG.md b/packages/tugboat_dio/CHANGELOG.md new file mode 100644 index 0000000..e258671 --- /dev/null +++ b/packages/tugboat_dio/CHANGELOG.md @@ -0,0 +1,8 @@ +## 0.6.0 + +### Added + +- Initial `TugboatDioInterceptor` that maps Dio request lifecycle callbacks to + the core Tugboat network observation token. +- `TugboatDioInterceptor.install` inserts at the start of the interceptor chain + and rejects duplicate installation on the same `Dio` instance. diff --git a/packages/tugboat_dio/LICENSE b/packages/tugboat_dio/LICENSE new file mode 100644 index 0000000..f74affa --- /dev/null +++ b/packages/tugboat_dio/LICENSE @@ -0,0 +1,667 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2026 Blend Technologies Inc. + + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/tugboat_dio/README.md b/packages/tugboat_dio/README.md new file mode 100644 index 0000000..25c5572 --- /dev/null +++ b/packages/tugboat_dio/README.md @@ -0,0 +1,54 @@ +# tugboat_dio + +Dio adapter for Tugboat network evidence. Records method, safe route template, +status, outcome, and duration into an active Tugboat session. Never captures +headers, queries, bodies, raw errors, or stack traces. + +Requires `tugboat` `0.6.0` (lockstep). + +## Install + +```yaml +dependencies: + tugboat: ^0.6.0 + tugboat_dio: ^0.6.0 +``` + +## Usage + +Supply a host-owned route template resolver. Unmatched routes are dropped. + +```dart +import 'package:dio/dio.dart'; +import 'package:tugboat_dio/tugboat_dio.dart'; + +final dio = Dio(); + +// Install at the start of the interceptor chain, before auth/retry handlers, +// so interceptor-level retries resolve before Tugboat emits. +TugboatDioInterceptor.install( + dio, + routeResolver: (request) => apiRouteTemplate(request.path), +); +``` + +`apiRouteTemplate` must return a safe template such as `/blend/:blendId`, never +a raw path containing entity IDs. Return `null` or `''` to drop the call. + +## Interceptor ordering + +| Position | Why | +| --- | --- | +| Before auth/retry | Auth can recover a 401 and resolve the final response before Tugboat finishes the token | +| Before/independent of cache | Cached or interceptor-resolved responses still emit one logical observation | +| Compatible with Sentry | Follow Sentry's required init order; keep one Tugboat interceptor per `Dio` | + +`install` inserts at index `0` and is a no-op when a `TugboatDioInterceptor` +is already present. + +## Privacy + +- Route templates only — no scheme, host, port, query, or fragment +- No request/response bodies, headers, or cookies +- No raw `DioException` messages or stack traces +- Dormant/disabled Tugboat → networking unchanged, no events diff --git a/packages/tugboat_dio/analysis_options.yaml b/packages/tugboat_dio/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/packages/tugboat_dio/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart new file mode 100644 index 0000000..6005f2f --- /dev/null +++ b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart @@ -0,0 +1,139 @@ +import 'package:dio/dio.dart'; +import 'package:tugboat/tugboat.dart'; + +/// Host-supplied mapper from a Dio request to a safe route template. +/// +/// Must return a bounded template such as `/blend/:blendId`, never a raw path +/// containing entity IDs. Return `null` or empty to drop the observation. +typedef TugboatDioRouteResolver = String? Function(RequestOptions request); + +/// Records one logical Dio request as Tugboat `network_call` evidence. +/// +/// Install **before** auth/retry interceptors so interceptor-level retries +/// resolve before this adapter emits. Prefer [install], which inserts at index +/// `0` and rejects duplicate installation on the same [Dio] instance. +/// +/// Never inspects request/response bodies, headers, cookies, query parameters, +/// or raw error text. +class TugboatDioInterceptor extends Interceptor { + TugboatDioInterceptor({required this.routeResolver}); + + static const extraCallKey = 'tugboat.network_call'; + static const extraAttemptCountKey = 'tugboat.network_attempt_count'; + + final TugboatDioRouteResolver routeResolver; + + /// Installs a single interceptor at the start of [dio]'s chain. + /// + /// Returns `false` when a [TugboatDioInterceptor] is already present. + static bool install( + Dio dio, { + required TugboatDioRouteResolver routeResolver, + }) { + if (dio.interceptors.any((i) => i is TugboatDioInterceptor)) { + return false; + } + dio.interceptors.insert( + 0, + TugboatDioInterceptor(routeResolver: routeResolver), + ); + return true; + } + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + try { + _ensureToken(options); + } catch (_) { + // Observation failures must never affect host networking. + } + handler.next(options); + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) { + try { + final call = _tokenOf(response.requestOptions); + call?.complete( + statusCode: response.statusCode, + attemptCount: _attemptCount(response.requestOptions), + ); + } catch (_) {} + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + try { + final call = _tokenOf(err.requestOptions); + if (call != null) { + final statusCode = err.response?.statusCode; + final attempts = _attemptCount(err.requestOptions); + if (err.type == DioExceptionType.cancel) { + call.fail( + outcome: TugboatNetworkOutcome.cancelled, + statusCode: statusCode, + attemptCount: attempts, + ); + } else if (err.response != null) { + // Logical HTTP response was available; retain status without error + // text. + call.complete(statusCode: statusCode, attemptCount: attempts); + } else { + call.fail( + outcome: TugboatNetworkOutcome.networkError, + statusCode: statusCode, + attemptCount: attempts, + ); + } + } + } catch (_) {} + handler.next(err); + } + + void _ensureToken(RequestOptions options) { + final existing = options.extra[extraCallKey]; + if (existing is TugboatNetworkCall) { + final attempts = options.extra[extraAttemptCountKey]; + final current = attempts is int ? attempts : 1; + options.extra[extraAttemptCountKey] = current + 1; + return; + } + + String? route; + try { + route = routeResolver(options); + } catch (_) { + route = null; + } + final normalized = _normalizeRoute(route); + final call = TugboatReplay.beginNetworkCall( + method: options.method, + // Empty route forces a bounded drop when the resolver rejected the call. + route: normalized ?? '', + ); + options.extra[extraCallKey] = call; + options.extra[extraAttemptCountKey] = 1; + } + + TugboatNetworkCall? _tokenOf(RequestOptions options) { + final value = options.extra[extraCallKey]; + return value is TugboatNetworkCall ? value : null; + } + + int? _attemptCount(RequestOptions options) { + final value = options.extra[extraAttemptCountKey]; + return value is int ? value : null; + } + + static String? _normalizeRoute(String? route) { + if (route == null) return null; + final trimmed = route.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.length > TugboatNetworkLimits.maxRouteLength) return null; + return trimmed; + } +} diff --git a/packages/tugboat_dio/lib/tugboat_dio.dart b/packages/tugboat_dio/lib/tugboat_dio.dart new file mode 100644 index 0000000..4db5fec --- /dev/null +++ b/packages/tugboat_dio/lib/tugboat_dio.dart @@ -0,0 +1,4 @@ +library; + +export 'src/tugboat_dio_interceptor.dart' + show TugboatDioInterceptor, TugboatDioRouteResolver; diff --git a/packages/tugboat_dio/pubspec.yaml b/packages/tugboat_dio/pubspec.yaml new file mode 100644 index 0000000..6544f1e --- /dev/null +++ b/packages/tugboat_dio/pubspec.yaml @@ -0,0 +1,27 @@ +name: tugboat_dio +description: >- + Dio interceptor that records safe, bounded network evidence into an active + Tugboat capture session. +version: 0.6.0 +repository: https://github.com/blendto/tugboat-flutter +issue_tracker: https://github.com/blendto/tugboat-flutter/issues +homepage: https://github.com/blendto/tugboat-flutter +license: AGPL-3.0-only + +environment: + sdk: ^3.9.2 + flutter: ">=3.35.0" + +resolution: workspace + +dependencies: + dio: ^5.4.0 + flutter: + sdk: flutter + tugboat: + path: ../tugboat + +dev_dependencies: + flutter_lints: ^5.0.0 + flutter_test: + sdk: flutter diff --git a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart new file mode 100644 index 0000000..ca70a28 --- /dev/null +++ b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -0,0 +1,274 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/tugboat.dart'; +import 'package:tugboat_dio/tugboat_dio.dart'; + +const _testConfig = TugboatReplayConfig( + profile: TugboatCaptureProfile.exploration, + settleDelay: Duration.zero, + interactionClaimWindow: Duration.zero, + enableGlobalPointerCapture: false, + capturePixelRatio: 1.0, +); + +Future _pumpCapture(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: const SizedBox.expand(), + ), + ); + await tester.pump(); +} + +class _ScriptedAdapter implements HttpClientAdapter { + _ScriptedAdapter(this._handler); + + final Future Function(RequestOptions options) _handler; + + @override + void close({bool force = false}) {} + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) => _handler(options); +} + +void main() { + tearDown(TugboatReplay.resetForTest); + + testWidgets('200 response emits one network_call', (tester) async { + await _pumpCapture(tester); + final dio = Dio(BaseOptions(baseUrl: 'https://example.test')); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString('{"ok":true}', 200), + ); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/blend/:blendId', + ); + + await dio.get('/blend/raw-id-should-not-appear?x=1'); + + final events = TugboatReplay.controller!.session!.events + .where((e) => e.type == 'network_call') + .toList(); + expect(events, hasLength(1)); + final data = events.single.data; + expect(data['method'], 'GET'); + expect(data['route'], '/blend/:blendId'); + expect(data['statusCode'], 200); + expect(data['outcome'], 'response'); + expect(data['durationMs'], isA()); + expect(data.toString().contains('raw-id'), isFalse); + expect(data.toString().contains('example.test'), isFalse); + expect(data.containsKey('headers'), isFalse); + }); + + testWidgets('bad response retains status without raw error', (tester) async { + await _pumpCapture(tester); + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString('secret-body', 503), + ); + dio.options.validateStatus = (status) => false; + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/health', + ); + + await expectLater(dio.get('/health'), throwsA(isA())); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (e) => e.type == 'network_call', + ); + expect(event.data['statusCode'], 503); + expect(event.data['outcome'], 'response'); + expect(event.data.toString().contains('secret-body'), isFalse); + }); + + testWidgets('transport error emits network_error', (tester) async { + await _pumpCapture(tester); + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + message: 'socket failed with user token abc', + ); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/sync', + ); + + await expectLater(dio.get('/sync'), throwsA(isA())); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (e) => e.type == 'network_call', + ); + expect(event.data['outcome'], 'network_error'); + expect(event.data.containsKey('statusCode'), isFalse); + expect(event.data.toString().contains('token'), isFalse); + expect(event.data.toString().contains('socket'), isFalse); + }); + + testWidgets('cancellation emits cancelled', (tester) async { + await _pumpCapture(tester); + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + throw DioException( + requestOptions: options, + type: DioExceptionType.cancel, + ); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/long', + ); + + await expectLater(dio.get('/long'), throwsA(isA())); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (e) => e.type == 'network_call', + ); + expect(event.data['outcome'], 'cancelled'); + }); + + testWidgets('auth retry emits one final logical event', (tester) async { + await _pumpCapture(tester); + final dio = Dio(); + var attempts = 0; + dio.httpClientAdapter = _ScriptedAdapter((options) async { + attempts += 1; + if (attempts == 1) { + return ResponseBody.fromString('unauthorized', 401); + } + return ResponseBody.fromString('ok', 200); + }); + + // Tugboat first (index 0), auth after — auth handles 401 before Tugboat + // finishes on the error path. + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/secure', + ); + dio.interceptors.add( + InterceptorsWrapper( + onError: (err, handler) async { + if (err.response?.statusCode == 401) { + final opts = err.requestOptions; + final response = await dio.fetch(opts); + handler.resolve(response); + return; + } + handler.next(err); + }, + ), + ); + + final response = await dio.get('/secure'); + expect(response.statusCode, 200); + expect(attempts, 2); + + final events = TugboatReplay.controller!.session!.events + .where((e) => e.type == 'network_call') + .toList(); + expect(events, hasLength(1)); + expect(events.single.data['statusCode'], 200); + expect(events.single.data['outcome'], 'response'); + expect(events.single.data['attemptCount'], 2); + }); + + testWidgets('unmatched route drops without event', (tester) async { + await _pumpCapture(tester); + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString('ok', 200), + ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => null); + + await dio.get('/mystery/id-123'); + expect( + TugboatReplay.controller!.session!.events.where( + (e) => e.type == 'network_call', + ), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkDropped, greaterThan(0)); + expect( + TugboatReplay.health.toJson().toString().contains('id-123'), + isFalse, + ); + }); + + testWidgets('duplicate install is rejected', (tester) async { + final dio = Dio(); + final first = TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/a', + ); + final second = TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/b', + ); + expect(first, isTrue); + expect(second, isFalse); + expect(dio.interceptors.whereType(), hasLength(1)); + }); + + testWidgets('dormant tugboat leaves networking unchanged', (tester) async { + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString('ok', 200), + ); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/x', + ); + + final response = await dio.get('/x'); + expect(response.statusCode, 200); + expect(TugboatReplay.controller, isNull); + }); + + testWidgets('cached interceptor resolve emits one logical response', ( + tester, + ) async { + await _pumpCapture(tester); + final dio = Dio(); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => '/cached', + ); + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: {'cached': true}, + ), + ); + }, + ), + ); + + final response = await dio.get('/cached'); + expect(response.statusCode, 200); + final events = TugboatReplay.controller!.session!.events + .where((e) => e.type == 'network_call') + .toList(); + expect(events, hasLength(1)); + expect(events.single.data['statusCode'], 200); + expect(events.single.data['outcome'], 'response'); + }); +} diff --git a/pubspec.lock b/pubspec.lock index b893c24..3a13250 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -137,6 +137,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" + dio: + dependency: transitive + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" + source: hosted + version: "2.2.1" fake_async: dependency: transitive description: @@ -276,18 +292,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" melos: dependency: "direct dev" description: @@ -300,10 +316,18 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" mustache_template: dependency: transitive description: @@ -465,10 +489,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" typed_data: dependency: transitive description: @@ -542,5 +566,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.9.2 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 48e1dcd..63c043f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: workspace: - packages/tugboat - packages/tugboat/example + - packages/tugboat_dio dev_dependencies: melos: ^7.8.2 @@ -20,8 +21,11 @@ melos: run: dart analyze . description: Analyze every package in the workspace. test: - run: flutter test packages/tugboat + run: flutter test packages/tugboat packages/tugboat_dio description: Run all package tests. test:sdk: run: flutter test packages/tugboat description: Run the Flutter SDK tests. + test:dio: + run: flutter test packages/tugboat_dio + description: Run the Dio adapter tests. From 149308bc9aac8c1eae6b703c30142e38413f4c8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:52:03 +0000 Subject: [PATCH 2/7] fix(replay): harden observation APIs and Dio install order Rename parameter transform field to avoid static/instance clash, append tugboat_dio after auth/retry for Dio FIFO error handling, and cover retries/cache resolves in adapter tests. Also make semantics flags compat tests work on Flutter 3.36+ Tristate APIs. Co-authored-by: Chinmay Kabi --- packages/tugboat/lib/src/controller.dart | 3 +- packages/tugboat/lib/src/external_event.dart | 16 +-- packages/tugboat/lib/src/tugboat.dart | 10 +- .../test/external_event_and_network_test.dart | 5 +- .../test/semantics_flags_compat_test.dart | 28 ++-- packages/tugboat_dio/CHANGELOG.md | 4 +- packages/tugboat_dio/README.md | 19 ++- .../lib/src/tugboat_dio_interceptor.dart | 14 +- packages/tugboat_dio/pubspec.yaml | 3 +- .../test/tugboat_dio_interceptor_test.dart | 128 ++++++++++-------- 10 files changed, 124 insertions(+), 106 deletions(-) diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index c91a758..6c2a202 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -4391,7 +4391,8 @@ class TugboatReplayController extends ChangeNotifier { if (statusCode != null) 'statusCode': statusCode, 'outcome': outcome.wireName, 'durationMs': durationMs, - if (attemptCount != null && attemptCount > 0) 'attemptCount': attemptCount, + if (attemptCount != null && attemptCount > 0) + 'attemptCount': attemptCount, }; _appendEvidenceEvent( TugboatEvent( diff --git a/packages/tugboat/lib/src/external_event.dart b/packages/tugboat/lib/src/external_event.dart index cc62fab..e44ead9 100644 --- a/packages/tugboat/lib/src/external_event.dart +++ b/packages/tugboat/lib/src/external_event.dart @@ -23,7 +23,7 @@ class TugboatParameterPolicy { const TugboatParameterPolicy._({ required this.captureValues, this.allowedKeys, - this.transform, + this.valueTransform, }); /// Record event name plus bounded parameter keys only. Default production @@ -45,7 +45,7 @@ class TugboatParameterPolicy { Object? Function(String key, Object? value) transform, ) => TugboatParameterPolicy._( captureValues: TugboatParameterCaptureValues.transform, - transform: transform, + valueTransform: transform, ); /// Exploration-only escape hatch that retains all JSON-safe values within @@ -60,7 +60,7 @@ class TugboatParameterPolicy { final String captureValues; final Set? allowedKeys; - final Object? Function(String key, Object? value)? transform; + final Object? Function(String key, Object? value)? valueTransform; } /// Hard limits applied when snapshotting external-event parameters. @@ -149,7 +149,7 @@ TugboatParameterSnapshot snapshotExternalParameters({ Object? candidate = entry.value; if (policy.captureValues == TugboatParameterCaptureValues.transform) { - final transform = policy.transform; + final transform = policy.valueTransform; if (transform == null) { dropped += 1; continue; @@ -223,6 +223,10 @@ Object? _copyJsonSafe( required void Function(int count) dropped, required bool Function() onCollectionItem, }) { + if (depth > TugboatParameterLimits.maxDepth) { + dropped(1); + return _unsupported; + } if (value == null || value is bool) return value; if (value is num) { if (value.isFinite) return value; @@ -234,10 +238,6 @@ Object? _copyJsonSafe( dropped(1); return _unsupported; } - if (depth > TugboatParameterLimits.maxDepth) { - dropped(1); - return _unsupported; - } if (value is Map) { if (!seen.add(value)) { dropped(1); diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index 235ca5a..8d740db 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -145,10 +145,7 @@ class TugboatReplay { String? source, TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly, }) { - return _TugboatEventHook( - source: source, - parameterPolicy: parameterPolicy, - ); + return _TugboatEventHook(source: source, parameterPolicy: parameterPolicy); } /// Begins observation of one logical network call. @@ -181,10 +178,7 @@ class TugboatReplay { } class _TugboatEventHook implements TugboatEventHook { - _TugboatEventHook({ - required this.source, - required this.parameterPolicy, - }); + _TugboatEventHook({required this.source, required this.parameterPolicy}); final String? source; final TugboatParameterPolicy parameterPolicy; diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index 3b17350..8373a7e 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -131,10 +131,7 @@ void main() { ); expect( - () => hook.record( - 'EVT', - parameters: {'ok': 1, 'boom': 'x', 'skip': 'y'}, - ), + () => hook.record('EVT', parameters: {'ok': 1, 'boom': 'x', 'skip': 'y'}), returnsNormally, ); diff --git a/packages/tugboat/test/semantics_flags_compat_test.dart b/packages/tugboat/test/semantics_flags_compat_test.dart index 6a83ac0..e97f8c1 100644 --- a/packages/tugboat/test/semantics_flags_compat_test.dart +++ b/packages/tugboat/test/semantics_flags_compat_test.dart @@ -12,17 +12,21 @@ void main() { ); test('semanticsEnabledFromFlags reads explicit enabled state', () { - expect( - semanticsEnabledFromFlags( - SemanticsFlags.none.copyWith(hasEnabledState: true, isEnabled: true), - ), - isTrue, - ); - expect( - semanticsEnabledFromFlags( - SemanticsFlags.none.copyWith(hasEnabledState: true, isEnabled: false), - ), - isFalse, - ); + expect(semanticsEnabledFromFlags(_flagsWithEnabled(true)), isTrue); + expect(semanticsEnabledFromFlags(_flagsWithEnabled(false)), isFalse); }); } + +/// Builds enabled-state flags across Flutter 3.35 bool pairs and 3.36+ Tristate. +SemanticsFlags _flagsWithEnabled(bool enabled) { + final dynamic none = SemanticsFlags.none; + try { + final dynamic tristate = enabled + ? (Tristate.isTrue as dynamic) + : (Tristate.isFalse as dynamic); + return none.copyWith(isEnabled: tristate) as SemanticsFlags; + } catch (_) { + return none.copyWith(hasEnabledState: true, isEnabled: enabled) + as SemanticsFlags; + } +} diff --git a/packages/tugboat_dio/CHANGELOG.md b/packages/tugboat_dio/CHANGELOG.md index e258671..5924ffd 100644 --- a/packages/tugboat_dio/CHANGELOG.md +++ b/packages/tugboat_dio/CHANGELOG.md @@ -4,5 +4,5 @@ - Initial `TugboatDioInterceptor` that maps Dio request lifecycle callbacks to the core Tugboat network observation token. -- `TugboatDioInterceptor.install` inserts at the start of the interceptor chain - and rejects duplicate installation on the same `Dio` instance. +- `TugboatDioInterceptor.install` appends to the interceptor chain (after + auth/retry) and rejects duplicate installation on the same `Dio` instance. diff --git a/packages/tugboat_dio/README.md b/packages/tugboat_dio/README.md index 25c5572..c48d3d3 100644 --- a/packages/tugboat_dio/README.md +++ b/packages/tugboat_dio/README.md @@ -24,8 +24,9 @@ import 'package:tugboat_dio/tugboat_dio.dart'; final dio = Dio(); -// Install at the start of the interceptor chain, before auth/retry handlers, -// so interceptor-level retries resolve before Tugboat emits. +// Configure auth/retry/cache interceptors first, then install Tugboat last. +// Dio runs error interceptors in FIFO order, so retry handlers must recover +// before Tugboat finishes the observation token. TugboatDioInterceptor.install( dio, routeResolver: (request) => apiRouteTemplate(request.path), @@ -39,12 +40,18 @@ a raw path containing entity IDs. Return `null` or `''` to drop the call. | Position | Why | | --- | --- | -| Before auth/retry | Auth can recover a 401 and resolve the final response before Tugboat finishes the token | -| Before/independent of cache | Cached or interceptor-resolved responses still emit one logical observation | +| After auth/retry | Dio error handlers run FIFO; auth must recover a 401 before Tugboat emits | +| After short-circuiting cache | Requests resolved before Tugboat's `onRequest` are not observed | | Compatible with Sentry | Follow Sentry's required init order; keep one Tugboat interceptor per `Dio` | -`install` inserts at index `0` and is a no-op when a `TugboatDioInterceptor` -is already present. +`install` appends to the interceptor list and is a no-op when a +`TugboatDioInterceptor` is already present. Namespaced `RequestOptions.extra` +state prevents duplicate tokens when a retry calls `dio.fetch` with the same +options. + +Cached/interceptor-resolved responses are recorded when they pass through this +interceptor's response path (for example `handler.resolve(response, true)` so +following response interceptors run). ## Privacy diff --git a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart index 6005f2f..47c3b68 100644 --- a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart +++ b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart @@ -9,9 +9,10 @@ typedef TugboatDioRouteResolver = String? Function(RequestOptions request); /// Records one logical Dio request as Tugboat `network_call` evidence. /// -/// Install **before** auth/retry interceptors so interceptor-level retries -/// resolve before this adapter emits. Prefer [install], which inserts at index -/// `0` and rejects duplicate installation on the same [Dio] instance. +/// Install **after** auth/retry interceptors. Dio runs error interceptors in +/// FIFO order, so retry handlers must run first and recover before this adapter +/// finishes the token. Prefer [install], which appends and rejects duplicate +/// installation on the same [Dio] instance. /// /// Never inspects request/response bodies, headers, cookies, query parameters, /// or raw error text. @@ -23,7 +24,7 @@ class TugboatDioInterceptor extends Interceptor { final TugboatDioRouteResolver routeResolver; - /// Installs a single interceptor at the start of [dio]'s chain. + /// Installs a single interceptor at the end of [dio]'s chain. /// /// Returns `false` when a [TugboatDioInterceptor] is already present. static bool install( @@ -33,10 +34,7 @@ class TugboatDioInterceptor extends Interceptor { if (dio.interceptors.any((i) => i is TugboatDioInterceptor)) { return false; } - dio.interceptors.insert( - 0, - TugboatDioInterceptor(routeResolver: routeResolver), - ); + dio.interceptors.add(TugboatDioInterceptor(routeResolver: routeResolver)); return true; } diff --git a/packages/tugboat_dio/pubspec.yaml b/packages/tugboat_dio/pubspec.yaml index 6544f1e..7649397 100644 --- a/packages/tugboat_dio/pubspec.yaml +++ b/packages/tugboat_dio/pubspec.yaml @@ -18,8 +18,7 @@ dependencies: dio: ^5.4.0 flutter: sdk: flutter - tugboat: - path: ../tugboat + tugboat: ^0.6.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart index ca70a28..69f7994 100644 --- a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart +++ b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -25,6 +25,15 @@ Future _pumpCapture(WidgetTester tester) async { await tester.pump(); } +Future _runAsync(WidgetTester tester, Future Function() body) { + return tester.runAsync(body).then((value) { + if (value is! T) { + throw StateError('runAsync returned null'); + } + return value; + }); +} + class _ScriptedAdapter implements HttpClientAdapter { _ScriptedAdapter(this._handler); @@ -48,14 +57,20 @@ void main() { await _pumpCapture(tester); final dio = Dio(BaseOptions(baseUrl: 'https://example.test')); dio.httpClientAdapter = _ScriptedAdapter( - (_) async => ResponseBody.fromString('{"ok":true}', 200), - ); - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/blend/:blendId', + (_) async => ResponseBody.fromString( + '{"ok":true}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ), ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/blend/:blendId'); - await dio.get('/blend/raw-id-should-not-appear?x=1'); + await _runAsync( + tester, + () => dio.get('/blend/raw-id-should-not-appear?x=1'), + ); final events = TugboatReplay.controller!.session!.events .where((e) => e.type == 'network_call') @@ -79,12 +94,15 @@ void main() { (_) async => ResponseBody.fromString('secret-body', 503), ); dio.options.validateStatus = (status) => false; - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/health', - ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/health'); - await expectLater(dio.get('/health'), throwsA(isA())); + await _runAsync( + tester, + () => expectLater( + dio.get('/health'), + throwsA(isA()), + ), + ); final event = TugboatReplay.controller!.session!.events.singleWhere( (e) => e.type == 'network_call', @@ -104,12 +122,13 @@ void main() { message: 'socket failed with user token abc', ); }); - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/sync', - ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/sync'); - await expectLater(dio.get('/sync'), throwsA(isA())); + await _runAsync( + tester, + () => + expectLater(dio.get('/sync'), throwsA(isA())), + ); final event = TugboatReplay.controller!.session!.events.singleWhere( (e) => e.type == 'network_call', @@ -129,12 +148,13 @@ void main() { type: DioExceptionType.cancel, ); }); - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/long', - ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/long'); - await expectLater(dio.get('/long'), throwsA(isA())); + await _runAsync( + tester, + () => + expectLater(dio.get('/long'), throwsA(isA())), + ); final event = TugboatReplay.controller!.session!.events.singleWhere( (e) => e.type == 'network_call', @@ -154,18 +174,13 @@ void main() { return ResponseBody.fromString('ok', 200); }); - // Tugboat first (index 0), auth after — auth handles 401 before Tugboat - // finishes on the error path. - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/secure', - ); + // Auth/retry first; Tugboat last so Dio's FIFO error handlers let auth + // recover before observation finishes. dio.interceptors.add( - InterceptorsWrapper( + QueuedInterceptorsWrapper( onError: (err, handler) async { if (err.response?.statusCode == 401) { - final opts = err.requestOptions; - final response = await dio.fetch(opts); + final response = await dio.fetch(err.requestOptions); handler.resolve(response); return; } @@ -173,8 +188,9 @@ void main() { }, ), ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/secure'); - final response = await dio.get('/secure'); + final response = await _runAsync(tester, () => dio.get('/secure')); expect(response.statusCode, 200); expect(attempts, 2); @@ -195,7 +211,7 @@ void main() { ); TugboatDioInterceptor.install(dio, routeResolver: (_) => null); - await dio.get('/mystery/id-123'); + await _runAsync(tester, () => dio.get('/mystery/id-123')); expect( TugboatReplay.controller!.session!.events.where( (e) => e.type == 'network_call', @@ -229,12 +245,9 @@ void main() { dio.httpClientAdapter = _ScriptedAdapter( (_) async => ResponseBody.fromString('ok', 200), ); - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/x', - ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/x'); - final response = await dio.get('/x'); + final response = await _runAsync(tester, () => dio.get('/x')); expect(response.statusCode, 200); expect(TugboatReplay.controller, isNull); }); @@ -244,25 +257,30 @@ void main() { ) async { await _pumpCapture(tester); final dio = Dio(); - TugboatDioInterceptor.install( - dio, - routeResolver: (_) => '/cached', - ); - dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - handler.resolve( - Response( - requestOptions: options, - statusCode: 200, - data: {'cached': true}, - ), - ); - }, - ), - ); + // Short-circuit after Tugboat's onRequest, and call following response + // interceptors so the observation can finish. + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/cached'); + // Move Tugboat before the cache short-circuit by rebuilding order: + final tugboat = dio.interceptors.whereType().single; + dio.interceptors + ..clear() + ..add(tugboat) + ..add( + InterceptorsWrapper( + onRequest: (options, handler) { + handler.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: {'cached': true}, + ), + true, // call following response interceptors + ); + }, + ), + ); - final response = await dio.get('/cached'); + final response = await _runAsync(tester, () => dio.get('/cached')); expect(response.statusCode, 200); final events = TugboatReplay.controller!.session!.events .where((e) => e.type == 'network_call') From d633b752cca068ea8cef27bcfffcec40ee62313c Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Tue, 4 Aug 2026 18:29:54 +0530 Subject: [PATCH 3/7] fix(replay): fence session-scoped evidence --- packages/tugboat/lib/src/controller.dart | 263 +++------------- .../tugboat/lib/src/evidence_recorder.dart | 266 ++++++++++++++++ packages/tugboat/lib/src/external_event.dart | 120 +++++--- .../tugboat/lib/src/network_observer.dart | 31 +- packages/tugboat/lib/src/tugboat.dart | 21 +- packages/tugboat/lib/tugboat.dart | 7 +- .../test/external_event_and_network_test.dart | 208 ++++++++++++- .../test/semantics_flags_compat_test.dart | 43 +-- .../lib/src/tugboat_dio_interceptor.dart | 88 ++++-- .../test/tugboat_dio_interceptor_test.dart | 287 +++++++++++++++++- pubspec.lock | 22 +- pubspec.yaml | 8 +- 12 files changed, 1015 insertions(+), 349 deletions(-) create mode 100644 packages/tugboat/lib/src/evidence_recorder.dart diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 6c2a202..85cc18d 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -11,6 +11,7 @@ import 'collector_http_sink.dart'; import 'coordinate_space.dart'; import 'debug_logging.dart'; import 'exploration_sink.dart'; +import 'evidence_recorder.dart'; import 'external_event.dart'; import 'health.dart'; import 'interaction_transaction.dart'; @@ -738,10 +739,18 @@ class TugboatReplayController extends ChangeNotifier { required GlobalKey boundaryKey, this.activationRequestId, this.sessionEpoch = 0, - }) : _boundaryKey = boundaryKey; + }) : _boundaryKey = boundaryKey { + _evidence = TugboatEvidenceRecorder( + appendEvidence: _appendEvidenceEvent, + nextEventId: _nextId, + nowMs: () => atMs, + profile: () => config.profile, + ); + } final TugboatReplayConfig config; final GlobalKey _boundaryKey; + late final TugboatEvidenceRecorder _evidence; /// Host-supplied activation / request correlation ID (distinct from capture). final String? activationRequestId; @@ -753,6 +762,7 @@ class TugboatReplayController extends ChangeNotifier { Future _queue = Future.value(); int _queuedTaskCount = 0; Future? _endSessionFuture; + bool _endingSession = false; TugboatSession? _session; ScreenshotCapturer? _capturer; @@ -799,13 +809,6 @@ class TugboatReplayController extends ChangeNotifier { final Map _captureDiagnosticOutcomes = {}; int _captureDiagnosticTotal = 0; String? _lastCaptureDiagnosticOutcome; - static const int _maxEvidenceCount = 10000; - int _externalAccepted = 0; - int _externalDropped = 0; - int _networkAccepted = 0; - int _networkDropped = 0; - int _networkDuplicateFinishes = 0; - String? _lastEvidenceDropReason; bool _capturePumpScheduled = false; bool _skipCapture = false; bool _captureLifecycleActive = true; @@ -845,6 +848,7 @@ class TugboatReplayController extends ChangeNotifier { TugboatSession? get session => _session; bool get recording => _session != null; + bool get acceptingEvidence => !_disposed && _evidence.accepting; bool get scrolling => _scrollTrackers.isNotEmpty; bool get capturePaused => _capturePaused; int get atMs => _clock.elapsedMilliseconds; @@ -1254,14 +1258,7 @@ class TugboatReplayController extends ChangeNotifier { lastOutcome: _lastCaptureDiagnosticOutcome, outcomes: Map.unmodifiable(_captureDiagnosticOutcomes), ), - evidence: TugboatEvidenceHealth( - externalAccepted: _externalAccepted, - externalDropped: _externalDropped, - networkAccepted: _networkAccepted, - networkDropped: _networkDropped, - networkDuplicateFinishes: _networkDuplicateFinishes, - lastDropReason: _lastEvidenceDropReason, - ), + evidence: _evidence.healthSnapshot(), truncated: _session?.truncated ?? false, recentFailures: List.unmodifiable(_recentFailures), ); @@ -1295,8 +1292,14 @@ class TugboatReplayController extends ChangeNotifier { Future _endSession(String cancellationReason) { final active = _endSessionFuture; if (active != null) return active; + if (_endingSession) return Future.value(); if (_session == null) return Future.value(); + // Sink delivery is synchronous and may re-enter the controller. Fence + // evidence before publishing the terminal event. + _endingSession = true; + _evidence.close(); + _cancelActiveTapSettles(cancellationReason); _cancelActiveRouteCapture(cancellationReason); _invalidateCaptureWork(cancellationReason); @@ -1334,6 +1337,7 @@ class TugboatReplayController extends ChangeNotifier { _invalidateCaptureWork('session_replacement'); _captureLifecycleActive = true; _captureLifecycleEpoch++; + _endingSession = false; _endSessionFuture = null; _clock ..reset() @@ -1371,12 +1375,7 @@ class TugboatReplayController extends ChangeNotifier { _captureDiagnosticOutcomes.clear(); _captureDiagnosticTotal = 0; _lastCaptureDiagnosticOutcome = null; - _externalAccepted = 0; - _externalDropped = 0; - _networkAccepted = 0; - _networkDropped = 0; - _networkDuplicateFinishes = 0; - _lastEvidenceDropReason = null; + _evidence.bindSession(_session!); if (!_disposed) notifyListeners(); final context = TugboatSinkSessionContext( @@ -2269,6 +2268,7 @@ class TugboatReplayController extends ChangeNotifier { !_disposed && _session != null && _captureLifecycleActive && + !_endingSession && _endSessionFuture == null; void recordPointerDown(Offset position, {int pointer = 0}) { @@ -4254,16 +4254,23 @@ class TugboatReplayController extends ChangeNotifier { } void _addEvent(TugboatEvent event) { - _appendEvent(event, inheritActionContext: true); + final session = _session; + if (session == null) return; + final enriched = event.withExplorationContext( + sessionId: session.id, + captureSessionId: session.id, + activationRequestId: session.activationRequestId ?? activationRequestId, + explorationRunId: _activeExplorationRunId ?? config.explorationRunId, + actionId: _activeActionId, + ); + session.events.add(enriched); + _sinkHub?.recordEvent(enriched); + _trim(); } /// Session-stamped evidence that must never inherit action/interaction /// context (active [actionId], related interaction, or anchors). void _appendEvidenceEvent(TugboatEvent event) { - _appendEvent(event, inheritActionContext: false); - } - - void _appendEvent(TugboatEvent event, {required bool inheritActionContext}) { final session = _session; if (session == null) return; final enriched = event.copyWith( @@ -4273,14 +4280,7 @@ class TugboatReplayController extends ChangeNotifier { event.activationRequestId ?? session.activationRequestId ?? activationRequestId, - explorationRunId: inheritActionContext - ? (event.explorationRunId ?? - _activeExplorationRunId ?? - config.explorationRunId) - : (event.explorationRunId ?? session.explorationRunId), - actionId: inheritActionContext - ? (event.actionId ?? _activeActionId) - : event.actionId, + explorationRunId: event.explorationRunId ?? session.explorationRunId, ); session.events.add(enriched); _sinkHub?.recordEvent(enriched); @@ -4288,145 +4288,25 @@ class TugboatReplayController extends ChangeNotifier { } /// Records one logical host app/analytics event onto the evidence stream. - /// - /// Safe no-op when capture is dormant. Never inherits action/interaction - /// context. Host failures inside policy transforms are swallowed. void recordExternalEvent({ required String name, String? source, Map? parameters, TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly, }) { - try { - if (_disposed || _session == null || _endSessionFuture != null) { - _noteEvidenceDrop(external: true, reason: 'no_active_session'); - return; - } - final boundedName = boundExternalLabel( - name, - TugboatParameterLimits.maxNameLength, - ); - if (boundedName == null) { - _noteEvidenceDrop(external: true, reason: 'invalid_name'); - return; - } - final boundedSource = boundExternalLabel( - source, - TugboatParameterLimits.maxSourceLength, - ); - final snapshot = snapshotExternalParameters( - policy: parameterPolicy, - parameters: parameters, - ); - final data = { - if (boundedSource != null) 'source': boundedSource, - 'name': boundedName, - 'parameterKeys': snapshot.parameterKeys, - if (snapshot.parameters != null) 'parameters': snapshot.parameters, - 'capture': snapshot.toCaptureMetadata(), - }; - _appendEvidenceEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'external_event', - stream: TugboatEventStream.evidence, - data: data, - ), - ); - _externalAccepted = _clampEvidenceCount(_externalAccepted + 1); - } catch (_) { - _noteEvidenceDrop(external: true, reason: 'record_failed'); - } - } - - /// Begins observation of one logical network call. - /// - /// Returns a no-op token when dormant/disabled or when [route] is empty. - /// The recorded route must already be a safe host-supplied template. - TugboatNetworkCall beginNetworkCall({ - required String method, - required String route, - }) { - try { - if (_disposed || _session == null || _endSessionFuture != null) { - _noteEvidenceDrop(external: false, reason: 'no_active_session'); - return const TugboatNoOpNetworkCall(); - } - final normalizedMethod = normalizeNetworkMethod(method); - final normalizedRoute = normalizeNetworkRoute(route); - if (normalizedMethod == null || normalizedRoute == null) { - _noteEvidenceDrop(external: false, reason: 'invalid_route'); - return const TugboatNoOpNetworkCall(); - } - return _ActiveNetworkCall( - controller: this, - method: normalizedMethod, - route: normalizedRoute, - startedAtMs: atMs, - ); - } catch (_) { - _noteEvidenceDrop(external: false, reason: 'begin_failed'); - return const TugboatNoOpNetworkCall(); - } - } - - void _finishNetworkCall({ - required String method, - required String route, - required int startedAtMs, - required TugboatNetworkOutcome outcome, - int? statusCode, - int? attemptCount, - }) { - try { - if (_disposed || _session == null || _endSessionFuture != null) { - _noteEvidenceDrop(external: false, reason: 'no_active_session'); - return; - } - final durationMs = (atMs - startedAtMs).clamp(0, 24 * 60 * 60 * 1000); - final data = { - 'method': method, - 'route': route, - if (statusCode != null) 'statusCode': statusCode, - 'outcome': outcome.wireName, - 'durationMs': durationMs, - if (attemptCount != null && attemptCount > 0) - 'attemptCount': attemptCount, - }; - _appendEvidenceEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'network_call', - stream: TugboatEventStream.evidence, - data: data, - ), - ); - _networkAccepted = _clampEvidenceCount(_networkAccepted + 1); - } catch (_) { - _noteEvidenceDrop(external: false, reason: 'finish_failed'); - } - } - - void _noteNetworkDuplicateFinish() { - _networkDuplicateFinishes = _clampEvidenceCount( - _networkDuplicateFinishes + 1, + _evidence.recordExternalEvent( + name: name, + source: source, + parameters: parameters, + parameterPolicy: parameterPolicy, ); } - void _noteEvidenceDrop({required bool external, required String reason}) { - if (external) { - _externalDropped = _clampEvidenceCount(_externalDropped + 1); - } else { - _networkDropped = _clampEvidenceCount(_networkDropped + 1); - } - _lastEvidenceDropReason = reason; + /// Begins observation of one logical network call. + TugboatNetworkCall beginNetworkCall({required String method, String? route}) { + return _evidence.beginNetworkCall(method: method, route: route); } - int _clampEvidenceCount(int value) => - value > _maxEvidenceCount ? _maxEvidenceCount : value; - void setExplorationActionWindow({ required String explorationRunId, required String actionId, @@ -4572,63 +4452,6 @@ class TugboatReplayController extends ChangeNotifier { String _nextId(String prefix) => '$prefix-${_id++}'; } -class _ActiveNetworkCall implements TugboatNetworkCall { - _ActiveNetworkCall({ - required TugboatReplayController controller, - required this.method, - required this.route, - required this.startedAtMs, - }) : _controller = controller; - - final TugboatReplayController _controller; - final String method; - final String route; - final int startedAtMs; - bool _finished = false; - - @override - void complete({int? statusCode, int? attemptCount}) { - _finish( - outcome: TugboatNetworkOutcome.response, - statusCode: statusCode, - attemptCount: attemptCount, - ); - } - - @override - void fail({ - required TugboatNetworkOutcome outcome, - int? statusCode, - int? attemptCount, - }) { - _finish( - outcome: outcome, - statusCode: statusCode, - attemptCount: attemptCount, - ); - } - - void _finish({ - required TugboatNetworkOutcome outcome, - int? statusCode, - int? attemptCount, - }) { - if (_finished) { - _controller._noteNetworkDuplicateFinish(); - return; - } - _finished = true; - _controller._finishNetworkCall( - method: method, - route: route, - startedAtMs: startedAtMs, - outcome: outcome, - statusCode: statusCode, - attemptCount: attemptCount, - ); - } -} - /// Adapts a session-owned factory sink to the legacy hub interface. class _FactorySinkAdapter implements TugboatCaptureSink { _FactorySinkAdapter(this._sink, this._context); diff --git a/packages/tugboat/lib/src/evidence_recorder.dart b/packages/tugboat/lib/src/evidence_recorder.dart new file mode 100644 index 0000000..a72745c --- /dev/null +++ b/packages/tugboat/lib/src/evidence_recorder.dart @@ -0,0 +1,266 @@ +import 'capture_profile.dart'; +import 'external_event.dart'; +import 'health.dart'; +import 'models.dart'; +import 'network_observer.dart'; + +enum _EvidenceKind { external, network } + +/// Records `external_event` / `network_call` evidence for one capture session. +/// +/// Owns admission, health counters, and network tokens. The host controller +/// supplies identity stamping + sink append via [appendEvidence]. +class TugboatEvidenceRecorder { + TugboatEvidenceRecorder({ + required this.appendEvidence, + required this.nextEventId, + required this.nowMs, + required this.profile, + }); + + final void Function(TugboatEvent event) appendEvidence; + final String Function(String prefix) nextEventId; + final int Function() nowMs; + final TugboatCaptureProfile Function() profile; + + static const int _maxCount = 10000; + + TugboatSession? _session; + bool _closed = false; + int _externalAccepted = 0; + int _externalDropped = 0; + int _networkAccepted = 0; + int _networkDropped = 0; + int _networkDuplicateFinishes = 0; + String? _lastDropReason; + + bool get accepting => !_closed && _session != null; + + TugboatEvidenceHealth healthSnapshot() => TugboatEvidenceHealth( + externalAccepted: _externalAccepted, + externalDropped: _externalDropped, + networkAccepted: _networkAccepted, + networkDropped: _networkDropped, + networkDuplicateFinishes: _networkDuplicateFinishes, + lastDropReason: _lastDropReason, + ); + + /// Binds a new session and clears counters. Re-opens admission. + void bindSession(TugboatSession session) { + _session = session; + _closed = false; + _externalAccepted = 0; + _externalDropped = 0; + _networkAccepted = 0; + _networkDropped = 0; + _networkDuplicateFinishes = 0; + _lastDropReason = null; + } + + /// Fences further evidence (session ending / dispose). + void close() { + _closed = true; + } + + void recordExternalEvent({ + required String name, + String? source, + Map? parameters, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly, + }) { + try { + if (!accepting) { + _noteDrop(_EvidenceKind.external, 'no_active_session'); + return; + } + final boundedName = boundExternalLabel( + name, + TugboatParameterLimits.maxNameLength, + ); + if (boundedName == null) { + _noteDrop(_EvidenceKind.external, 'invalid_name'); + return; + } + final boundedSource = boundExternalLabel( + source, + TugboatParameterLimits.maxSourceLength, + ); + final effectivePolicy = parameterPolicy.effectiveFor(profile()); + final snapshot = snapshotExternalParameters( + policy: effectivePolicy, + parameters: parameters, + ); + appendEvidence( + TugboatEvent( + id: nextEventId('event'), + atMs: nowMs(), + type: 'external_event', + stream: TugboatEventStream.evidence, + data: { + if (boundedSource != null) 'source': boundedSource, + 'name': boundedName, + 'parameterKeys': snapshot.parameterKeys, + if (snapshot.parameters != null) 'parameters': snapshot.parameters, + 'capture': snapshot.toCaptureMetadata(), + }, + ), + ); + _externalAccepted = _clamp(_externalAccepted + 1); + } catch (_) { + _noteDrop(_EvidenceKind.external, 'record_failed'); + } + } + + /// Begins observation of one logical network call. + /// + /// [route] must already be a safe host-supplied template. Null/invalid routes + /// return a no-op token without recording. + TugboatNetworkCall beginNetworkCall({required String method, String? route}) { + try { + if (!accepting) { + _noteDrop(_EvidenceKind.network, 'no_active_session'); + return const TugboatNoOpNetworkCall(); + } + final normalizedMethod = normalizeNetworkMethod(method); + final normalizedRoute = normalizeNetworkRoute(route); + if (normalizedMethod == null || normalizedRoute == null) { + _noteDrop(_EvidenceKind.network, 'invalid_route'); + return const TugboatNoOpNetworkCall(); + } + final session = _session; + if (session == null) { + _noteDrop(_EvidenceKind.network, 'no_active_session'); + return const TugboatNoOpNetworkCall(); + } + return _ActiveNetworkCall( + recorder: this, + sessionId: session.id, + method: normalizedMethod, + route: normalizedRoute, + startedAtMs: nowMs(), + ); + } catch (_) { + _noteDrop(_EvidenceKind.network, 'begin_failed'); + return const TugboatNoOpNetworkCall(); + } + } + + void _finishNetworkCall({ + required String sessionId, + required String method, + required String route, + required int startedAtMs, + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) { + try { + if (!accepting) { + _noteDrop(_EvidenceKind.network, 'no_active_session'); + return; + } + if (_session?.id != sessionId) { + _noteDrop(_EvidenceKind.network, 'stale_session'); + return; + } + final durationMs = (nowMs() - startedAtMs).clamp(0, 24 * 60 * 60 * 1000); + appendEvidence( + TugboatEvent( + id: nextEventId('event'), + atMs: nowMs(), + type: 'network_call', + stream: TugboatEventStream.evidence, + data: { + 'method': method, + 'route': route, + if (statusCode != null) 'statusCode': statusCode, + 'outcome': outcome.wireName, + 'durationMs': durationMs, + if (attemptCount != null && attemptCount > 0) + 'attemptCount': attemptCount, + }, + ), + ); + _networkAccepted = _clamp(_networkAccepted + 1); + } catch (_) { + _noteDrop(_EvidenceKind.network, 'finish_failed'); + } + } + + void _noteDuplicateFinish(String sessionId) { + if (!accepting || _session?.id != sessionId) return; + _networkDuplicateFinishes = _clamp(_networkDuplicateFinishes + 1); + } + + void _noteDrop(_EvidenceKind kind, String reason) { + switch (kind) { + case _EvidenceKind.external: + _externalDropped = _clamp(_externalDropped + 1); + case _EvidenceKind.network: + _networkDropped = _clamp(_networkDropped + 1); + } + _lastDropReason = reason; + } + + int _clamp(int value) => value > _maxCount ? _maxCount : value; +} + +class _ActiveNetworkCall implements TugboatNetworkCall { + _ActiveNetworkCall({ + required TugboatEvidenceRecorder recorder, + required this.sessionId, + required this.method, + required this.route, + required this.startedAtMs, + }) : _recorder = recorder; + + final TugboatEvidenceRecorder _recorder; + final String sessionId; + final String method; + final String route; + final int startedAtMs; + bool _finished = false; + + @override + void complete({int? statusCode, int? attemptCount}) { + _finish( + outcome: TugboatNetworkOutcome.response, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } + + @override + void fail({ + required TugboatNetworkFailure failure, + int? statusCode, + int? attemptCount, + }) { + _finish( + outcome: failure.outcome, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } + + void _finish({ + required TugboatNetworkOutcome outcome, + int? statusCode, + int? attemptCount, + }) { + if (_finished) { + _recorder._noteDuplicateFinish(sessionId); + return; + } + _finished = true; + _recorder._finishNetworkCall( + sessionId: sessionId, + method: method, + route: route, + startedAtMs: startedAtMs, + outcome: outcome, + statusCode: statusCode, + attemptCount: attemptCount, + ); + } +} diff --git a/packages/tugboat/lib/src/external_event.dart b/packages/tugboat/lib/src/external_event.dart index e44ead9..780f8eb 100644 --- a/packages/tugboat/lib/src/external_event.dart +++ b/packages/tugboat/lib/src/external_event.dart @@ -1,11 +1,20 @@ import 'dart:convert'; +import 'capture_profile.dart'; + /// Closed vocabulary for how external-event parameter values were retained. -abstract final class TugboatParameterCaptureValues { - static const namesOnly = 'names_only'; - static const allowList = 'allow_list'; - static const transform = 'transform'; - static const allowAll = 'allow_all'; +enum TugboatParameterCaptureMode { + namesOnly, + allowList, + transform, + allowAll; + + String get wireName => switch (this) { + TugboatParameterCaptureMode.namesOnly => 'names_only', + TugboatParameterCaptureMode.allowList => 'allow_list', + TugboatParameterCaptureMode.transform => 'transform', + TugboatParameterCaptureMode.allowAll => 'allow_all', + }; } /// Sentinel returned from a [TugboatParameterPolicy.transform] callback to omit @@ -21,7 +30,7 @@ class TugboatParameterDrop { /// exploration escape hatch. class TugboatParameterPolicy { const TugboatParameterPolicy._({ - required this.captureValues, + required this.mode, this.allowedKeys, this.valueTransform, }); @@ -29,13 +38,13 @@ class TugboatParameterPolicy { /// Record event name plus bounded parameter keys only. Default production /// policy. static const namesOnly = TugboatParameterPolicy._( - captureValues: TugboatParameterCaptureValues.namesOnly, + mode: TugboatParameterCaptureMode.namesOnly, ); /// Preserve JSON-safe values only for the named keys. static TugboatParameterPolicy allowList(Set keys) => TugboatParameterPolicy._( - captureValues: TugboatParameterCaptureValues.allowList, + mode: TugboatParameterCaptureMode.allowList, allowedKeys: Set.unmodifiable(keys), ); @@ -44,23 +53,38 @@ class TugboatParameterPolicy { static TugboatParameterPolicy transform( Object? Function(String key, Object? value) transform, ) => TugboatParameterPolicy._( - captureValues: TugboatParameterCaptureValues.transform, + mode: TugboatParameterCaptureMode.transform, valueTransform: transform, ); /// Exploration-only escape hatch that retains all JSON-safe values within /// hard limits. Can capture feedback text, search terms, IDs, and other user /// content. Do not use as the default production example. + /// + /// Outside [TugboatCaptureProfile.exploration], [effectiveFor] downgrades + /// this to [namesOnly]. static const allowAll = TugboatParameterPolicy._( - captureValues: TugboatParameterCaptureValues.allowAll, + mode: TugboatParameterCaptureMode.allowAll, ); /// Sentinel for transform callbacks. static const drop = TugboatParameterDrop._(); - final String captureValues; + final TugboatParameterCaptureMode mode; final Set? allowedKeys; final Object? Function(String key, Object? value)? valueTransform; + + /// Wire label for capture metadata (`names_only`, `allow_list`, …). + String get captureValues => mode.wireName; + + /// Resolves exploration-only escape hatches against the active profile. + TugboatParameterPolicy effectiveFor(TugboatCaptureProfile profile) { + if (mode == TugboatParameterCaptureMode.allowAll && + profile != TugboatCaptureProfile.exploration) { + return namesOnly; + } + return this; + } } /// Hard limits applied when snapshotting external-event parameters. @@ -135,37 +159,23 @@ TugboatParameterSnapshot snapshotExternalParameters({ } keys.add(key); - if (policy.captureValues == TugboatParameterCaptureValues.namesOnly) { + final candidate = switch (policy.mode) { + TugboatParameterCaptureMode.namesOnly => _skipValue, + TugboatParameterCaptureMode.allowList => + (policy.allowedKeys?.contains(key) ?? false) ? entry.value : _dropValue, + TugboatParameterCaptureMode.transform => _applyTransform( + policy, + key, + entry.value, + ), + TugboatParameterCaptureMode.allowAll => entry.value, + }; + if (identical(candidate, _skipValue)) continue; + if (identical(candidate, _dropValue)) { + dropped += 1; continue; } - if (policy.captureValues == TugboatParameterCaptureValues.allowList) { - final allowed = policy.allowedKeys; - if (allowed == null || !allowed.contains(key)) { - dropped += 1; - continue; - } - } - - Object? candidate = entry.value; - if (policy.captureValues == TugboatParameterCaptureValues.transform) { - final transform = policy.valueTransform; - if (transform == null) { - dropped += 1; - continue; - } - try { - candidate = transform(key, entry.value); - } catch (_) { - dropped += 1; - continue; - } - if (identical(candidate, TugboatParameterPolicy.drop)) { - dropped += 1; - continue; - } - } - final copied = _copyJsonSafe( candidate, depth: 1, @@ -183,15 +193,12 @@ TugboatParameterSnapshot snapshotExternalParameters({ return true; }, ); - if (copied == _unsupported) { - dropped += 1; - continue; - } + if (identical(copied, _unsupported)) continue; retained[key] = copied; } Map? parametersOut; - if (policy.captureValues != TugboatParameterCaptureValues.namesOnly && + if (policy.mode != TugboatParameterCaptureMode.namesOnly && retained.isNotEmpty) { parametersOut = Map.unmodifiable(retained); final encodedLength = utf8.encode(jsonEncode(parametersOut)).length; @@ -214,7 +221,30 @@ TugboatParameterSnapshot snapshotExternalParameters({ ); } -const Object _unsupported = Object(); +const _Sentinel _unsupported = _Sentinel('unsupported'); +const _Sentinel _skipValue = _Sentinel('skip'); +const _Sentinel _dropValue = _Sentinel('drop'); + +class _Sentinel { + const _Sentinel(this.label); + final String label; +} + +Object? _applyTransform( + TugboatParameterPolicy policy, + String key, + Object? value, +) { + final transform = policy.valueTransform; + if (transform == null) return _dropValue; + try { + final candidate = transform(key, value); + if (identical(candidate, TugboatParameterPolicy.drop)) return _dropValue; + return candidate; + } catch (_) { + return _dropValue; + } +} Object? _copyJsonSafe( Object? value, { diff --git a/packages/tugboat/lib/src/network_observer.dart b/packages/tugboat/lib/src/network_observer.dart index 482ae06..0801200 100644 --- a/packages/tugboat/lib/src/network_observer.dart +++ b/packages/tugboat/lib/src/network_observer.dart @@ -11,6 +11,17 @@ enum TugboatNetworkOutcome { }; } +/// Failure reasons accepted by [TugboatNetworkCall.fail]. +enum TugboatNetworkFailure { + networkError, + cancelled; + + TugboatNetworkOutcome get outcome => switch (this) { + TugboatNetworkFailure.networkError => TugboatNetworkOutcome.networkError, + TugboatNetworkFailure.cancelled => TugboatNetworkOutcome.cancelled, + }; +} + /// Hard limits for host-supplied network observation fields. abstract final class TugboatNetworkLimits { static const maxMethodLength = 16; @@ -25,7 +36,7 @@ abstract interface class TugboatNetworkCall { void complete({int? statusCode, int? attemptCount}); void fail({ - required TugboatNetworkOutcome outcome, + required TugboatNetworkFailure failure, int? statusCode, int? attemptCount, }); @@ -40,7 +51,7 @@ class TugboatNoOpNetworkCall implements TugboatNetworkCall { @override void fail({ - required TugboatNetworkOutcome outcome, + required TugboatNetworkFailure failure, int? statusCode, int? attemptCount, }) {} @@ -57,6 +68,22 @@ String? normalizeNetworkRoute(String? route) { if (route == null) return null; final trimmed = route.trim(); if (trimmed.isEmpty) return null; + if (trimmed != route) return null; if (trimmed.length > TugboatNetworkLimits.maxRouteLength) return null; + // Routes are host-supplied templates, never arbitrary URLs. Requiring an + // absolute path and rejecting URI delimiters, encoded delimiters, backslash, + // and whitespace keeps accidental raw request URLs out of evidence. Dynamic + // IDs still need to be removed by the host's route resolver. + if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null; + if (trimmed.contains('://')) return null; + if (trimmed.runes.any(_isForbiddenRouteRune)) return null; return trimmed; } + +bool _isForbiddenRouteRune(int rune) { + if (rune <= 0x20 || rune == 0x7f) return true; + return rune == '%'.codeUnitAt(0) || + rune == '#'.codeUnitAt(0) || + rune == '?'.codeUnitAt(0) || + rune == r'\'.codeUnitAt(0); +} diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index 8d740db..ce62a17 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -84,6 +84,16 @@ class TugboatReplay { /// Whether capture machinery is allowed to run ([disabled] is `false`). static bool get isEnabled => !_lifecycle.disabled; + /// Whether the current session can accept app and network evidence now. + /// + /// Companion adapters should check this before invoking host callbacks or + /// attaching observation metadata. The core APIs remain safe no-ops if the + /// lifecycle changes before an observation reaches them. + static bool get isAcceptingEvidence => + !disabled && + _lifecycle.state != TugboatLifecycleState.stopping && + (_controller?.acceptingEvidence ?? false); + /// Enables capture machinery for dormant builds at runtime. /// /// Prefer [activationRequestId]; [sessionId] is retained for compatibility. @@ -104,6 +114,9 @@ class TugboatReplay { /// Returns the SDK to dormant mode without tearing down the host app. static void deactivate() { _lifecycle.deactivate(); + // Widget teardown happens on the next build. End now so same-turn calls + // cannot append evidence after deactivation was requested. + unawaited(_controller?.endSession()); } /// Current sanitized health snapshot (empty when no controller). @@ -152,13 +165,13 @@ class TugboatReplay { /// /// [route] must already be a safe host-supplied template such as /// `/blend/:blendId`. Raw paths are never accepted as a fallback. Returns a - /// no-op token when Tugboat is dormant/disabled or [route] is empty. + /// no-op token when Tugboat is dormant/disabled or [route] is null/invalid. static TugboatNetworkCall beginNetworkCall({ required String method, - required String route, + String? route, }) { try { - if (disabled) return const TugboatNoOpNetworkCall(); + if (!isAcceptingEvidence) return const TugboatNoOpNetworkCall(); final controller = _controller; if (controller == null) return const TugboatNoOpNetworkCall(); return controller.beginNetworkCall(method: method, route: route); @@ -186,7 +199,7 @@ class _TugboatEventHook implements TugboatEventHook { @override void record(String name, {Map? parameters}) { try { - if (TugboatReplay.disabled) return; + if (!TugboatReplay.isAcceptingEvidence) return; final controller = TugboatReplay.controller; if (controller == null) return; controller.recordExternalEvent( diff --git a/packages/tugboat/lib/tugboat.dart b/packages/tugboat/lib/tugboat.dart index b895b59..af46131 100644 --- a/packages/tugboat/lib/tugboat.dart +++ b/packages/tugboat/lib/tugboat.dart @@ -39,16 +39,15 @@ export 'src/external_event.dart' show TugboatEventHook, TugboatParameterPolicy, - TugboatParameterCaptureValues, + TugboatParameterCaptureMode, TugboatParameterLimits, - TugboatParameterSnapshot, TugboatParameterDrop; export 'src/network_observer.dart' show TugboatNetworkCall, TugboatNetworkOutcome, - TugboatNetworkLimits, - TugboatNoOpNetworkCall; + TugboatNetworkFailure, + TugboatNetworkLimits; export 'src/coordinate_space.dart' show tugboatCaptureCoordinateVersion, diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index 8373a7e..53ac8a3 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -11,17 +11,54 @@ const _testConfig = TugboatReplayConfig( capturePixelRatio: 1.0, ); -Future _pumpCapture(WidgetTester tester) async { +Future _pumpCapture( + WidgetTester tester, { + TugboatReplayConfig config = _testConfig, +}) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => - TugboatReplay.wrapApp(config: _testConfig, child: child!), + TugboatReplay.wrapApp(config: config, child: child!), home: const SizedBox.expand(), ), ); await tester.pump(); } +class _CallbackSinkFactory implements TugboatCaptureSinkFactory { + _CallbackSinkFactory(this.onEvent); + + final void Function(TugboatEvent event) onEvent; + + @override + TugboatSessionCaptureSink create(TugboatSinkSessionContext context) => + _CallbackSink(onEvent); +} + +class _CallbackSink implements TugboatSessionCaptureSink { + _CallbackSink(this.onEvent); + + final void Function(TugboatEvent event) onEvent; + + @override + void accept(TugboatCaptureEnvelope envelope) { + final event = envelope.event; + if (event != null) onEvent(event); + } + + @override + Future dispose() async {} + + @override + Future finish() async {} + + @override + Future flush() async {} + + @override + Future start(TugboatSinkSessionContext context) async {} +} + void main() { tearDown(TugboatReplay.resetForTest); @@ -77,6 +114,32 @@ void main() { }); }); + testWidgets('production capture downgrades allow-all to names-only', ( + tester, + ) async { + await _pumpCapture( + tester, + config: _testConfig.copyWith( + profile: TugboatCaptureProfile.productionLean, + ), + ); + + TugboatReplay.eventHook( + parameterPolicy: TugboatParameterPolicy.allowAll, + ).record('SEARCH', parameters: {'query': 'private search'}); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'external_event', + ); + expect(event.data['parameterKeys'], ['query']); + expect(event.data.containsKey('parameters'), isFalse); + expect(event.data['capture'], { + 'values': 'names_only', + 'truncated': false, + 'droppedCount': 0, + }); + }); + testWidgets('external event ignores active action window', (tester) async { await _pumpCapture(tester); final controller = TugboatReplay.controller!; @@ -149,7 +212,7 @@ void main() { route: '/cart/:cartId', ); call.complete(statusCode: 201); - call.fail(outcome: TugboatNetworkOutcome.networkError); + call.fail(failure: TugboatNetworkFailure.networkError); call.complete(statusCode: 500); final events = TugboatReplay.controller!.session!.events @@ -167,6 +230,88 @@ void main() { ); }); + testWidgets('network token cannot finish into a replacement session', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + final staleCall = TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/stale', + ); + + controller.start(const Size(320, 640), 'replacement'); + staleCall.complete(statusCode: 200); + staleCall.complete(statusCode: 500); + + expect( + controller.session!.events.where((e) => e.type == 'network_call'), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkAccepted, 0); + expect(TugboatReplay.health.evidence.networkDropped, 1); + expect(TugboatReplay.health.evidence.networkDuplicateFinishes, 0); + expect(TugboatReplay.health.evidence.lastDropReason, 'stale_session'); + + TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/current', + ).complete(statusCode: 204); + final current = controller.session!.events.singleWhere( + (e) => e.type == 'network_call', + ); + expect(current.data['route'], '/current'); + expect(current.data['statusCode'], 204); + }); + + testWidgets('clear fences in-flight network tokens', (tester) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + final staleCall = TugboatReplay.beginNetworkCall( + method: 'POST', + route: '/before-clear', + ); + + controller.clear(); + staleCall.fail(failure: TugboatNetworkFailure.networkError); + + expect( + controller.session!.events.where((e) => e.type == 'network_call'), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkAccepted, 0); + expect(TugboatReplay.health.evidence.networkDropped, 1); + expect(TugboatReplay.health.evidence.lastDropReason, 'stale_session'); + }); + + testWidgets('session end rejects evidence during sink reentrancy', ( + tester, + ) async { + TugboatReplayController? activeController; + final factory = _CallbackSinkFactory((event) { + if (event.type != 'session_end') return; + final controller = activeController!; + controller.recordExternalEvent(name: 'AFTER_SESSION_END'); + controller + .beginNetworkCall(method: 'GET', route: '/after-end') + .complete(statusCode: 200); + }); + await _pumpCapture( + tester, + config: _testConfig.copyWith(sinkFactories: [factory]), + ); + final controller = TugboatReplay.controller!; + activeController = controller; + + await controller.endSession(); + + final eventTypes = controller.session!.events.map((event) => event.type); + expect(eventTypes.where((type) => type == 'session_end'), hasLength(1)); + expect(eventTypes, isNot(contains('external_event'))); + expect(eventTypes, isNot(contains('network_call'))); + expect(controller.acceptingEvidence, isFalse); + }); + testWidgets('empty route returns no-op without event', (tester) async { await _pumpCapture(tester); final call = TugboatReplay.beginNetworkCall(method: 'GET', route: ' '); @@ -180,6 +325,37 @@ void main() { expect(TugboatReplay.health.evidence.networkDropped, greaterThan(0)); }); + testWidgets('unsafe route forms return no-op without retaining URL data', ( + tester, + ) async { + await _pumpCapture(tester); + for (final route in [ + 'https://example.test/users/42?token=secret', + '/users/42?token=secret', + '/users/42#fragment', + '/users/42%3Ftoken%3Dsecret', + 'users/42', + '//example.test/users/42', + ' /users/42', + '/users/42 ', + '/users/42\\details', + ]) { + TugboatReplay.beginNetworkCall( + method: 'GET', + route: route, + ).complete(statusCode: 200); + } + + expect( + TugboatReplay.controller!.session!.events.where( + (e) => e.type == 'network_call', + ), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkDropped, 9); + expect(TugboatReplay.health.toJson().toString().contains('secret'), false); + }); + test('parameter snapshot deep-copies and bounds nested values', () { final nested = { 'a': { @@ -203,4 +379,30 @@ void main() { expect(encoded.contains('too-deep'), isFalse); expect(encoded.contains('Object'), isFalse); }); + + test('unsupported top-level value counts as one drop', () { + final snapshot = snapshotExternalParameters( + policy: TugboatParameterPolicy.allowAll, + parameters: {'unsupported': Object()}, + ); + + expect(snapshot.parameters, isNull); + expect(snapshot.truncated, isTrue); + expect(snapshot.droppedCount, 1); + }); + + test('aggregate byte budget drops all values but retains keys', () { + final parameters = { + for (var i = 0; i < 17; i++) 'key$i': 'x' * 1024, + }; + final snapshot = snapshotExternalParameters( + policy: TugboatParameterPolicy.allowAll, + parameters: parameters, + ); + + expect(snapshot.parameterKeys, parameters.keys); + expect(snapshot.parameters, isNull); + expect(snapshot.truncated, isTrue); + expect(snapshot.droppedCount, parameters.length); + }); } diff --git a/packages/tugboat/test/semantics_flags_compat_test.dart b/packages/tugboat/test/semantics_flags_compat_test.dart index e97f8c1..5828cce 100644 --- a/packages/tugboat/test/semantics_flags_compat_test.dart +++ b/packages/tugboat/test/semantics_flags_compat_test.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/src/semantics_flags_compat.dart'; @@ -11,22 +12,30 @@ void main() { }, ); - test('semanticsEnabledFromFlags reads explicit enabled state', () { - expect(semanticsEnabledFromFlags(_flagsWithEnabled(true)), isTrue); - expect(semanticsEnabledFromFlags(_flagsWithEnabled(false)), isFalse); - }); -} + testWidgets('semanticsEnabledFromFlags reads explicit enabled state', ( + tester, + ) async { + final semanticsHandle = tester.ensureSemantics(); + try { + await tester.pumpWidget( + Semantics(container: true, enabled: true, child: SizedBox.shrink()), + ); + final enabledFlags = tester + .getSemantics(find.byType(Semantics)) + .getSemanticsData() + .flagsCollection; + expect(semanticsEnabledFromFlags(enabledFlags), isTrue); -/// Builds enabled-state flags across Flutter 3.35 bool pairs and 3.36+ Tristate. -SemanticsFlags _flagsWithEnabled(bool enabled) { - final dynamic none = SemanticsFlags.none; - try { - final dynamic tristate = enabled - ? (Tristate.isTrue as dynamic) - : (Tristate.isFalse as dynamic); - return none.copyWith(isEnabled: tristate) as SemanticsFlags; - } catch (_) { - return none.copyWith(hasEnabledState: true, isEnabled: enabled) - as SemanticsFlags; - } + await tester.pumpWidget( + Semantics(container: true, enabled: false, child: SizedBox.shrink()), + ); + final disabledFlags = tester + .getSemantics(find.byType(Semantics)) + .getSemanticsData() + .flagsCollection; + expect(semanticsEnabledFromFlags(disabledFlags), isFalse); + } finally { + semanticsHandle.dispose(); + } + }); } diff --git a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart index 47c3b68..9a49380 100644 --- a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart +++ b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart @@ -19,8 +19,7 @@ typedef TugboatDioRouteResolver = String? Function(RequestOptions request); class TugboatDioInterceptor extends Interceptor { TugboatDioInterceptor({required this.routeResolver}); - static const extraCallKey = 'tugboat.network_call'; - static const extraAttemptCountKey = 'tugboat.network_attempt_count'; + static const _extraCallKey = 'tugboat.network_call'; final TugboatDioRouteResolver routeResolver; @@ -53,26 +52,33 @@ class TugboatDioInterceptor extends Interceptor { Response response, ResponseInterceptorHandler handler, ) { + final options = response.requestOptions; + TugboatNetworkCall? call; try { - final call = _tokenOf(response.requestOptions); + call = _tokenOf(options); call?.complete( statusCode: response.statusCode, - attemptCount: _attemptCount(response.requestOptions), + attemptCount: _attemptCount(options), ); - } catch (_) {} + } catch (_) { + } finally { + if (call != null) _clearToken(options); + } handler.next(response); } @override void onError(DioException err, ErrorInterceptorHandler handler) { + final options = err.requestOptions; + TugboatNetworkCall? call; try { - final call = _tokenOf(err.requestOptions); + call = _tokenOf(options); if (call != null) { final statusCode = err.response?.statusCode; - final attempts = _attemptCount(err.requestOptions); + final attempts = _attemptCount(options); if (err.type == DioExceptionType.cancel) { call.fail( - outcome: TugboatNetworkOutcome.cancelled, + failure: TugboatNetworkFailure.cancelled, statusCode: statusCode, attemptCount: attempts, ); @@ -82,22 +88,30 @@ class TugboatDioInterceptor extends Interceptor { call.complete(statusCode: statusCode, attemptCount: attempts); } else { call.fail( - outcome: TugboatNetworkOutcome.networkError, + failure: TugboatNetworkFailure.networkError, statusCode: statusCode, attemptCount: attempts, ); } } - } catch (_) {} + } catch (_) { + } finally { + if (call != null) _clearToken(options); + } handler.next(err); } void _ensureToken(RequestOptions options) { - final existing = options.extra[extraCallKey]; - if (existing is TugboatNetworkCall) { - final attempts = options.extra[extraAttemptCountKey]; - final current = attempts is int ? attempts : 1; - options.extra[extraAttemptCountKey] = current + 1; + if (!TugboatReplay.isAcceptingEvidence) return; + + final existing = options.extra[_extraCallKey]; + if (existing is _TugboatDioCallState) { + existing.attemptCount += 1; + return; + } + if (options.extra.containsKey(_extraCallKey)) { + // A host owns this key. Fail open without invoking its resolver or + // changing metadata that does not belong to this interceptor. return; } @@ -107,31 +121,41 @@ class TugboatDioInterceptor extends Interceptor { } catch (_) { route = null; } - final normalized = _normalizeRoute(route); - final call = TugboatReplay.beginNetworkCall( - method: options.method, - // Empty route forces a bounded drop when the resolver rejected the call. - route: normalized ?? '', + if (!TugboatReplay.isAcceptingEvidence) return; + + final trimmed = route?.trim(); + options.extra[_extraCallKey] = _TugboatDioCallState( + TugboatReplay.beginNetworkCall( + method: options.method, + route: (trimmed == null || trimmed.isEmpty) ? null : trimmed, + ), ); - options.extra[extraCallKey] = call; - options.extra[extraAttemptCountKey] = 1; } TugboatNetworkCall? _tokenOf(RequestOptions options) { - final value = options.extra[extraCallKey]; - return value is TugboatNetworkCall ? value : null; + final value = options.extra[_extraCallKey]; + return value is _TugboatDioCallState ? value.call : null; } int? _attemptCount(RequestOptions options) { - final value = options.extra[extraAttemptCountKey]; - return value is int ? value : null; + final value = options.extra[_extraCallKey]; + return value is _TugboatDioCallState ? value.attemptCount : null; } - static String? _normalizeRoute(String? route) { - if (route == null) return null; - final trimmed = route.trim(); - if (trimmed.isEmpty) return null; - if (trimmed.length > TugboatNetworkLimits.maxRouteLength) return null; - return trimmed; + void _clearToken(RequestOptions options) { + try { + if (options.extra[_extraCallKey] is _TugboatDioCallState) { + options.extra.remove(_extraCallKey); + } + } catch (_) { + // Cleanup must never affect host networking. + } } } + +class _TugboatDioCallState { + _TugboatDioCallState(this.call); + + final TugboatNetworkCall call; + int attemptCount = 1; +} diff --git a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart index 69f7994..e060dfc 100644 --- a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart +++ b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -67,10 +68,14 @@ void main() { ); TugboatDioInterceptor.install(dio, routeResolver: (_) => '/blend/:blendId'); - await _runAsync( + final response = await _runAsync( tester, - () => dio.get('/blend/raw-id-should-not-appear?x=1'), + () => dio.get( + '/blend/raw-id-should-not-appear?x=1', + options: Options(extra: {'host.keep': 'value'}), + ), ); + expect(response.requestOptions.extra, {'host.keep': 'value'}); final events = TugboatReplay.controller!.session!.events .where((e) => e.type == 'network_call') @@ -114,8 +119,10 @@ void main() { testWidgets('transport error emits network_error', (tester) async { await _pumpCapture(tester); + RequestOptions? observedRequest; final dio = Dio(); dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; throw DioException( requestOptions: options, type: DioExceptionType.connectionError, @@ -126,9 +133,15 @@ void main() { await _runAsync( tester, - () => - expectLater(dio.get('/sync'), throwsA(isA())), + () => expectLater( + dio.get( + '/sync', + options: Options(extra: {'host.keep': 'value'}), + ), + throwsA(isA()), + ), ); + expect(observedRequest!.extra, {'host.keep': 'value'}); final event = TugboatReplay.controller!.session!.events.singleWhere( (e) => e.type == 'network_call', @@ -225,6 +238,30 @@ void main() { ); }); + testWidgets('unsafe resolver output drops without retaining URL data', ( + tester, + ) async { + await _pumpCapture(tester); + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString('ok', 200), + ); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) => 'https://example.test/users/42?token=secret', + ); + + await _runAsync(tester, () => dio.get('/users/42?token=secret')); + expect( + TugboatReplay.controller!.session!.events.where( + (e) => e.type == 'network_call', + ), + isEmpty, + ); + expect(TugboatReplay.health.evidence.networkDropped, greaterThan(0)); + expect(TugboatReplay.health.toJson().toString().contains('secret'), false); + }); + testWidgets('duplicate install is rejected', (tester) async { final dio = Dio(); final first = TugboatDioInterceptor.install( @@ -241,15 +278,251 @@ void main() { }); testWidgets('dormant tugboat leaves networking unchanged', (tester) async { + var resolverCalls = 0; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/x'; + }, + ); + + final response = await _runAsync( + tester, + () => dio.get( + '/x', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + expect(response.statusCode, 200); + expect(TugboatReplay.controller, isNull); + expect(resolverCalls, 0); + expect(observedRequest!.extra, {'host.keep': 'value'}); + }); + + testWidgets('disabled tugboat leaves request extras untouched', ( + tester, + ) async { + await _pumpCapture(tester); + TugboatReplay.disabled = true; + var resolverCalls = 0; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/x'; + }, + ); + + final response = await _runAsync( + tester, + () => dio.get( + '/x', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + + expect(response.statusCode, 200); + expect(resolverCalls, 0); + expect(observedRequest!.extra, {'host.keep': 'value'}); + }); + + testWidgets('ended session leaves request extras untouched', (tester) async { + await _pumpCapture(tester); + await TugboatReplay.controller!.endSession(); + var resolverCalls = 0; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/x'; + }, + ); + + final response = await _runAsync( + tester, + () => dio.get( + '/x', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + + expect(response.statusCode, 200); + expect(resolverCalls, 0); + expect(observedRequest!.extra, {'host.keep': 'value'}); + }); + + testWidgets('lifecycle change inside resolver does not attach metadata', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + unawaited(controller.endSession()); + return '/x'; + }, + ); + + final response = await _runAsync( + tester, + () => dio.get( + '/x', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + + expect(response.statusCode, 200); + expect(observedRequest!.extra, {'host.keep': 'value'}); + expect( + controller.session!.events.where((e) => e.type == 'network_call'), + isEmpty, + ); + }); + + testWidgets('deactivate synchronously fences event and network evidence', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + var resolverCalls = 0; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/after-deactivate'; + }, + ); + + TugboatReplay.deactivate(); + TugboatReplay.eventHook().record('AFTER_DEACTIVATE'); + final response = await _runAsync( + tester, + () => dio.get( + '/after-deactivate', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + + expect(response.statusCode, 200); + expect(TugboatReplay.isAcceptingEvidence, isFalse); + expect(resolverCalls, 0); + expect(observedRequest!.extra, {'host.keep': 'value'}); + expect( + controller.session!.events.where( + (event) => + event.type == 'external_event' || event.type == 'network_call', + ), + isEmpty, + ); + }); + + testWidgets('reserved extras survive a successful request', (tester) async { + await _pumpCapture(tester); + var resolverCalls = 0; final dio = Dio(); dio.httpClientAdapter = _ScriptedAdapter( (_) async => ResponseBody.fromString('ok', 200), ); - TugboatDioInterceptor.install(dio, routeResolver: (_) => '/x'); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/collision'; + }, + ); + final hostExtras = { + 'tugboat.network_call': 'host-call', + 'tugboat.network_attempt_count': 'host-attempts', + }; + + final response = await _runAsync( + tester, + () => dio.get('/collision', options: Options(extra: hostExtras)), + ); - final response = await _runAsync(tester, () => dio.get('/x')); expect(response.statusCode, 200); - expect(TugboatReplay.controller, isNull); + expect(response.requestOptions.extra, hostExtras); + expect(resolverCalls, 0); + expect( + TugboatReplay.controller!.session!.events.where( + (event) => event.type == 'network_call', + ), + isEmpty, + ); + }); + + testWidgets('reserved extras survive a failed request', (tester) async { + await _pumpCapture(tester); + var resolverCalls = 0; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + throw DioException( + requestOptions: options, + type: DioExceptionType.connectionError, + ); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + resolverCalls += 1; + return '/collision'; + }, + ); + final hostExtras = { + 'tugboat.network_call': 'host-call', + 'tugboat.network_attempt_count': 'host-attempts', + }; + + await _runAsync( + tester, + () => expectLater( + dio.get('/collision', options: Options(extra: hostExtras)), + throwsA(isA()), + ), + ); + + expect(observedRequest!.extra, hostExtras); + expect(resolverCalls, 0); + expect( + TugboatReplay.controller!.session!.events.where( + (event) => event.type == 'network_call', + ), + isEmpty, + ); }); testWidgets('cached interceptor resolve emits one logical response', ( diff --git a/pubspec.lock b/pubspec.lock index 3a13250..b76531a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -292,18 +292,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" melos: dependency: "direct dev" description: @@ -316,10 +316,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.16.0" mime: dependency: transitive description: @@ -489,10 +489,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.6" typed_data: dependency: transitive description: @@ -566,5 +566,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.9.2 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 63c043f..e5c60cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,11 +21,11 @@ melos: run: dart analyze . description: Analyze every package in the workspace. test: - run: flutter test packages/tugboat packages/tugboat_dio - description: Run all package tests. + run: cd packages/tugboat && flutter test -j 1 . && cd ../tugboat_dio && flutter test -j 1 . + description: Run all package tests with each package's asset bundle. test:sdk: - run: flutter test packages/tugboat + run: cd packages/tugboat && flutter test -j 1 . description: Run the Flutter SDK tests. test:dio: - run: flutter test packages/tugboat_dio + run: cd packages/tugboat_dio && flutter test -j 1 . description: Run the Dio adapter tests. From 7e27ca78f4a97c60f9724f60c214cabf11ffbe54 Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Tue, 4 Aug 2026 18:30:11 +0530 Subject: [PATCH 4/7] docs(replay): document observation lifecycle --- packages/tugboat/CHANGELOG.md | 9 +++++++++ packages/tugboat/README.md | 13 ++++++++++++- packages/tugboat_dio/CHANGELOG.md | 7 +++++++ packages/tugboat_dio/README.md | 18 ++++++++++++++++-- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index db90fd4..550eaae 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -21,6 +21,15 @@ lifecycle callbacks onto the core network token without importing Dio into core. +### Fixed + +- **Session-bound evidence completion** — in-flight network tokens can no + longer finish into a replacement session, and session end fences reentrant + evidence before publishing its terminal event. +- **Production parameter policy** — exploration-only `allowAll` is downgraded + to names-only outside exploration, and unsupported values contribute one + drop to bounded diagnostics. + ## 0.5.0 ### Breaking changes diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 69b3f7b..872c264 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -49,7 +49,18 @@ call.complete(statusCode: 200); Both emit on `stream: evidence` and never inherit exploration `actionId` or UI anchors. Parameter values are omitted unless an explicit policy allows them. -`allowAll` is an exploration escape hatch, not a production default. +`allowAll` is an exploration escape hatch; outside exploration profiles the SDK +downgrades it to names-only at record time. Network routes must be absolute path +templates. The SDK drops resolver output containing a scheme, query, fragment, +percent-encoded data, a network-path prefix, backslash, or whitespace/control +characters; host resolvers must still replace dynamic IDs with placeholders. + +Hooks resolve the active controller when `record` is called, rather than keeping +a session reference. Network tokens are bound to the capture session in which +they were created. Finishing a token after `clear`, session replacement, +deactivation, or session end is a bounded no-op and cannot append evidence to a +newer session. Calls made while Tugboat is dormant, disabled, deactivating, not +yet started, or already ended are also safe no-ops. ## Migrating to 0.5.0 diff --git a/packages/tugboat_dio/CHANGELOG.md b/packages/tugboat_dio/CHANGELOG.md index 5924ffd..08a164c 100644 --- a/packages/tugboat_dio/CHANGELOG.md +++ b/packages/tugboat_dio/CHANGELOG.md @@ -6,3 +6,10 @@ the core Tugboat network observation token. - `TugboatDioInterceptor.install` appends to the interceptor chain (after auth/retry) and rejects duplicate installation on the same `Dio` instance. + +### Fixed + +- Inactive or ended capture no longer invokes the host route resolver or + mutates `RequestOptions.extra`. +- Adapter-owned request state is cleaned up after terminal callbacks without + deleting colliding host metadata. diff --git a/packages/tugboat_dio/README.md b/packages/tugboat_dio/README.md index c48d3d3..9fe5fa1 100644 --- a/packages/tugboat_dio/README.md +++ b/packages/tugboat_dio/README.md @@ -35,6 +35,10 @@ TugboatDioInterceptor.install( `apiRouteTemplate` must return a safe template such as `/blend/:blendId`, never a raw path containing entity IDs. Return `null` or `''` to drop the call. +The adapter also drops resolver output that is not an absolute path or that +contains a scheme, query, fragment, percent-encoded data, a network-path +prefix, backslash, or whitespace/control character. A resolver is still +responsible for replacing dynamic path segments with placeholders. ## Interceptor ordering @@ -47,15 +51,25 @@ a raw path containing entity IDs. Return `null` or `''` to drop the call. `install` appends to the interceptor list and is a no-op when a `TugboatDioInterceptor` is already present. Namespaced `RequestOptions.extra` state prevents duplicate tokens when a retry calls `dio.fetch` with the same -options. +options. The state is owned by a private typed envelope and is removed after a +terminal response or error. If the host already owns the reserved +`tugboat.network_call` key, observation is skipped and that value is preserved. Cached/interceptor-resolved responses are recorded when they pass through this interceptor's response path (for example `handler.resolve(response, true)` so following response interceptors run). +Before invoking the route resolver or attaching request state, the adapter +checks whether the SDK is accepting evidence. Dormant, disabled, deactivating, +not-yet-started, and ended sessions therefore leave networking and +`RequestOptions.extra` unchanged. A lifecycle change inside the resolver is +checked again before any state is attached. + ## Privacy - Route templates only — no scheme, host, port, query, or fragment +- Invalid route outputs are dropped before a call is started - No request/response bodies, headers, or cookies - No raw `DioException` messages or stack traces -- Dormant/disabled Tugboat → networking unchanged, no events +- Dormant/disabled/deactivating/ended Tugboat → resolver not called, networking + unchanged, no events From 1abda10dcdb93cd778735e68171856937a582fcc Mon Sep 17 00:00:00 2001 From: Chinmay-KB Date: Tue, 4 Aug 2026 18:47:16 +0530 Subject: [PATCH 5/7] Address PR review feedback (#34) - fence external transforms to the admitted capture session - fence Dio route resolution across controller/session replacement - add lifecycle-race regression coverage --- .../tugboat/lib/src/evidence_recorder.dart | 13 ++++ .../test/external_event_and_network_test.dart | 60 +++++++++++++++++++ .../lib/src/tugboat_dio_interceptor.dart | 10 +++- .../test/tugboat_dio_interceptor_test.dart | 42 +++++++++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/tugboat/lib/src/evidence_recorder.dart b/packages/tugboat/lib/src/evidence_recorder.dart index a72745c..cf2c0b8 100644 --- a/packages/tugboat/lib/src/evidence_recorder.dart +++ b/packages/tugboat/lib/src/evidence_recorder.dart @@ -73,6 +73,11 @@ class TugboatEvidenceRecorder { _noteDrop(_EvidenceKind.external, 'no_active_session'); return; } + final admittedSessionId = _session?.id; + if (admittedSessionId == null) { + _noteDrop(_EvidenceKind.external, 'no_active_session'); + return; + } final boundedName = boundExternalLabel( name, TugboatParameterLimits.maxNameLength, @@ -90,6 +95,14 @@ class TugboatEvidenceRecorder { policy: effectivePolicy, parameters: parameters, ); + if (!accepting) { + _noteDrop(_EvidenceKind.external, 'no_active_session'); + return; + } + if (_session?.id != admittedSessionId) { + _noteDrop(_EvidenceKind.external, 'stale_session'); + return; + } appendEvidence( TugboatEvent( id: nextEventId('event'), diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index 53ac8a3..ee18087 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -205,6 +205,66 @@ void main() { expect((event.data['capture'] as Map)['droppedCount'], 2); }); + testWidgets('transform cannot append after synchronously ending session', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + final session = controller.session!; + final hook = TugboatReplay.eventHook( + parameterPolicy: TugboatParameterPolicy.transform((key, value) { + controller.endSession(); + return value; + }), + ); + + hook.record('AFTER_END', parameters: {'trigger': true}); + + expect( + session.events.where((event) => event.type == 'external_event'), + isEmpty, + ); + expect( + session.events.where((event) => event.type == 'session_end'), + hasLength(1), + ); + expect(TugboatReplay.health.evidence.externalAccepted, 0); + expect(TugboatReplay.health.evidence.externalDropped, 1); + expect(TugboatReplay.health.evidence.lastDropReason, 'no_active_session'); + }); + + testWidgets('transform cannot append into a replacement session', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + final originalSession = controller.session!; + final hook = TugboatReplay.eventHook( + parameterPolicy: TugboatParameterPolicy.transform((key, value) { + controller.clear(); + return value; + }), + ); + + hook.record('AFTER_CLEAR', parameters: {'trigger': true}); + + final replacementSession = controller.session!; + expect(replacementSession.id, isNot(originalSession.id)); + expect( + originalSession.events.where((event) => event.type == 'external_event'), + isEmpty, + ); + expect( + replacementSession.events.where( + (event) => event.type == 'external_event', + ), + isEmpty, + ); + expect(TugboatReplay.health.evidence.externalAccepted, 0); + expect(TugboatReplay.health.evidence.externalDropped, 1); + expect(TugboatReplay.health.evidence.lastDropReason, 'stale_session'); + }); + testWidgets('network token finishes exactly once', (tester) async { await _pumpCapture(tester); final call = TugboatReplay.beginNetworkCall( diff --git a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart index 9a49380..a9f2b16 100644 --- a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart +++ b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart @@ -115,13 +115,21 @@ class TugboatDioInterceptor extends Interceptor { return; } + final controller = TugboatReplay.controller; + final session = controller?.session; + if (controller == null || session == null) return; + String? route; try { route = routeResolver(options); } catch (_) { route = null; } - if (!TugboatReplay.isAcceptingEvidence) return; + if (!TugboatReplay.isAcceptingEvidence || + !identical(TugboatReplay.controller, controller) || + !identical(controller.session, session)) { + return; + } final trimmed = route?.trim(); options.extra[_extraCallKey] = _TugboatDioCallState( diff --git a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart index e060dfc..a2389ef 100644 --- a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart +++ b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -405,6 +405,48 @@ void main() { ); }); + testWidgets('session replacement inside resolver does not cross sessions', ( + tester, + ) async { + await _pumpCapture(tester); + final controller = TugboatReplay.controller!; + final originalSession = controller.session!; + RequestOptions? observedRequest; + final dio = Dio(); + dio.httpClientAdapter = _ScriptedAdapter((options) async { + observedRequest = options; + return ResponseBody.fromString('ok', 200); + }); + TugboatDioInterceptor.install( + dio, + routeResolver: (_) { + controller.clear(); + return '/x'; + }, + ); + + final response = await _runAsync( + tester, + () => dio.get( + '/x', + options: Options(extra: {'host.keep': 'value'}), + ), + ); + + final replacementSession = controller.session!; + expect(response.statusCode, 200); + expect(identical(replacementSession, originalSession), isFalse); + expect(observedRequest!.extra, {'host.keep': 'value'}); + expect( + originalSession.events.where((event) => event.type == 'network_call'), + isEmpty, + ); + expect( + replacementSession.events.where((event) => event.type == 'network_call'), + isEmpty, + ); + }); + testWidgets('deactivate synchronously fences event and network evidence', ( tester, ) async { From 0ec3c7a82a5edd8cc0d9d1ce61b0b569a6e8735a Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Thu, 6 Aug 2026 17:16:01 +0530 Subject: [PATCH 6/7] refactor(tugboat): simplify evidence admission and default to canonical interactions Claim end-session before sync sink work, unify evidence publish, fence on deactivate without early session_end, and seal parameter snapshot decisions. Also default interactionPublishMode to canonicalOnly with matching docs/tests. Co-authored-by: Cursor --- docs/README.md | 8 +- .../production-replay-acceptance-0.4.15.md | 6 +- ...uction-replay-run-2026-07-27-sdk-0.4.12.md | 4 +- ...feat-sdk-lifecycle-durable-capture-plan.md | 4 +- ...-001-sdk-interaction-consolidation-plan.md | 21 ++- packages/tugboat/CHANGELOG.md | 19 +- packages/tugboat/README.md | 71 +++++-- packages/tugboat/lib/src/controller.dart | 95 ++++++---- packages/tugboat/lib/src/external_event.dart | 177 +++++++++++------- packages/tugboat/lib/src/models.dart | 17 +- packages/tugboat/lib/src/replay_config.dart | 7 +- packages/tugboat/lib/src/tugboat.dart | 6 +- .../test/external_event_and_network_test.dart | 15 +- .../helpers/replay_coherence_harness.dart | 2 + .../release_compatibility_matrix_test.dart | 6 +- .../replay/interaction_transaction_test.dart | 74 ++++++++ ...ay_navigation_interaction_matrix_test.dart | 1 + .../replay_navigation_race_matrix_test.dart | 1 + ...overlay_nested_navigation_matrix_test.dart | 1 + .../replay/tap_coordinate_transform_test.dart | 1 + .../tugboat/test/scene_inventory_test.dart | 5 + .../tugboat/test/scroll_attribution_test.dart | 1 + .../test/scroll_playground_live_test.dart | 1 + .../tugboat/test/tugboat_replay_test.dart | 1 + .../test/viewport_semantic_map_test.dart | 6 + pubspec.lock | 22 +-- 26 files changed, 412 insertions(+), 160 deletions(-) diff --git a/docs/README.md b/docs/README.md index d6475a0..41629a1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,8 +3,8 @@ Documentation for the [Tugboat Flutter SDK](https://github.com/blendto/tugboat-flutter). These pages describe the Flutter package in this repository. The CLI, -collector, dashboard, and Atlas services have separate ownership and should be -verified in their own repositories. +collector, Context Graph (Atlas context), and wiki have separate ownership and +should be verified in their own repositories. ## Getting started @@ -15,7 +15,7 @@ verified in their own repositories. ## Design -- [Capture and fingerprint architecture](design/capture-and-fingerprint.md) — implemented schema-v6 identity, screenshots, semantic evidence, gaps, and next steps +- [Capture and fingerprint architecture](design/capture-and-fingerprint.md) — implemented schema-v6 identity, screenshots, inferred-event evidence, gaps, and next steps ## Repository layout @@ -28,7 +28,7 @@ verified in their own repositories. ## Current compatibility -- package version: `0.5.3`; +- package version: `0.6.0`; - session JSON schema: `9`; - fingerprint schema: `6`; - minimum Dart SDK: `3.9.2`; diff --git a/docs/integration/production-replay-acceptance-0.4.15.md b/docs/integration/production-replay-acceptance-0.4.15.md index b017520..cb512ee 100644 --- a/docs/integration/production-replay-acceptance-0.4.15.md +++ b/docs/integration/production-replay-acceptance-0.4.15.md @@ -40,16 +40,16 @@ side-by-side scoring. Record the new session id and Blend build before scoring. 5. **Automatic false-claim rate** — timer/auth redirects after the window, and routes with competing pointers, stay `navigationOrigin = automatic_or_unknown`. -6. **No semantic tap for scrolls/swipes** — completed scroll/swipe produces no +6. **No inferred tap for scrolls/swipes** — completed scroll/swipe produces no `stream: semantic` tap; one `interaction` with `gesture=scroll|swipe`. -7. **Diagnostic isolation** — enrichment selection of `stream: semantic` +7. **Diagnostic isolation** — enrichment selection of inferred events (`stream: semantic` excludes `capture_diagnostic`. 8. **Rage-tap precision** — three no-result taps on the same origin target flag once; three scrolls or three successful navigation taps do not. ## Soft / observational -- Semantic event count per completed gesture should drop vs 0.4.0 raw +- Inferred event count per completed gesture should drop vs 0.4.0 raw `tap`+`tap_settled`+scroll peer inflation. - Legacy projection remains present until collector/graph cut over; do not delete `tap`/`tap_settled` selection until two representative Blend flows pass diff --git a/docs/integration/production-replay-run-2026-07-27-sdk-0.4.12.md b/docs/integration/production-replay-run-2026-07-27-sdk-0.4.12.md index 89c5a22..8fe4efa 100644 --- a/docs/integration/production-replay-run-2026-07-27-sdk-0.4.12.md +++ b/docs/integration/production-replay-run-2026-07-27-sdk-0.4.12.md @@ -122,7 +122,7 @@ interaction or a gesture sequence that should be coalesced before replay. This likely explains a major part of the replay feeling erratic: the player may be faithfully rendering an event stream that is already too noisy. -### 5. Context graph identity was missing for diagnostics +### 5. Atlas context build identity was missing for diagnostics All 37 `capture_diagnostic` events had `contextEnrichment.reason = missing_context_graph_build_identity`. @@ -163,7 +163,7 @@ quality and completeness of the interaction evidence around those routes. coordinates without a clear degraded visual state. 4. Add a focused runtime acceptance flow for bottom sheets and paywalls in the Blend app, using production collection and dashboard replay inspection. -5. Ensure capture diagnostics include or can resolve context graph build +5. Ensure capture diagnostics include or can resolve Atlas context build identity, so replay-quality diagnostics and enrichment state can be separated cleanly. diff --git a/docs/plans/2026-07-16-001-feat-sdk-lifecycle-durable-capture-plan.md b/docs/plans/2026-07-16-001-feat-sdk-lifecycle-durable-capture-plan.md index 93a4496..4185588 100644 --- a/docs/plans/2026-07-16-001-feat-sdk-lifecycle-durable-capture-plan.md +++ b/docs/plans/2026-07-16-001-feat-sdk-lifecycle-durable-capture-plan.md @@ -99,7 +99,7 @@ Screenshot readback and PNG encoding also perform UI-thread work without a stabl - AE1. Given an app mounted with the default dormant profile, when `activate()` is called, capture begins without rebuilding `MaterialApp`, and exactly one emitted session is linked to the activation request. - AE2. Given activate-deactivate-activate calls in rapid succession, each created session ends once, no event is delivered to the prior session's sinks, and the second activation has a new capture-session ID. - AE3. Given a process restart with unacknowledged outbox entries, delivery resumes within retry bounds and acknowledged envelopes are not delivered again. -- AE4. Given screenshot encoding exceeds budget, interaction events continue, screenshots are coalesced or skipped, and health reports the specific degradation without including pixels or labels. +- AE4. Given screenshot encoding exceeds budget, inferred events continue, screenshots are coalesced or skipped, and health reports the specific degradation without including pixels or labels. - AE5. Given the same tagged actionable control in two locale variants of one exact release build, structural identity remains stable; a different build is not automatically treated as equivalent. ### Scope Boundaries @@ -237,7 +237,7 @@ Release-build validation and documentation close the rollout after the behavior - **Requirements:** R9-R10; KTD7. - **Dependencies:** U1. - **Files:** Modify screenshot/frame capture and replay policy files; add benchmarks and tests under `packages/tugboat/test/replay/` and `packages/tugboat/benchmark/`. -- **Approach:** Instrument capture stages, maintain a rolling budget, and coalesce or skip eligible screenshots while keeping interaction events and diagnostics. +- **Approach:** Instrument capture stages, maintain a rolling budget, and coalesce or skip eligible screenshots while keeping inferred events and diagnostics. - **Test scenarios:** Stage timings and sizes are recorded without pixels; unchanged frames still deduplicate; overload coalesces pending captures; critical lifecycle captures follow policy; structural evidence continues when screenshots degrade; recovery clears degraded state after the budget window. - **Verification:** Benchmark fixtures establish thresholds and tests prove predictable degradation without event loss. diff --git a/docs/plans/2026-07-28-001-sdk-interaction-consolidation-plan.md b/docs/plans/2026-07-28-001-sdk-interaction-consolidation-plan.md index bef601e..ae1ddc0 100644 --- a/docs/plans/2026-07-28-001-sdk-interaction-consolidation-plan.md +++ b/docs/plans/2026-07-28-001-sdk-interaction-consolidation-plan.md @@ -105,7 +105,7 @@ inflates insight calculations such as rage taps. configuration. 4. **Canonical `interaction` envelope with compatibility projection.** Add a - canonical semantic event shape (`type: interaction`, `gesture: tap|swipe| + canonical inferred event shape (`type: interaction`, `gesture: tap|swipe| scroll|cancelled`) and project legacy `tap`/`tap_settled` only behind a temporary compatibility gate. The collector and graph should migrate to the canonical shape before the legacy pair is removed. This avoids a breaking @@ -267,7 +267,7 @@ the transaction window has ended or a guard failed. - Place the semantic-publication gate immediately before `_addEvent`. The capture sink hub, outbox sink, and collector HTTP sink each serialize or queue events immediately, so none can safely be made responsible for - consolidation. Enforce one terminal semantic event per transaction ID before + consolidation. Enforce one terminal inferred event per transaction ID before it reaches any sink. - Move `_recordCaptureDiagnostic` to the diagnostic stream and define a compact end-of-session health aggregate for production observability. @@ -280,7 +280,7 @@ the transaction window has ended or a guard failed. - Serialization round-trip preserves immutable origin and successor result. - Outbox recovery never duplicates a finalized interaction or loses its evidence IDs. -- Normal semantic event selection excludes diagnostics and legacy projections. +- Normal inferred event selection excludes diagnostics and legacy projections. - A session with 10 gestures publishes 10 canonical semantic interactions, regardless of raw pointer/route/scroll callback count. @@ -330,6 +330,19 @@ the transaction window has ended or a guard failed. selection only after all acceptance gates pass for two representative Blend flows and no consumer still relies on it. +### Migration status — 2026-08-06 + +- The canonical `interaction` schema and temporary compatibility projection are + implemented. +- New recordings now default to `canonicalOnly`; `dualWrite` and `legacyOnly` + require an explicit override and are deprecated for new integrations. +- Collectors and replay readers must continue accepting historical legacy rows, + but enrichment, insight, and flow-attribution paths must select canonical + semantic `interaction` records. +- Final emitter deletion is intentionally deferred. Track it through + `TODO(tugboat-legacy-projection-removal)` and the removal checklist in + `packages/tugboat/README.md`. + ## Performance and safety budget - Maximum pending transactions: one per active pointer plus a small bounded @@ -380,7 +393,7 @@ the transaction window has ended or a guard failed. analysis/formatting. 2. Build Blend against the local SDK and manually exercise the acceptance flow. 3. Query ClickHouse by SDK version and canonical interaction schema version. -4. Score origin correctness, delayed attribution, false claims, semantic event +4. Score origin correctness, delayed attribution, false claims, inferred event count per completed gesture, diagnostic-stream isolation, and rage-tap precision. 5. Publish a side-by-side report against a locked 0.4.0+ baseline before diff --git a/packages/tugboat/CHANGELOG.md b/packages/tugboat/CHANGELOG.md index c4f0dc6..239ec10 100644 --- a/packages/tugboat/CHANGELOG.md +++ b/packages/tugboat/CHANGELOG.md @@ -1,8 +1,20 @@ ## 0.6.0 +### Changed + +- **Canonical interactions are now the recording default** — + `TugboatReplayConfig.interactionPublishMode` defaults to `canonicalOnly`, so + each finalized gesture emits one semantic `interaction` instead of also + emitting `tap`, `tap_settled`, or `swipe` compatibility rows. +- **Legacy gesture publication is deprecated** — `dualWrite` and `legacyOnly` + remain explicit migration overrides for historical consumers. New + integrations must not enable them; removal prerequisites and the searchable + `TODO(tugboat-legacy-projection-removal)` marker are documented in the SDK + README. + ### Added -- **Provider-neutral app-event hook** — `TugboatReplay.eventHook` records one +- **Provider-neutral coded-event hook** — `TugboatReplay.eventHook` records one logical `external_event` on the evidence stream with a bounded parameter policy (`namesOnly`, `allowList`, `transform`, or exploration-only `allowAll`). Values are deep-copied at hook time; dormant/disabled calls are @@ -29,6 +41,11 @@ - **Production parameter policy** — exploration-only `allowAll` is downgraded to names-only outside exploration, and unsupported values contribute one drop to bounded diagnostics. +- **Session-end admission** — session end claims its in-flight future before + sync sink work, so evidence fencing no longer needs a separate ending bool. +- **Deactivate evidence fence** — `TugboatReplay.deactivate` closes evidence + admission immediately; the activation gate still owns `session_end` on + teardown. ## 0.5.3 diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index 6d24d93..d19016f 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -28,9 +28,9 @@ dependencies: See `packages/tugboat_dio/README.md`. -## App events and network observation +## Coded events and network observation -Opt-in evidence hooks append to the active session without coupling to Amplitude, +Opt-in coded-event hooks append to the active session without coupling to Amplitude, Firebase, or a specific HTTP client: ```dart @@ -248,7 +248,7 @@ Call `TugboatReplay.clearDurableOutbox()` on logout/consent revocation. | `profile` | `dormant` | capture cost and exploration-only behavior | | `settleDelay` | 1 second | delay before post-interaction and post-route capture | | `interactionClaimWindow` | 1,250 ms | released-tap window for delayed route/modal attribution; `Duration.zero` keeps microtask-only same-turn claims | -| `interactionPublishMode` | `dualWrite` | how finalized gestures are published: `legacyOnly`, `dualWrite` (canonical + legacy peers on `stream: legacy_projection`), or `canonicalOnly` | +| `interactionPublishMode` | `canonicalOnly` | how finalized gestures are published; new recordings emit one canonical `interaction`. `legacyOnly` and `dualWrite` are deprecated compatibility modes | | `maxFrames` | 500 | in-memory frame bound | | `maxEvents` | 5000 | in-memory event bound | | `scrollCaptureInterval` | 2 seconds | interval for scroll checkpoint capture | @@ -269,6 +269,46 @@ Call `TugboatReplay.clearDurableOutbox()` on logout/consent revocation. | `outbox` | disabled | durable HTTP outbox configuration | | `screenshotBudget` | defaults | degraded-capture skip window / budget | +### Legacy gesture projection deprecation + +New recordings default to `TugboatInteractionPublishMode.canonicalOnly`. One +completed physical gesture produces one semantic `interaction` event containing +its immutable origin, finalized gesture, result, attribution, and evidence IDs. +The SDK no longer emits separate `tap`, `tap_settled`, or `swipe` rows unless an +integration explicitly opts into a legacy mode. + +`dualWrite` and `legacyOnly` remain available temporarily so older collectors, +Context Graph revisions, dashboards, and replay fixtures can be migrated without +making historical recordings unreadable: + +- `canonicalOnly` — supported default for all new recordings; +- `dualWrite` — deprecated migration override that adds legacy peers on + `stream: legacy_projection` with `enrichmentCandidate: false`; +- `legacyOnly` — deprecated emergency compatibility override for consumers that + cannot yet read canonical `interaction` records. + +Do not enable either legacy mode in a new application integration. Consumers +must use `interaction` as the user action and treat route/state/frame records as +linked evidence. Historical `tap` and `tap_settled` rows may still be read and +correlated through `interactionId` / `relatedEventId`, but must not be counted as +additional user actions. + +The code marker `TODO(tugboat-legacy-projection-removal)` tracks final removal. +Remove the legacy enum values and emission branches in a future breaking SDK +release only after all of the following are true: + +1. Supported Collector and Context Graph versions consume canonical + `interaction` records and ignore legacy projections by default. +2. Dashboard, insight, rage-tap, and replay queries no longer depend on + `tap_settled` or legacy `swipe` rows. +3. Production telemetry confirms that current SDK versions are recording + canonical interactions successfully across representative tap, navigation, + scroll, cancellation, and lifecycle cases. +4. Retained dual-write fixtures remain available to test historical replay + compatibility after the emitters are deleted. +5. Release notes announce the removal and the SDK schema/breaking version is + advanced deliberately. + ### Resolver and exploration events When exploration is active, the controller may emit: @@ -326,12 +366,17 @@ out to configured sinks. Sink failures are isolated from the host app. The session is bounded by `maxFrames` and `maxEvents`; trimming marks it `truncated`. -Emitted event types currently include: +**Inferred events** are derived from UI instrumentation. **Coded events** are +host-supplied analytics records via `TugboatReplay.eventHook` (see +[Coded events and network observation](#coded-events-and-network-observation)). + +Emitted inferred event types currently include: - canonical: `interaction` (`stream: semantic`) — one finalized gesture with immutable `origin`, `result`, `attribution`, and `evidenceEventIds`; -- legacy gesture peers (`stream: legacy_projection` when canonical is on): - `tap`, `tap_settled`, `swipe`, `tap_outside_tree`, `tap_gesture_resolved`; +- deprecated legacy gesture peers (emitted only when an integration explicitly + selects `dualWrite` or `legacyOnly`): `tap`, `tap_settled`, `swipe`, + `tap_outside_tree`, `tap_gesture_resolved`; - lifecycle: `session_start`, `session_identify`, `session_end`; - input: `pointer_cancel` (`stream: evidence`); - state/navigation evidence (`stream: evidence`): `state_change`, `route_change` @@ -343,8 +388,9 @@ Emitted event types currently include: - semantic-map modes: `viewport_semantic_map`, `scroll_semantic_snapshot`. -Default enrichment and insight selection should use `stream: semantic` -`interaction` records (`enrichmentCandidate: true` on collector payloads). +Default enrichment and insight selection should use inferred events: +`stream: semantic` `interaction` records (`enrichmentCandidate: true` on +collector payloads). Rage-tap style insights must count finalized `gesture=tap` interactions with no successful `navigated`/`changed` result; exclude scrolls, swipes, cancellations, evidence, legacy projections, and diagnostics. @@ -369,9 +415,10 @@ an `InteractionTransaction`. After pointer-up, settlement waits for either the first eligible visible successor inside `interactionClaimWindow` (default 1,250 ms) or the deadline. The canonical `interaction` event retains that frozen origin and attaches destination/result fields when a successor claims. -Legacy `tap` + `tap_settled` remain dual-written for migration; `tap_settled` -links via `relatedEventId` / `interactionId`. A missing attachment is explicit -in `frameAttachment`/settle diagnostics rather than a fallback to an unrelated +When deprecated dual-write compatibility is explicitly enabled, legacy `tap` + +`tap_settled` records link via `relatedEventId` / `interactionId`. They are not +additional semantic actions. A missing attachment is explicit in +`frameAttachment`/settle diagnostics rather than a fallback to an unrelated frame. During local WebSocket exploration, connecting without an HTTP collector @@ -425,7 +472,7 @@ Diagnostics contain only bounded correlation, outcome, route epoch, trigger, and evidence fields; they never include image bytes, labels, raw errors, or stack traces. `visualEvidence` distinguishes fresh, reused, and unavailable visual evidence, while `interactionEvidence` states whether the request links -to an interaction event. The closed outcome vocabulary is: +to an inferred event. The closed outcome vocabulary is: | Outcome | Meaning | | --- | --- | diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index e1c8f9f..8bbcfe8 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/widgets.dart'; @@ -751,7 +751,7 @@ class TugboatReplayController extends ChangeNotifier { _initialUserId = initialUserId, _initialUserIdOverride = initialUserIdOverride { _evidence = TugboatEvidenceRecorder( - appendEvidence: _appendEvidenceEvent, + appendEvidence: (event) => _addEvent(event, attachActionContext: false), nextEventId: _nextId, nowMs: () => atMs, profile: () => config.profile, @@ -777,7 +777,6 @@ class TugboatReplayController extends ChangeNotifier { Future _queue = Future.value(); int _queuedTaskCount = 0; Future? _endSessionFuture; - bool _endingSession = false; TugboatSession? _session; ScreenshotCapturer? _capturer; @@ -863,7 +862,14 @@ class TugboatReplayController extends ChangeNotifier { TugboatSession? get session => _session; bool get recording => _session != null; + + /// Whether the evidence recorder is open for this mounted controller. + /// + /// Host apps should gate on [TugboatReplay.isAcceptingEvidence] instead of + /// reading this directly — the facade also applies lifecycle admission. + @internal bool get acceptingEvidence => !_disposed && _evidence.accepting; + bool get scrolling => _scrollTrackers.isNotEmpty; bool get capturePaused => _capturePaused; int get atMs => _clock.elapsedMilliseconds; @@ -1343,12 +1349,15 @@ class TugboatReplayController extends ChangeNotifier { Future _endSession(String cancellationReason) { final active = _endSessionFuture; if (active != null) return active; - if (_endingSession) return Future.value(); if (_session == null) return Future.value(); - // Sink delivery is synchronous and may re-enter the controller. Fence - // evidence before publishing the terminal event. - _endingSession = true; + // Claim the end-session future before any sync sink work so re-entry sees + // a single in-flight end and cannot race a null future. + final done = Completer(); + _endSessionFuture = done.future; + + // Fence evidence before publishing the terminal event — sink delivery is + // synchronous and may re-enter the controller. _evidence.close(); _cancelActiveTapSettles(cancellationReason); @@ -1368,9 +1377,9 @@ class TugboatReplayController extends ChangeNotifier { stateAnchor: _currentStateAnchor, ), ); - final future = _sinkHub?.endSession() ?? Future.value(); - _endSessionFuture = future; - return future; + final sinkEnd = _sinkHub?.endSession() ?? Future.value(); + sinkEnd.then(done.complete, onError: done.completeError); + return done.future; } /// Pushes buffered capture output without closing the session. @@ -1388,7 +1397,6 @@ class TugboatReplayController extends ChangeNotifier { _invalidateCaptureWork('session_replacement'); _captureLifecycleActive = true; _captureLifecycleEpoch++; - _endingSession = false; _endSessionFuture = null; _clock ..reset() @@ -2319,7 +2327,6 @@ class TugboatReplayController extends ChangeNotifier { !_disposed && _session != null && _captureLifecycleActive && - !_endingSession && _endSessionFuture == null; void recordPointerDown(Offset position, {int pointer = 0}) { @@ -4304,41 +4311,50 @@ class TugboatReplayController extends ChangeNotifier { } } - void _addEvent(TugboatEvent event) { + /// Publishes one timeline event. + /// + /// When [attachActionContext] is true (default), stamps the active + /// exploration action window. Host app/network evidence passes false so it + /// never inherits [actionId] or interaction context. + void _addEvent(TugboatEvent event, {bool attachActionContext = true}) { final session = _session; if (session == null) return; - final enriched = event.withExplorationContext( - sessionId: session.id, - captureSessionId: session.id, - activationRequestId: session.activationRequestId ?? activationRequestId, - explorationRunId: _activeExplorationRunId ?? config.explorationRunId, - actionId: _activeActionId, - ); + final enriched = attachActionContext + ? event.withExplorationContext( + sessionId: session.id, + captureSessionId: session.id, + activationRequestId: + session.activationRequestId ?? activationRequestId, + explorationRunId: + _activeExplorationRunId ?? config.explorationRunId, + actionId: _activeActionId, + ) + : event.copyWith( + sessionId: event.sessionId ?? session.id, + captureSessionId: event.captureSessionId ?? session.id, + activationRequestId: + event.activationRequestId ?? + session.activationRequestId ?? + activationRequestId, + explorationRunId: + event.explorationRunId ?? session.explorationRunId, + ); session.events.add(enriched); _sinkHub?.recordEvent(enriched); _trim(); } - /// Session-stamped evidence that must never inherit action/interaction - /// context (active [actionId], related interaction, or anchors). - void _appendEvidenceEvent(TugboatEvent event) { - final session = _session; - if (session == null) return; - final enriched = event.copyWith( - sessionId: event.sessionId ?? session.id, - captureSessionId: event.captureSessionId ?? session.id, - activationRequestId: - event.activationRequestId ?? - session.activationRequestId ?? - activationRequestId, - explorationRunId: event.explorationRunId ?? session.explorationRunId, - ); - session.events.add(enriched); - _sinkHub?.recordEvent(enriched); - _trim(); - } + /// Same-turn fence for [TugboatReplay.deactivate] without full session end. + /// + /// The activation gate still owns `session_end` on teardown; this only stops + /// evidence admission for in-flight host callbacks. + @internal + void fenceEvidence() => _evidence.close(); /// Records one logical host app/analytics event onto the evidence stream. + /// + /// Prefer [TugboatReplay.eventHook] — it applies lifecycle admission. + @internal void recordExternalEvent({ required String name, String? source, @@ -4354,6 +4370,9 @@ class TugboatReplayController extends ChangeNotifier { } /// Begins observation of one logical network call. + /// + /// Prefer [TugboatReplay.beginNetworkCall] — it applies lifecycle admission. + @internal TugboatNetworkCall beginNetworkCall({required String method, String? route}) { return _evidence.beginNetworkCall(method: method, route: route); } diff --git a/packages/tugboat/lib/src/external_event.dart b/packages/tugboat/lib/src/external_event.dart index 780f8eb..7e5f02a 100644 --- a/packages/tugboat/lib/src/external_event.dart +++ b/packages/tugboat/lib/src/external_event.dart @@ -159,42 +159,36 @@ TugboatParameterSnapshot snapshotExternalParameters({ } keys.add(key); - final candidate = switch (policy.mode) { - TugboatParameterCaptureMode.namesOnly => _skipValue, - TugboatParameterCaptureMode.allowList => - (policy.allowedKeys?.contains(key) ?? false) ? entry.value : _dropValue, - TugboatParameterCaptureMode.transform => _applyTransform( - policy, - key, - entry.value, - ), - TugboatParameterCaptureMode.allowAll => entry.value, - }; - if (identical(candidate, _skipValue)) continue; - if (identical(candidate, _dropValue)) { - dropped += 1; - continue; - } - - final copied = _copyJsonSafe( - candidate, - depth: 1, - seen: {}, - dropped: (count) { - dropped += count; - truncated = true; - }, - onCollectionItem: () { - collectionItems += 1; - if (collectionItems > TugboatParameterLimits.maxCollectionItems) { - truncated = true; - return false; + switch (_decideTopLevelValue(policy, key, entry.value)) { + case _SkipValue(): + continue; + case _DropValue(): + dropped += 1; + continue; + case _KeepValue(:final value): + switch (_copyJsonSafe( + value, + depth: 1, + seen: {}, + dropped: (count) { + dropped += count; + truncated = true; + }, + onCollectionItem: () { + collectionItems += 1; + if (collectionItems > TugboatParameterLimits.maxCollectionItems) { + truncated = true; + return false; + } + return true; + }, + )) { + case _CopyUnsupported(): + continue; + case _CopyOk(:final value): + retained[key] = value; } - return true; - }, - ); - if (identical(copied, _unsupported)) continue; - retained[key] = copied; + } } Map? parametersOut; @@ -221,32 +215,75 @@ TugboatParameterSnapshot snapshotExternalParameters({ ); } -const _Sentinel _unsupported = _Sentinel('unsupported'); -const _Sentinel _skipValue = _Sentinel('skip'); -const _Sentinel _dropValue = _Sentinel('drop'); +sealed class _ValueDecision { + const _ValueDecision(); +} + +final class _SkipValue extends _ValueDecision { + const _SkipValue(); +} + +final class _DropValue extends _ValueDecision { + const _DropValue(); +} + +final class _KeepValue extends _ValueDecision { + const _KeepValue(this.value); + final Object? value; +} -class _Sentinel { - const _Sentinel(this.label); - final String label; +sealed class _CopyResult { + const _CopyResult(); } -Object? _applyTransform( +final class _CopyUnsupported extends _CopyResult { + const _CopyUnsupported(); +} + +final class _CopyOk extends _CopyResult { + const _CopyOk(this.value); + final Object? value; +} + +_ValueDecision _decideTopLevelValue( + TugboatParameterPolicy policy, + String key, + Object? value, +) { + return switch (policy.mode) { + TugboatParameterCaptureMode.namesOnly => const _SkipValue(), + TugboatParameterCaptureMode.allowList => + (policy.allowedKeys?.contains(key) ?? false) + ? _KeepValue(value) + : const _DropValue(), + TugboatParameterCaptureMode.transform => _applyTransform( + policy, + key, + value, + ), + TugboatParameterCaptureMode.allowAll => _KeepValue(value), + }; +} + +_ValueDecision _applyTransform( TugboatParameterPolicy policy, String key, Object? value, ) { final transform = policy.valueTransform; - if (transform == null) return _dropValue; + if (transform == null) return const _DropValue(); try { final candidate = transform(key, value); - if (identical(candidate, TugboatParameterPolicy.drop)) return _dropValue; - return candidate; + if (identical(candidate, TugboatParameterPolicy.drop)) { + return const _DropValue(); + } + return _KeepValue(candidate); } catch (_) { - return _dropValue; + return const _DropValue(); } } -Object? _copyJsonSafe( +_CopyResult _copyJsonSafe( Object? value, { required int depth, required Set seen, @@ -255,23 +292,25 @@ Object? _copyJsonSafe( }) { if (depth > TugboatParameterLimits.maxDepth) { dropped(1); - return _unsupported; + return const _CopyUnsupported(); } - if (value == null || value is bool) return value; + if (value == null || value is bool) return _CopyOk(value); if (value is num) { - if (value.isFinite) return value; + if (value.isFinite) return _CopyOk(value); dropped(1); - return _unsupported; + return const _CopyUnsupported(); } if (value is String) { - if (value.length <= TugboatParameterLimits.maxStringLength) return value; + if (value.length <= TugboatParameterLimits.maxStringLength) { + return _CopyOk(value); + } dropped(1); - return _unsupported; + return const _CopyUnsupported(); } if (value is Map) { if (!seen.add(value)) { dropped(1); - return _unsupported; + return const _CopyUnsupported(); } final out = {}; for (final entry in value.entries) { @@ -286,23 +325,26 @@ Object? _copyJsonSafe( dropped(1); break; } - final copied = _copyJsonSafe( + switch (_copyJsonSafe( entry.value, depth: depth + 1, seen: seen, dropped: dropped, onCollectionItem: onCollectionItem, - ); - if (identical(copied, _unsupported)) continue; - out[key] = copied; + )) { + case _CopyUnsupported(): + continue; + case _CopyOk(:final value): + out[key] = value; + } } seen.remove(value); - return out; + return _CopyOk(out); } if (value is Iterable) { if (!seen.add(value)) { dropped(1); - return _unsupported; + return const _CopyUnsupported(); } final out = []; for (final item in value) { @@ -310,21 +352,24 @@ Object? _copyJsonSafe( dropped(1); break; } - final copied = _copyJsonSafe( + switch (_copyJsonSafe( item, depth: depth + 1, seen: seen, dropped: dropped, onCollectionItem: onCollectionItem, - ); - if (identical(copied, _unsupported)) continue; - out.add(copied); + )) { + case _CopyUnsupported(): + continue; + case _CopyOk(:final value): + out.add(value); + } } seen.remove(value); - return out; + return _CopyOk(out); } dropped(1); - return _unsupported; + return const _CopyUnsupported(); } /// Host-facing callable for recording one logical app/analytics event. diff --git a/packages/tugboat/lib/src/models.dart b/packages/tugboat/lib/src/models.dart index e0560c3..bf066d8 100644 --- a/packages/tugboat/lib/src/models.dart +++ b/packages/tugboat/lib/src/models.dart @@ -50,13 +50,24 @@ enum TugboatEventStream { /// How finalized gestures are published to sinks. enum TugboatInteractionPublishMode { - /// Only legacy `tap` / `tap_settled` / `swipe` on the semantic stream. + // TODO(tugboat-legacy-projection-removal): Remove legacyOnly and dualWrite + // after supported collectors, Context Graph, dashboards, and retained replay + // fixtures no longer consume legacy gesture rows. See the SDK README's + // "Legacy gesture projection deprecation" section. + + /// Deprecated compatibility mode. + /// + /// Emits only legacy `tap` / `tap_settled` / `swipe` records on the semantic + /// stream. Do not use for new recordings. legacyOnly, - /// Canonical `interaction` plus legacy peers on [TugboatEventStream.legacyProjection]. + /// Deprecated migration mode. + /// + /// Emits the canonical `interaction` plus legacy peers on + /// [TugboatEventStream.legacyProjection]. Do not use for new recordings. dualWrite, - /// Canonical `interaction` only. + /// Canonical `interaction` only. This is the default for new recordings. canonicalOnly, } diff --git a/packages/tugboat/lib/src/replay_config.dart b/packages/tugboat/lib/src/replay_config.dart index 6bc7dde..1d7eb19 100644 --- a/packages/tugboat/lib/src/replay_config.dart +++ b/packages/tugboat/lib/src/replay_config.dart @@ -84,7 +84,7 @@ class TugboatReplayConfig { this.profile = TugboatCaptureProfile.dormant, this.settleDelay = const Duration(seconds: 1), this.interactionClaimWindow = tugboatDefaultReconciliationWindow, - this.interactionPublishMode = TugboatInteractionPublishMode.dualWrite, + this.interactionPublishMode = TugboatInteractionPublishMode.canonicalOnly, this.maxFrames = 500, this.maxEvents = 5000, this.scrollCaptureInterval = const Duration(seconds: 2), @@ -116,6 +116,11 @@ class TugboatReplayConfig { final Duration interactionClaimWindow; /// Canonical vs legacy gesture publication policy. + /// + /// New recordings default to [TugboatInteractionPublishMode.canonicalOnly] + /// so each finalized gesture produces one semantic `interaction` event. + /// The legacy modes are temporary read/migration compatibility options and + /// must not be enabled by new integrations. final TugboatInteractionPublishMode interactionPublishMode; bool get emitCanonicalInteractions => diff --git a/packages/tugboat/lib/src/tugboat.dart b/packages/tugboat/lib/src/tugboat.dart index db2ccac..cc3c188 100644 --- a/packages/tugboat/lib/src/tugboat.dart +++ b/packages/tugboat/lib/src/tugboat.dart @@ -186,9 +186,9 @@ class TugboatReplay { /// Returns the SDK to dormant mode without tearing down the host app. static void deactivate() { _lifecycle.deactivate(); - // Widget teardown happens on the next build. End now so same-turn calls - // cannot append evidence after deactivation was requested. - unawaited(_controller?.endSession()); + // Widget teardown (and session_end) happens on the next gate rebuild. + // Fence evidence now so same-turn host callbacks cannot append. + _controller?.fenceEvidence(); } /// Current sanitized health snapshot (empty when no controller). diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index ee18087..f51e9f4 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -347,21 +347,21 @@ void main() { testWidgets('session end rejects evidence during sink reentrancy', ( tester, ) async { - TugboatReplayController? activeController; final factory = _CallbackSinkFactory((event) { if (event.type != 'session_end') return; - final controller = activeController!; - controller.recordExternalEvent(name: 'AFTER_SESSION_END'); - controller - .beginNetworkCall(method: 'GET', route: '/after-end') - .complete(statusCode: 200); + // Host-facing APIs must no-op once evidence is fenced, even when the + // lifecycle has not yet moved to stopping. + TugboatReplay.eventHook().record('AFTER_SESSION_END'); + TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/after-end', + ).complete(statusCode: 200); }); await _pumpCapture( tester, config: _testConfig.copyWith(sinkFactories: [factory]), ); final controller = TugboatReplay.controller!; - activeController = controller; await controller.endSession(); @@ -370,6 +370,7 @@ void main() { expect(eventTypes, isNot(contains('external_event'))); expect(eventTypes, isNot(contains('network_call'))); expect(controller.acceptingEvidence, isFalse); + expect(TugboatReplay.isAcceptingEvidence, isFalse); }); testWidgets('empty route returns no-op without event', (tester) async { diff --git a/packages/tugboat/test/helpers/replay_coherence_harness.dart b/packages/tugboat/test/helpers/replay_coherence_harness.dart index 77ba828..745504a 100644 --- a/packages/tugboat/test/helpers/replay_coherence_harness.dart +++ b/packages/tugboat/test/helpers/replay_coherence_harness.dart @@ -214,6 +214,8 @@ class ReplayCoherenceHarness { /// reconciliation — pass that window explicitly when testing delayed /// attribution. this.interactionClaimWindow = Duration.zero, + // Keep deprecated projection behavior covered here even though production + // recordings now default to canonical-only publication. this.interactionPublishMode = TugboatInteractionPublishMode.dualWrite, this.maxFrames = 300, this.screenshotBudget = TugboatScreenshotBudgetConfig.defaults, diff --git a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index cd7640b..1159444 100644 --- a/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -256,9 +256,9 @@ void main() { expect(push.data['navigatorId'], isNotNull); expect(push.data['navigationOrigin'], isNotNull); - final taps = session.events.where((e) => e.type == 'tap'); - expect(taps, isNotEmpty); - expect(taps.first.data['captureCoordinate'], isA()); + final interactions = session.events.where((e) => e.type == 'interaction'); + expect(interactions, isNotEmpty); + expect(interactions.first.data['origin'], isA()); }, ); } diff --git a/packages/tugboat/test/replay/interaction_transaction_test.dart b/packages/tugboat/test/replay/interaction_transaction_test.dart index ef34103..40e080c 100644 --- a/packages/tugboat/test/replay/interaction_transaction_test.dart +++ b/packages/tugboat/test/replay/interaction_transaction_test.dart @@ -19,6 +19,80 @@ extension on TugboatSession { } void main() { + group('Interaction publication defaults', () { + test('new recordings emit canonical interactions only', () { + const config = TugboatReplayConfig(); + + expect( + config.interactionPublishMode, + TugboatInteractionPublishMode.canonicalOnly, + ); + expect(config.emitCanonicalInteractions, isTrue); + expect(config.emitLegacyInteractionProjection, isFalse); + }); + + test( + 'default controller recordings emit canonical interactions without legacy rows', + () async { + final harness = ReplayCoherenceHarness( + interactionPublishMode: + const TugboatReplayConfig().interactionPublishMode, + ); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(8, 8)); + harness.controller.recordPointerUp(const Offset(8, 8)); + await harness.flushScheduler(); + + final events = harness.controller.session!.events; + expect( + events.where((event) => event.type == 'interaction'), + isNotEmpty, + ); + expect( + events.where( + (event) => + event.type == 'tap' || + event.type == 'tap_settled' || + event.type == 'swipe', + ), + isEmpty, + ); + }, + ); + + test('legacy dual-write remains an explicit compatibility override', () { + const config = TugboatReplayConfig( + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, + ); + + expect(config.emitCanonicalInteractions, isTrue); + expect(config.emitLegacyInteractionProjection, isTrue); + expect(config.legacyGestureStream, TugboatEventStream.legacyProjection); + }); + + test('legacy-only recordings omit canonical interactions', () async { + final harness = ReplayCoherenceHarness( + interactionPublishMode: TugboatInteractionPublishMode.legacyOnly, + ); + await harness.setUp(); + addTearDown(harness.dispose); + + harness.controller.recordPointerDown(const Offset(8, 8)); + harness.controller.recordPointerUp(const Offset(8, 8)); + await harness.flushScheduler(); + + final events = harness.controller.session!.events; + expect(events.where((event) => event.type == 'interaction'), isEmpty); + expect(events.where((event) => event.type == 'tap'), hasLength(1)); + expect( + events.where((event) => event.type == 'tap_settled'), + hasLength(1), + ); + }); + }); + group('InteractionTransaction origin freeze (U1)', () { test( 'origin screen/component survive route mutation before pointer-up', diff --git a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart index f7f398d..1a3e658 100644 --- a/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_interaction_matrix_test.dart @@ -136,6 +136,7 @@ class _NavigationFixture { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart index 424a1e5..fa02e0f 100644 --- a/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_navigation_race_matrix_test.dart @@ -8,6 +8,7 @@ import '../helpers/replay_coherence_harness.dart'; const _config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index 0264ace..3b8b9c6 100644 --- a/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -190,6 +190,7 @@ class _OverlayFixture { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/packages/tugboat/test/replay/tap_coordinate_transform_test.dart b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart index bdc085a..59af9fc 100644 --- a/packages/tugboat/test/replay/tap_coordinate_transform_test.dart +++ b/packages/tugboat/test/replay/tap_coordinate_transform_test.dart @@ -17,6 +17,7 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/packages/tugboat/test/scene_inventory_test.dart b/packages/tugboat/test/scene_inventory_test.dart index b743ae7..43ba567 100644 --- a/packages/tugboat/test/scene_inventory_test.dart +++ b/packages/tugboat/test/scene_inventory_test.dart @@ -183,6 +183,7 @@ void main() { (tester) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -247,6 +248,7 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -305,6 +307,7 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -349,6 +352,7 @@ void main() { ) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -382,6 +386,7 @@ void main() { testWidgets('scene inventory event is deduped per state', (tester) async { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/packages/tugboat/test/scroll_attribution_test.dart b/packages/tugboat/test/scroll_attribution_test.dart index ca94e7f..da55139 100644 --- a/packages/tugboat/test/scroll_attribution_test.dart +++ b/packages/tugboat/test/scroll_attribution_test.dart @@ -4,6 +4,7 @@ import 'package:tugboat/tugboat.dart'; const _scrollTestConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/packages/tugboat/test/scroll_playground_live_test.dart b/packages/tugboat/test/scroll_playground_live_test.dart index eef501f..a7f9f8e 100644 --- a/packages/tugboat/test/scroll_playground_live_test.dart +++ b/packages/tugboat/test/scroll_playground_live_test.dart @@ -8,6 +8,7 @@ import 'package:tugboat/tugboat.dart'; void main() { const config = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index 3b8b085..c1ef16a 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -14,6 +14,7 @@ import 'helpers/json_roundtrip.dart'; const _testConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/packages/tugboat/test/viewport_semantic_map_test.dart b/packages/tugboat/test/viewport_semantic_map_test.dart index f39711b..d0d789f 100644 --- a/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/packages/tugboat/test/viewport_semantic_map_test.dart @@ -5,6 +5,7 @@ import 'package:tugboat/src/anchors.dart'; const _semanticMapConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -14,6 +15,7 @@ const _semanticMapConfig = TugboatReplayConfig( const _semanticMapConfigWithLogs = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -23,6 +25,7 @@ const _semanticMapConfigWithLogs = TugboatReplayConfig( const _scrollSemanticMapConfig = TugboatReplayConfig( profile: TugboatCaptureProfile.exploration, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -358,6 +361,7 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -409,6 +413,7 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -460,6 +465,7 @@ void main() { builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( profile: TugboatCaptureProfile.productionLean, + interactionPublishMode: TugboatInteractionPublishMode.dualWrite, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/pubspec.lock b/pubspec.lock index b76531a..3a13250 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -292,18 +292,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" melos: dependency: "direct dev" description: @@ -316,10 +316,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: transitive description: @@ -489,10 +489,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" typed_data: dependency: transitive description: @@ -566,5 +566,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.9.2 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" From 803dbe786b9e66484d8d41d465451c0b42cc396f Mon Sep 17 00:00:00 2001 From: Chinmay Kabi Date: Thu, 6 Aug 2026 19:22:17 +0530 Subject: [PATCH 7/7] feat: improve interaction and network evidence --- packages/tugboat/README.md | 13 ++ packages/tugboat/lib/src/controller.dart | 26 ++- .../tugboat/lib/src/evidence_recorder.dart | 18 +- .../lib/src/interaction_transaction.dart | 6 + .../tugboat/lib/src/network_observer.dart | 169 +++++++++++++++++- .../test/external_event_and_network_test.dart | 86 +++++++++ .../tugboat/test/tugboat_replay_test.dart | 76 ++++++++ packages/tugboat_dio/README.md | 9 +- .../lib/src/tugboat_dio_interceptor.dart | 19 +- .../test/tugboat_dio_interceptor_test.dart | 45 ++++- 10 files changed, 452 insertions(+), 15 deletions(-) diff --git a/packages/tugboat/README.md b/packages/tugboat/README.md index d19016f..55d364f 100644 --- a/packages/tugboat/README.md +++ b/packages/tugboat/README.md @@ -45,6 +45,16 @@ final call = TugboatReplay.beginNetworkCall( route: '/blend/:blendId', // host-supplied template only ); call.complete(statusCode: 200); + +// HTTP error bodies may be supplied as bounded JSON/text evidence. +final failedCall = TugboatReplay.beginNetworkCall( + method: 'POST', + route: '/projects', +); +failedCall.complete( + statusCode: 422, + errorResponseBody: {'code': 'invalid_project'}, +); ``` Both emit on `stream: evidence` and never inherit exploration `actionId` or UI @@ -54,6 +64,9 @@ downgrades it to names-only at record time. Network routes must be absolute path templates. The SDK drops resolver output containing a scheme, query, fragment, percent-encoded data, a network-path prefix, backslash, or whitespace/control characters; host resolvers must still replace dynamic IDs with placeholders. +HTTP response bodies are retained only when `statusCode >= 400`. JSON and text +are deep-copied and bounded to 16 KiB; binary and unsupported values are +omitted. Successful response bodies are never retained. Hooks resolve the active controller when `record` is called, rather than keeping a session reference. Network tokens are bound to the capture session in which diff --git a/packages/tugboat/lib/src/controller.dart b/packages/tugboat/lib/src/controller.dart index 8bbcfe8..2037aa2 100644 --- a/packages/tugboat/lib/src/controller.dart +++ b/packages/tugboat/lib/src/controller.dart @@ -1158,12 +1158,16 @@ class TugboatReplayController extends ChangeNotifier { required TugboatStateAnchor? afterState, required String? beforeFrame, required String? afterFrame, + TugboatTargetAnchor? targetAnchor, + bool causallyClaimed = false, }) { return _computeTapSettleResult( beforeState: beforeState, afterState: afterState, beforeFrame: beforeFrame, afterFrame: afterFrame, + targetAnchor: targetAnchor, + causallyClaimed: causallyClaimed, ); } @@ -2414,6 +2418,8 @@ class TugboatReplayController extends ChangeNotifier { startPosition: position, pointerGeneration: ++_pointerGeneration, captureSessionId: _session?.id, + explorationRunId: _activeExplorationRunId ?? config.explorationRunId, + actionId: _activeActionId, ); final tx = InteractionTransaction(origin: origin, pointerId: pointer); final legacyStream = config.legacyGestureStream; @@ -3041,6 +3047,8 @@ class TugboatReplayController extends ChangeNotifier { afterState: afterState, beforeFrame: beforeFrame, afterFrame: afterFrame, + targetAnchor: tapTargetAnchor, + causallyClaimed: pending.claimed, navigationOutcome: observation.navigationOutcome, degraded: observation.isDegraded, ); @@ -3225,6 +3233,8 @@ class TugboatReplayController extends ChangeNotifier { required TugboatStateAnchor? afterState, required String? beforeFrame, required String? afterFrame, + TugboatTargetAnchor? targetAnchor, + bool causallyClaimed = false, String navigationOutcome = 'same_route', bool degraded = false, }) { @@ -3237,6 +3247,14 @@ class TugboatReplayController extends ChangeNotifier { if (beforeSig.isNotEmpty && afterSig.isNotEmpty && beforeSig != afterSig) { return TugboatInteractionResult.changed; } + // Animated/loading surfaces can repaint independently of the pointer. If + // the resolved origin exposes no tap action, a pixel-only difference is + // ambient evidence and must not turn an empty-area tap into a successful + // interaction. Navigation and structural state changes still win above. + if (!causallyClaimed && + (targetAnchor == null || !targetAnchor.actions.contains('tap'))) { + return TugboatInteractionResult.noVisibleChange; + } if (_framesVisuallyDifferent(beforeFrame, afterFrame)) { return TugboatInteractionResult.changed; } @@ -3299,6 +3317,8 @@ class TugboatReplayController extends ChangeNotifier { ), 'evidenceEventIds': List.from(tx.evidenceEventIds), }, + explorationRunId: tx.origin.explorationRunId, + actionId: tx.origin.actionId, ), ); } @@ -4326,8 +4346,10 @@ class TugboatReplayController extends ChangeNotifier { activationRequestId: session.activationRequestId ?? activationRequestId, explorationRunId: - _activeExplorationRunId ?? config.explorationRunId, - actionId: _activeActionId, + event.explorationRunId ?? + _activeExplorationRunId ?? + config.explorationRunId, + actionId: event.actionId ?? _activeActionId, ) : event.copyWith( sessionId: event.sessionId ?? session.id, diff --git a/packages/tugboat/lib/src/evidence_recorder.dart b/packages/tugboat/lib/src/evidence_recorder.dart index cf2c0b8..a32d981 100644 --- a/packages/tugboat/lib/src/evidence_recorder.dart +++ b/packages/tugboat/lib/src/evidence_recorder.dart @@ -166,6 +166,7 @@ class TugboatEvidenceRecorder { required TugboatNetworkOutcome outcome, int? statusCode, int? attemptCount, + Object? errorResponseBody, }) { try { if (!accepting) { @@ -177,6 +178,9 @@ class TugboatEvidenceRecorder { return; } final durationMs = (nowMs() - startedAtMs).clamp(0, 24 * 60 * 60 * 1000); + final errorBody = statusCode != null && statusCode >= 400 + ? snapshotNetworkErrorResponseBody(errorResponseBody) + : null; appendEvidence( TugboatEvent( id: nextEventId('event'), @@ -191,6 +195,9 @@ class TugboatEvidenceRecorder { 'durationMs': durationMs, if (attemptCount != null && attemptCount > 0) 'attemptCount': attemptCount, + if (errorBody != null) 'errorResponseBody': errorBody.value, + if (errorBody != null) + 'errorResponseBodyCapture': errorBody.toCaptureMetadata(), }, ), ); @@ -235,11 +242,16 @@ class _ActiveNetworkCall implements TugboatNetworkCall { bool _finished = false; @override - void complete({int? statusCode, int? attemptCount}) { + void complete({ + int? statusCode, + int? attemptCount, + Object? errorResponseBody, + }) { _finish( outcome: TugboatNetworkOutcome.response, statusCode: statusCode, attemptCount: attemptCount, + errorResponseBody: errorResponseBody, ); } @@ -248,11 +260,13 @@ class _ActiveNetworkCall implements TugboatNetworkCall { required TugboatNetworkFailure failure, int? statusCode, int? attemptCount, + Object? errorResponseBody, }) { _finish( outcome: failure.outcome, statusCode: statusCode, attemptCount: attemptCount, + errorResponseBody: errorResponseBody, ); } @@ -260,6 +274,7 @@ class _ActiveNetworkCall implements TugboatNetworkCall { required TugboatNetworkOutcome outcome, int? statusCode, int? attemptCount, + Object? errorResponseBody, }) { if (_finished) { _recorder._noteDuplicateFinish(sessionId); @@ -274,6 +289,7 @@ class _ActiveNetworkCall implements TugboatNetworkCall { outcome: outcome, statusCode: statusCode, attemptCount: attemptCount, + errorResponseBody: errorResponseBody, ); } } diff --git a/packages/tugboat/lib/src/interaction_transaction.dart b/packages/tugboat/lib/src/interaction_transaction.dart index 34096a7..42e4b02 100644 --- a/packages/tugboat/lib/src/interaction_transaction.dart +++ b/packages/tugboat/lib/src/interaction_transaction.dart @@ -29,6 +29,8 @@ class InteractionOrigin { required this.startPosition, required this.pointerGeneration, required this.captureSessionId, + this.explorationRunId, + this.actionId, }); final String interactionId; @@ -43,6 +45,8 @@ class InteractionOrigin { final Offset startPosition; final int pointerGeneration; final String? captureSessionId; + final String? explorationRunId; + final String? actionId; Map toJson() => { 'interactionId': interactionId, @@ -57,6 +61,8 @@ class InteractionOrigin { 'startPosition': {'x': startPosition.dx, 'y': startPosition.dy}, 'pointerGeneration': pointerGeneration, if (captureSessionId != null) 'captureSessionId': captureSessionId, + if (explorationRunId != null) 'explorationRunId': explorationRunId, + if (actionId != null) 'actionId': actionId, }; } diff --git a/packages/tugboat/lib/src/network_observer.dart b/packages/tugboat/lib/src/network_observer.dart index 0801200..2c0b355 100644 --- a/packages/tugboat/lib/src/network_observer.dart +++ b/packages/tugboat/lib/src/network_observer.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + /// Closed vocabulary for a logical network call's terminal outcome. enum TugboatNetworkOutcome { response, @@ -26,6 +29,30 @@ enum TugboatNetworkFailure { abstract final class TugboatNetworkLimits { static const maxMethodLength = 16; static const maxRouteLength = 256; + static const maxErrorResponseBodyBytes = 16 * 1024; + static const maxErrorResponseBodyDepth = 6; + static const maxErrorResponseBodyCollectionItems = 128; +} + +/// Bounded snapshot of an HTTP error response body. +class TugboatNetworkErrorBodySnapshot { + const TugboatNetworkErrorBodySnapshot({ + required this.value, + required this.format, + required this.truncated, + this.representation = 'native', + }); + + final Object? value; + final String format; + final bool truncated; + final String representation; + + Map toCaptureMetadata() => { + 'format': format, + 'representation': representation, + 'truncated': truncated, + }; } /// Exactly-once observation token for one logical HTTP request. @@ -33,12 +60,17 @@ abstract final class TugboatNetworkLimits { /// Adapters call [complete] or [fail] when response headers/status are /// available. Further terminal calls are no-ops. abstract interface class TugboatNetworkCall { - void complete({int? statusCode, int? attemptCount}); + void complete({ + int? statusCode, + int? attemptCount, + Object? errorResponseBody, + }); void fail({ required TugboatNetworkFailure failure, int? statusCode, int? attemptCount, + Object? errorResponseBody, }); } @@ -47,16 +79,149 @@ class TugboatNoOpNetworkCall implements TugboatNetworkCall { const TugboatNoOpNetworkCall(); @override - void complete({int? statusCode, int? attemptCount}) {} + void complete({ + int? statusCode, + int? attemptCount, + Object? errorResponseBody, + }) {} @override void fail({ required TugboatNetworkFailure failure, int? statusCode, int? attemptCount, + Object? errorResponseBody, }) {} } +/// Returns a deep-copied, bounded JSON/text body for HTTP error responses. +/// +/// Binary and unsupported bodies are deliberately omitted. Oversized JSON is +/// retained as a bounded serialized prefix with explicit capture metadata. +TugboatNetworkErrorBodySnapshot? snapshotNetworkErrorResponseBody(Object? raw) { + if (raw == null || + raw is ByteBuffer || + raw is TypedData || + raw is List) { + return null; + } + + if (raw is String) { + final bounded = _boundedUtf8Prefix( + raw, + TugboatNetworkLimits.maxErrorResponseBodyBytes, + ); + return TugboatNetworkErrorBodySnapshot( + value: bounded.value, + format: 'text', + truncated: bounded.truncated, + ); + } + + final normalized = _copyJsonValue(raw, depth: 0); + if (!normalized.supported) return null; + final encoded = jsonEncode(normalized.value); + final encodedBytes = utf8.encode(encoded); + if (encodedBytes.length <= TugboatNetworkLimits.maxErrorResponseBodyBytes) { + return TugboatNetworkErrorBodySnapshot( + value: normalized.value, + format: 'json', + truncated: normalized.truncated, + ); + } + + final bounded = _boundedUtf8Prefix( + encoded, + TugboatNetworkLimits.maxErrorResponseBodyBytes, + ); + return TugboatNetworkErrorBodySnapshot( + value: bounded.value, + format: 'json', + representation: 'serialized_prefix', + truncated: true, + ); +} + +({String value, bool truncated}) _boundedUtf8Prefix( + String value, + int maxBytes, +) { + if (utf8.encode(value).length <= maxBytes) { + return (value: value, truncated: false); + } + final buffer = StringBuffer(); + var usedBytes = 0; + for (final rune in value.runes) { + final runeValue = String.fromCharCode(rune); + final runeBytes = utf8.encode(runeValue).length; + if (usedBytes + runeBytes > maxBytes) break; + buffer.write(runeValue); + usedBytes += runeBytes; + } + return (value: buffer.toString(), truncated: true); +} + +({Object? value, bool supported, bool truncated}) _copyJsonValue( + Object? value, { + required int depth, +}) { + if (value == null || value is bool || value is String) { + return (value: value, supported: true, truncated: false); + } + if (value is num) { + if (value is double && !value.isFinite) { + return (value: null, supported: false, truncated: true); + } + return (value: value, supported: true, truncated: false); + } + if (depth >= TugboatNetworkLimits.maxErrorResponseBodyDepth) { + return (value: null, supported: false, truncated: true); + } + if (value is List) { + final copied = []; + var truncated = + value.length > TugboatNetworkLimits.maxErrorResponseBodyCollectionItems; + for (final item in value.take( + TugboatNetworkLimits.maxErrorResponseBodyCollectionItems, + )) { + final normalized = _copyJsonValue(item, depth: depth + 1); + if (!normalized.supported) { + truncated = true; + continue; + } + copied.add(normalized.value); + truncated = truncated || normalized.truncated; + } + return (value: copied, supported: true, truncated: truncated); + } + if (value is Map) { + final copied = {}; + var truncated = + value.length > TugboatNetworkLimits.maxErrorResponseBodyCollectionItems; + var visited = 0; + for (final entry in value.entries) { + if (visited >= TugboatNetworkLimits.maxErrorResponseBodyCollectionItems) { + truncated = true; + break; + } + visited += 1; + if (entry.key is! String) { + truncated = true; + continue; + } + final normalized = _copyJsonValue(entry.value, depth: depth + 1); + if (!normalized.supported) { + truncated = true; + continue; + } + copied[entry.key as String] = normalized.value; + truncated = truncated || normalized.truncated; + } + return (value: copied, supported: true, truncated: truncated); + } + return (value: null, supported: false, truncated: true); +} + String? normalizeNetworkMethod(String method) { final trimmed = method.trim().toUpperCase(); if (trimmed.isEmpty) return null; diff --git a/packages/tugboat/test/external_event_and_network_test.dart b/packages/tugboat/test/external_event_and_network_test.dart index f51e9f4..2d4bc9b 100644 --- a/packages/tugboat/test/external_event_and_network_test.dart +++ b/packages/tugboat/test/external_event_and_network_test.dart @@ -290,6 +290,92 @@ void main() { ); }); + testWidgets('network call retains a copied HTTP error response body', ( + tester, + ) async { + await _pumpCapture(tester); + final body = { + 'code': 'invalid_project', + 'details': ['missing_name'], + }; + + TugboatReplay.beginNetworkCall( + method: 'POST', + route: '/projects', + ).complete(statusCode: 422, errorResponseBody: body); + body['code'] = 'mutated'; + (body['details'] as List).add('mutated'); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'network_call', + ); + expect(event.data['errorResponseBody'], { + 'code': 'invalid_project', + 'details': ['missing_name'], + }); + expect(event.data['errorResponseBodyCapture'], { + 'format': 'json', + 'representation': 'native', + 'truncated': false, + }); + }); + + testWidgets('network call never retains a successful response body', ( + tester, + ) async { + await _pumpCapture(tester); + + TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/projects', + ).complete(statusCode: 200, errorResponseBody: {'secret': 'success-body'}); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'network_call', + ); + expect(event.data.containsKey('errorResponseBody'), isFalse); + expect(event.data.toString(), isNot(contains('success-body'))); + }); + + testWidgets('network error response text is bounded', (tester) async { + await _pumpCapture(tester); + + TugboatReplay.beginNetworkCall(method: 'GET', route: '/projects').complete( + statusCode: 500, + errorResponseBody: + 'x' * (TugboatNetworkLimits.maxErrorResponseBodyBytes + 100), + ); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'network_call', + ); + expect( + (event.data['errorResponseBody'] as String).length, + TugboatNetworkLimits.maxErrorResponseBodyBytes, + ); + expect(event.data['errorResponseBodyCapture'], { + 'format': 'text', + 'representation': 'native', + 'truncated': true, + }); + }); + + testWidgets('network call omits binary error response bodies', ( + tester, + ) async { + await _pumpCapture(tester); + + TugboatReplay.beginNetworkCall( + method: 'GET', + route: '/download', + ).complete(statusCode: 500, errorResponseBody: [0, 1, 2, 3]); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'network_call', + ); + expect(event.data.containsKey('errorResponseBody'), isFalse); + }); + testWidgets('network token cannot finish into a replacement session', ( tester, ) async { diff --git a/packages/tugboat/test/tugboat_replay_test.dart b/packages/tugboat/test/tugboat_replay_test.dart index c1ef16a..abcccc6 100644 --- a/packages/tugboat/test/tugboat_replay_test.dart +++ b/packages/tugboat/test/tugboat_replay_test.dart @@ -782,6 +782,40 @@ void main() { ); }); + testWidgets('canonical interaction keeps its pointer-down action window', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: Scaffold( + body: FilledButton(onPressed: () {}, child: const Text('Act')), + ), + ), + ); + await _waitForCaptures(tester); + + final controller = TugboatReplay.controller!; + controller.setExplorationActionWindow( + explorationRunId: 'run-1', + actionId: 'A-origin', + ); + await tester.tap(find.text('Act')); + controller.setExplorationActionWindow( + explorationRunId: 'run-1', + actionId: 'A-next', + ); + await _waitForCaptures(tester); + + final interaction = controller.session!.events.singleWhere( + (event) => event.type == 'interaction', + ); + expect(interaction.actionId, 'A-origin'); + expect(interaction.explorationRunId, 'run-1'); + expect((interaction.data['origin'] as Map)['actionId'], 'A-origin'); + }); + testWidgets('does not record icon or tooltip labels on icon button taps', ( tester, ) async { @@ -1475,6 +1509,7 @@ void main() { afterState: const TugboatStateAnchor(signature: 'sig-after'), beforeFrame: 'frame-1', afterFrame: 'frame-1', + targetAnchor: const TugboatTargetAnchor(actions: ['tap']), ); expect(result, TugboatInteractionResult.changed); controller.dispose(); @@ -1492,6 +1527,7 @@ void main() { afterState: const TugboatStateAnchor(signature: 'route-sig'), beforeFrame: 'frame-1', afterFrame: 'frame-1', + targetAnchor: const TugboatTargetAnchor(actions: ['tap']), ); expect(result, TugboatInteractionResult.changed); controller.dispose(); @@ -1509,11 +1545,51 @@ void main() { afterState: const TugboatStateAnchor(signature: 'same-sig'), beforeFrame: null, afterFrame: 'frame-without-evidence', + targetAnchor: const TugboatTargetAnchor(actions: ['tap']), ); expect(result, TugboatInteractionResult.unknown); controller.dispose(); }); + test('tap_settled ignores ambient frame changes on non-tappable targets', () { + final rootKey = GlobalKey(); + final controller = TugboatReplayController( + config: _testConfig, + boundaryKey: rootKey, + ); + controller.start(const Size(100, 100), 'test'); + controller.session!.frames.addAll(const [ + TugboatFrame( + id: 'frame-before', + atMs: 1, + width: 100, + height: 100, + contentHash: 'before-hash', + ), + TugboatFrame( + id: 'frame-after', + atMs: 2, + width: 100, + height: 100, + contentHash: 'after-hash', + ), + ]); + + final result = controller.debugComputeTapSettleResult( + beforeState: const TugboatStateAnchor(signature: 'same-sig'), + afterState: const TugboatStateAnchor(signature: 'same-sig'), + beforeFrame: 'frame-before', + afterFrame: 'frame-after', + targetAnchor: const TugboatTargetAnchor( + role: 'scrollable', + actions: ['scroll'], + ), + ); + + expect(result, TugboatInteractionResult.noVisibleChange); + controller.dispose(); + }); + test('a throwing queued task does not poison later tap settles', () async { final rootKey = GlobalKey(); final controller = TugboatReplayController( diff --git a/packages/tugboat_dio/README.md b/packages/tugboat_dio/README.md index 9fe5fa1..1c44e51 100644 --- a/packages/tugboat_dio/README.md +++ b/packages/tugboat_dio/README.md @@ -1,8 +1,9 @@ # tugboat_dio Dio adapter for Tugboat network evidence. Records method, safe route template, -status, outcome, and duration into an active Tugboat session. Never captures -headers, queries, bodies, raw errors, or stack traces. +status, outcome, and duration into an active Tugboat session. HTTP error +responses additionally retain bounded JSON/text bodies. Successful response +bodies, headers, queries, raw transport errors, and stack traces are omitted. Requires `tugboat` `0.6.0` (lockstep). @@ -69,7 +70,9 @@ checked again before any state is attached. - Route templates only — no scheme, host, port, query, or fragment - Invalid route outputs are dropped before a call is started -- No request/response bodies, headers, or cookies +- No request bodies, successful response bodies, headers, or cookies +- HTTP status `>= 400`: JSON/text response body only, deep-copied and bounded + to 16 KiB; binary and unsupported bodies are omitted - No raw `DioException` messages or stack traces - Dormant/disabled/deactivating/ended Tugboat → resolver not called, networking unchanged, no events diff --git a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart index a9f2b16..d072369 100644 --- a/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart +++ b/packages/tugboat_dio/lib/src/tugboat_dio_interceptor.dart @@ -14,8 +14,9 @@ typedef TugboatDioRouteResolver = String? Function(RequestOptions request); /// finishes the token. Prefer [install], which appends and rejects duplicate /// installation on the same [Dio] instance. /// -/// Never inspects request/response bodies, headers, cookies, query parameters, -/// or raw error text. +/// Never inspects request bodies, successful response bodies, headers, cookies, +/// query parameters, or raw transport error text. Bounded JSON/text response +/// bodies are retained only for HTTP error statuses. class TugboatDioInterceptor extends Interceptor { TugboatDioInterceptor({required this.routeResolver}); @@ -56,9 +57,11 @@ class TugboatDioInterceptor extends Interceptor { TugboatNetworkCall? call; try { call = _tokenOf(options); + final statusCode = response.statusCode; call?.complete( - statusCode: response.statusCode, + statusCode: statusCode, attemptCount: _attemptCount(options), + errorResponseBody: _isHttpError(statusCode) ? response.data : null, ); } catch (_) { } finally { @@ -84,8 +87,12 @@ class TugboatDioInterceptor extends Interceptor { ); } else if (err.response != null) { // Logical HTTP response was available; retain status without error - // text. - call.complete(statusCode: statusCode, attemptCount: attempts); + // text, but include its bounded HTTP error response body. + call.complete( + statusCode: statusCode, + attemptCount: attempts, + errorResponseBody: err.response?.data, + ); } else { call.fail( failure: TugboatNetworkFailure.networkError, @@ -101,6 +108,8 @@ class TugboatDioInterceptor extends Interceptor { handler.next(err); } + bool _isHttpError(int? statusCode) => statusCode != null && statusCode >= 400; + void _ensureToken(RequestOptions options) { if (!TugboatReplay.isAcceptingEvidence) return; diff --git a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart index a2389ef..5f5b992 100644 --- a/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart +++ b/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -90,9 +90,12 @@ void main() { expect(data.toString().contains('raw-id'), isFalse); expect(data.toString().contains('example.test'), isFalse); expect(data.containsKey('headers'), isFalse); + expect(data.containsKey('errorResponseBody'), isFalse); }); - testWidgets('bad response retains status without raw error', (tester) async { + testWidgets('bad response retains status and bounded response body', ( + tester, + ) async { await _pumpCapture(tester); final dio = Dio(); dio.httpClientAdapter = _ScriptedAdapter( @@ -114,7 +117,12 @@ void main() { ); expect(event.data['statusCode'], 503); expect(event.data['outcome'], 'response'); - expect(event.data.toString().contains('secret-body'), isFalse); + expect(event.data['errorResponseBody'], 'secret-body'); + expect(event.data['errorResponseBodyCapture'], { + 'format': 'text', + 'representation': 'native', + 'truncated': false, + }); }); testWidgets('transport error emits network_error', (tester) async { @@ -214,6 +222,39 @@ void main() { expect(events.single.data['statusCode'], 200); expect(events.single.data['outcome'], 'response'); expect(events.single.data['attemptCount'], 2); + expect(events.single.data.containsKey('errorResponseBody'), isFalse); + }); + + testWidgets('accepted HTTP error still retains its response body', ( + tester, + ) async { + await _pumpCapture(tester); + final dio = Dio( + BaseOptions(validateStatus: (status) => status != null && status < 600), + ); + dio.httpClientAdapter = _ScriptedAdapter( + (_) async => ResponseBody.fromString( + '{"code":"overloaded"}', + 503, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ), + ); + TugboatDioInterceptor.install(dio, routeResolver: (_) => '/health'); + + final response = await _runAsync(tester, () => dio.get('/health')); + expect(response.statusCode, 503); + + final event = TugboatReplay.controller!.session!.events.singleWhere( + (event) => event.type == 'network_call', + ); + expect(event.data['errorResponseBody'], {'code': 'overloaded'}); + expect(event.data['errorResponseBodyCapture'], { + 'format': 'json', + 'representation': 'native', + 'truncated': false, + }); }); testWidgets('unmatched route drops without event', (tester) async {