Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/tugboat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
## 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.

### 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
Expand Down
45 changes: 44 additions & 1 deletion packages/tugboat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -19,6 +19,49 @@ 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; 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

This is a breaking release. Session JSON written by 0.5.0 uses schema version
Expand Down
2 changes: 1 addition & 1 deletion packages/tugboat/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 63 additions & 1 deletion packages/tugboat/lib/src/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ 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';
import 'models.dart';
import 'network_observer.dart';
import 'outbox/outbox.dart';
import 'outbox/outbox_sink.dart';
import 'replay_config.dart';
Expand Down Expand Up @@ -736,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;
Expand All @@ -751,6 +762,7 @@ class TugboatReplayController extends ChangeNotifier {
Future<void> _queue = Future.value();
int _queuedTaskCount = 0;
Future<void>? _endSessionFuture;
bool _endingSession = false;

TugboatSession? _session;
ScreenshotCapturer? _capturer;
Expand Down Expand Up @@ -836,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;
Expand Down Expand Up @@ -1245,6 +1258,7 @@ class TugboatReplayController extends ChangeNotifier {
lastOutcome: _lastCaptureDiagnosticOutcome,
outcomes: Map.unmodifiable(_captureDiagnosticOutcomes),
),
evidence: _evidence.healthSnapshot(),
truncated: _session?.truncated ?? false,
recentFailures: List.unmodifiable(_recentFailures),
);
Expand Down Expand Up @@ -1278,8 +1292,14 @@ class TugboatReplayController extends ChangeNotifier {
Future<void> _endSession(String cancellationReason) {
final active = _endSessionFuture;
if (active != null) return active;
if (_endingSession) return Future<void>.value();
if (_session == null) return Future<void>.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);
Expand Down Expand Up @@ -1317,6 +1337,7 @@ class TugboatReplayController extends ChangeNotifier {
_invalidateCaptureWork('session_replacement');
_captureLifecycleActive = true;
_captureLifecycleEpoch++;
_endingSession = false;
_endSessionFuture = null;
_clock
..reset()
Expand Down Expand Up @@ -1354,6 +1375,7 @@ class TugboatReplayController extends ChangeNotifier {
_captureDiagnosticOutcomes.clear();
_captureDiagnosticTotal = 0;
_lastCaptureDiagnosticOutcome = null;
_evidence.bindSession(_session!);
if (!_disposed) notifyListeners();

final context = TugboatSinkSessionContext(
Expand Down Expand Up @@ -2246,6 +2268,7 @@ class TugboatReplayController extends ChangeNotifier {
!_disposed &&
_session != null &&
_captureLifecycleActive &&
!_endingSession &&
_endSessionFuture == null;

void recordPointerDown(Offset position, {int pointer = 0}) {
Expand Down Expand Up @@ -4245,6 +4268,45 @@ class TugboatReplayController extends ChangeNotifier {
_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();
}

/// Records one logical host app/analytics event onto the evidence stream.
void recordExternalEvent({
required String name,
String? source,
Map<String, Object?>? parameters,
TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.namesOnly,
}) {
_evidence.recordExternalEvent(
name: name,
source: source,
parameters: parameters,
parameterPolicy: parameterPolicy,
);
}

/// Begins observation of one logical network call.
TugboatNetworkCall beginNetworkCall({required String method, String? route}) {
return _evidence.beginNetworkCall(method: method, route: route);
}

void setExplorationActionWindow({
required String explorationRunId,
required String actionId,
Expand Down
Loading
Loading