From 26d9832643b4e84d30815617778c01a8e21bf40c Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 20 Aug 2025 13:56:02 +0200 Subject: [PATCH 1/8] improved call stats --- dogfooding/lib/screens/call_stats_screen.dart | 113 +++++++++++++--- .../lib/screens/stats_battery_chart.dart | 118 +++++++++++++++++ .../lib/screens/stats_thermal_chart.dart | 88 +++++++++++++ packages/stream_video/CHANGELOG.md | 17 ++- packages/stream_video/lib/src/call/call.dart | 76 +++++++---- .../lib/src/call/session/call_session.dart | 37 ++++-- .../state/mixins/state_lifecycle_mixin.dart | 20 --- .../lib/src/call/stats/stats_reporter.dart | 122 ++++++++++++------ packages/stream_video/lib/src/call_state.dart | 24 ---- .../lib/src/models/call_stats.dart | 66 ++++++++-- .../call_diagnostics_content.dart | 19 +-- 11 files changed, 546 insertions(+), 154 deletions(-) create mode 100644 dogfooding/lib/screens/stats_battery_chart.dart create mode 100644 dogfooding/lib/screens/stats_thermal_chart.dart diff --git a/dogfooding/lib/screens/call_stats_screen.dart b/dogfooding/lib/screens/call_stats_screen.dart index 08d900ab3..29f3ec338 100644 --- a/dogfooding/lib/screens/call_stats_screen.dart +++ b/dogfooding/lib/screens/call_stats_screen.dart @@ -1,7 +1,11 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_dogfooding/app/user_auth_controller.dart'; import 'package:flutter_dogfooding/di/injector.dart'; import 'package:flutter_dogfooding/screens/stats_latency_chart.dart'; +import 'package:flutter_dogfooding/screens/stats_battery_chart.dart'; +import 'package:flutter_dogfooding/screens/stats_thermal_chart.dart'; import 'package:flutter_dogfooding/theme/app_palette.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; @@ -21,12 +25,23 @@ class CallStatsScreen extends StatelessWidget { final textTheme = streamVideoTheme.textTheme; final currentUser = _userAuthController.currentUser; - return StreamBuilder( - stream: call.state.asStream(), + return StreamBuilder( + stream: call.statsReporter?.stream, + initialData: call.statsReporter?.currentMetrics, builder: (context, snapshot) { final state = snapshot.data; - final subscriberBitrate = state?.subscriberStats?.bitrateKbps; - final publisherBitrate = state?.publisherStats?.bitrateKbps; + + if (state == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final subscriberBitrate = state.subscriber?.bitrateKbps; + final publisherBitrate = state.publisher?.bitrateKbps; + + final batteryDrained = (state.initialBatteryLevel ?? 0) - + (state.batteryLevelHistory.lastOrNull ?? 0); return SafeArea( top: false, @@ -93,9 +108,75 @@ class CallStatsScreen extends StatelessWidget { SizedBox( height: 200, child: StatsLatencyChart( - latencyHistory: state!.latencyHistory, + latencyHistory: state.latencyHistory, + ), + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.whatshot, color: Colors.white), + const SizedBox(width: 8), + Text( + 'Thermal state', + style: + textTheme.title3.apply(color: Colors.white), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Device thermal state history. Higher bars indicate more severe states.', + style: TextStyle(color: Colors.white), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: StatsThermalChart( + thermalSeverityHistory: state.thermalStatusHistory, + ), + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.battery_full, color: Colors.white), + const SizedBox(width: 8), + Text( + 'Battery level', + style: + textTheme.title3.apply(color: Colors.white), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + 'Track device battery level throughout the call.', + style: TextStyle(color: Colors.white), ), ), + const SizedBox(height: 16), + SizedBox( + height: 200, + child: StatsBatteryChart( + batteryLevelHistory: state.batteryLevelHistory, + ), + ), + Text( + 'Battery percentage consumed during call: $batteryDrained%', + style: const TextStyle(color: Colors.white), + ), const SizedBox( height: 16, ), @@ -128,7 +209,7 @@ class CallStatsScreen extends StatelessWidget { Expanded( child: LatencyOrJitterItem( title: 'Latency', - value: state.publisherStats?.latency ?? 0, + value: state.publisher?.latency ?? 0, ), ), ], @@ -138,13 +219,13 @@ class CallStatsScreen extends StatelessWidget { Expanded( child: LatencyOrJitterItem( title: 'Receive jitter', - value: state.subscriberStats?.jitterInMs, + value: state.subscriber?.jitterInMs, ), ), Expanded( child: LatencyOrJitterItem( title: 'Publish jitter', - value: state.publisherStats?.jitterInMs, + value: state.publisher?.jitterInMs, ), ), ], @@ -156,7 +237,7 @@ class CallStatsScreen extends StatelessWidget { title: 'Publish bitrate', value: publisherBitrate == null ? '--' - : '${state.publisherStats?.bitrateKbps} Kbps', + : '${state.publisher?.bitrateKbps} Kbps', ), ), Expanded( @@ -164,7 +245,7 @@ class CallStatsScreen extends StatelessWidget { title: 'Receive bitrate', value: subscriberBitrate == null ? '--' - : '${state.subscriberStats?.bitrateKbps} Kbps', + : '${state.subscriber?.bitrateKbps} Kbps', ), ), ], @@ -175,29 +256,29 @@ class CallStatsScreen extends StatelessWidget { child: StatsItem( title: 'Publish resolution', value: - "${state.publisherStats?.resolution} | ${state.publisherStats?.videoCodec?.join('+')}", + "${state.publisher?.resolution} | ${state.publisher?.videoCodec?.join('+')}", ), ), Expanded( child: StatsItem( - title: 'Reveive resolution', + title: 'Receive resolution', value: - "${state.subscriberStats?.resolution} | ${state.subscriberStats?.videoCodec?.join('+')}", + "${state.subscriber?.resolution} | ${state.subscriber?.videoCodec?.join('+')}", ), ), ], ), StatsItem( title: 'Region', - value: state.localStats?.sfu, + value: state.clientEnvironment.sfu, ), StatsItem( title: 'SDK Version', - value: state.localStats?.sdkVersion, + value: state.clientEnvironment.sdkVersion, ), StatsItem( title: 'WebRTC Version', - value: state.localStats?.webRtcVersion, + value: state.clientEnvironment.webRtcVersion, ), ] ], diff --git a/dogfooding/lib/screens/stats_battery_chart.dart b/dogfooding/lib/screens/stats_battery_chart.dart new file mode 100644 index 000000000..df282e440 --- /dev/null +++ b/dogfooding/lib/screens/stats_battery_chart.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter_dogfooding/theme/app_palette.dart'; + +class StatsBatteryChart extends StatelessWidget { + const StatsBatteryChart({super.key, required this.batteryLevelHistory}); + + final List batteryLevelHistory; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: LineChart( + LineChartData( + lineTouchData: const LineTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + verticalInterval: 1, + horizontalInterval: 20, + getDrawingVerticalLine: (value) { + return const FlLine( + color: Color(0xff37434d), + strokeWidth: 1, + ); + }, + getDrawingHorizontalLine: (value) { + return const FlLine( + color: Color(0xff37434d), + strokeWidth: 1, + ); + }, + ), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 20, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData( + show: false, + ), + minX: 0, + maxX: 20, + minY: 0, + maxY: 100, + lineBarsData: [ + LineChartBarData( + spots: batteryLevelHistory.indexed + .map( + (m) => FlSpot(m.$1.toDouble(), m.$2.toDouble()), + ) + .toList(), + isCurved: true, + gradient: LinearGradient( + colors: [ + ColorTween( + begin: AppColorPalette.appGreen, + // ignore: deprecated_member_use + end: AppColorPalette.appGreen.withOpacity(0.5)) + .lerp(0.2)!, + ColorTween( + begin: AppColorPalette.appGreen, + // ignore: deprecated_member_use + end: AppColorPalette.appGreen.withOpacity(0.5)) + .lerp(0.2)!, + ], + ), + barWidth: 3, + isStrokeCapRound: true, + dotData: const FlDotData( + show: false, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + ColorTween( + begin: AppColorPalette.appGreen, + end: + // ignore: deprecated_member_use + AppColorPalette.appGreen.withOpacity(0.5)) + .lerp(0.2)! + // ignore: deprecated_member_use + .withOpacity(0.1), + ColorTween( + begin: AppColorPalette.appGreen, + end: + // ignore: deprecated_member_use + AppColorPalette.appGreen.withOpacity(0.5)) + .lerp(0.2)! + // ignore: deprecated_member_use + .withOpacity(0.1), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/dogfooding/lib/screens/stats_thermal_chart.dart b/dogfooding/lib/screens/stats_thermal_chart.dart new file mode 100644 index 000000000..d565afe78 --- /dev/null +++ b/dogfooding/lib/screens/stats_thermal_chart.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter_dogfooding/theme/app_palette.dart'; + +class StatsThermalChart extends StatelessWidget { + const StatsThermalChart({super.key, required this.thermalSeverityHistory}); + + /// Severity scale expected in ascending order (0 = none ... N = most severe). + final List thermalSeverityHistory; + + static const int _maxSeverity = + 6; // none, light, moderate, severe, critical, emergency, shutdown + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: BarChart( + BarChartData( + alignment: BarChartAlignment.start, + barTouchData: BarTouchData(enabled: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + drawHorizontalLine: true, + horizontalInterval: 1, + getDrawingHorizontalLine: (value) => const FlLine( + color: Color(0xff37434d), + strokeWidth: 1, + ), + ), + titlesData: const FlTitlesData( + show: true, + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 1, + ), + ), + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + minY: 0, + maxY: _maxSeverity.toDouble() + 1, + barGroups: thermalSeverityHistory.indexed + .map( + (entry) => BarChartGroupData( + x: entry.$1, + barRods: [ + BarChartRodData( + toY: entry.$2 + .toDouble() + .clamp(0, _maxSeverity) + .toDouble() + + 1, + width: 10, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: Color.lerp( + AppColorPalette.appGreen, + AppColorPalette.appRed, + (entry.$2.clamp(0, _maxSeverity)) / _maxSeverity, + ), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: _maxSeverity.toDouble(), + color: Colors.white.withOpacity(0.04), + ), + ), + ], + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 1c7f41214..bf94b3739 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -2,9 +2,24 @@ 🐞 Fixed * Handled SFU stats reporting failures gracefully -* + +🚧 Breaking changes +* `Call.stats` payload changed. It now emits + `({ PeerConnectionStatsBundle publisherStatsBundle, PeerConnectionStatsBundle subscriberStatsBundle })` + instead of the previous + `({ CallStats publisherStats, CallStats subscriberStats })`. + - The record field names have changed and the element types are different. +* Stats-related fields were removed from `CallState` (e.g., `publisherStats`, `subscriberStats`, `latencyHistory`). + - Use `call.stats` for periodic WebRTC stats updates, or + - Access `call.statsReporter?.currentMetrics` for the latest aggregated metrics instead. + ✅ Added * Added option to configure android audio configuration when initializing `StreamVideo` instance by providing `androidAudioConfiguration` to `StreamVideoOptions`. +* `StatsReporter` is now exposed on `Call` as `call.statsReporter`, providing `currentMetrics` — a consolidated view of publisher/subscriber WebRTC quality, client environment, and rolling histories (latency, battery level, thermal status). +* Battery level and device thermal status are now tracked and available via `call.statsReporter?.currentMetrics`. + +🔄 Changed +* `Call.stats` continues to emit periodically, but the record field names/types changed as noted under breaking changes. ## 0.10.2 diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index f603c4107..3c04a05c0 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -301,15 +301,22 @@ class Call { StateEmitter get state => _stateManager.callStateStream; Stream get callDurationStream => _stateManager.durationStream; + StatsReporter? get statsReporter => _session?.statsReporter; Stream partialState(CallStateSelector selector) { return _stateManager.partialCallStateStream(selector); } - SharedEmitter<({CallStats publisherStats, CallStats subscriberStats})> - get stats => _stats; + SharedEmitter< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle + })> get stats => _stats; late final _stats = MutableSharedEmitterImpl< - ({CallStats publisherStats, CallStats subscriberStats})>(); + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle + })>(); SharedEmitter get callEvents => _callEvents; final _callEvents = MutableSharedEmitterImpl(); @@ -856,7 +863,13 @@ class Call { networkMonitor: networkMonitor, streamVideo: _streamVideo, statsOptions: _sfuStatsOptions!, - onReconnectionNeeded: (pc, strategy) => _reconnect(strategy), + onReconnectionNeeded: (pc, strategy) { + _session?.trace('pc_reconnection_needed', { + 'peerConnectionId': pc.type, + 'reconnectionStrategy': strategy.name, + }); + _reconnect(strategy); + }, clientPublishOptions: _stateManager.callState.preferences.clientPublishOptions, ); @@ -1157,20 +1170,8 @@ class Call { }), ); - var localStats = state.value.localStats ?? LocalStats.empty(); - localStats = localStats.copyWith( - sfu: session.config.sfuUrl, - sdkVersion: streamVideoVersion, - webRtcVersion: switch (CurrentPlatform.type) { - PlatformType.android => androidWebRTCVersion, - PlatformType.ios => iosWebRTCVersion, - _ => null, - }, - ); - _stateManager.lifecycleCallSessionStart( sessionId: session.sessionId, - localStats: localStats, ); if (_callLifecycleCompleter.isCompleted) { @@ -1188,20 +1189,19 @@ class Call { _streamVideo.state.currentUser.type == UserType.anonymous, ); - if (session.rtcManager != null) { + if (session.statsReporter != null) { _subscriptions.add( _idSessionStats, - StatsReporter( - rtcManager: session.rtcManager!, - stateManager: _stateManager, - ) + session.statsReporter! .run( interval: _stateManager.callState.preferences.callStatsReportingInterval, ) - .listen((stats) { - _stats.emit(stats); - }), + .listen( + (stats) { + _stats.emit(stats); + }, + ), ); } @@ -1267,6 +1267,11 @@ class Call { sfuEvent.reason.closeCode, )) { _logger.w(() => '[onSfuEvent] socket disconnected'); + + _session?.trace('sfu_socket_disconnected', { + 'closeCode': sfuEvent.reason.closeCode, + 'closeReason': sfuEvent.reason.closeReason, + }); await _reconnect(SfuReconnectionStrategy.fast); } else if (_leaveCallTriggered) { _logger.d( @@ -1276,9 +1281,15 @@ class Call { } } else if (sfuEvent is SfuSocketFailed) { _logger.w(() => '[onSfuEvent] socket failed'); + _session?.trace('sfu_socket_failed', { + 'error': sfuEvent.error.message, + }); await _reconnect(SfuReconnectionStrategy.fast); } else if (sfuEvent is SfuGoAwayEvent) { _logger.w(() => '[onSfuEvent] go away, migrating sfu'); + _session?.trace('sfu_go_away', { + 'reason': sfuEvent.goAwayReason.name, + }); await _reconnect(SfuReconnectionStrategy.migrate); } // error event @@ -1291,6 +1302,9 @@ class Call { () => '[onSfuEvent] SFU error: ${sfuEvent.error}, reconnect strategy: ${sfuEvent.error.reconnectStrategy}', ); + _session?.trace('sfu_error', { + 'error': sfuEvent.error.message, + }); await _reconnect(sfuEvent.error.reconnectStrategy); break; case SfuReconnectionStrategy.disconnect: @@ -1325,6 +1339,10 @@ class Call { return; } + _session?.trace('call_reconnect', { + 'strategy': strategy.name, + }); + await _callReconnectLock.synchronized(() async { _reconnectStrategy = strategy; _awaitNetworkAvailableFuture = _awaitNetworkAvailable(); @@ -1389,12 +1407,22 @@ class Call { _logger.v(() => '[reconnect] migrate'); await _reconnectMigrate(); } + + _session?.trace('call_reconnect_success', { + 'strategy': strategy.name, + }); } catch (error) { switch (error) { case OpenApiError() when error.apiError.unrecoverable ?? false: case APIError() when error.unrecoverable ?? false: _logger.w(() => '[reconnect] unrecoverable error'); _stateManager.lifecycleCallReconnectingFailed(); + + _session?.trace('call_reconnect_failed', { + 'strategy': strategy.name, + 'error': error.toString(), + }); + return; default: _logger.w( diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index 1501a0c5b..d69c036bd 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -10,7 +10,6 @@ import 'package:synchronized/synchronized.dart'; import 'package:system_info2/system_info2.dart'; import '../../../globals.dart'; -import '../../../open_api/video/coordinator/api.dart'; import '../../../protobuf/video/sfu/event/events.pb.dart' as sfu_events; import '../../../protobuf/video/sfu/models/models.pb.dart' as sfu_models; import '../../../protobuf/video/sfu/models/models.pbenum.dart'; @@ -19,37 +18,26 @@ import '../../../stream_video.dart'; import '../../disposable.dart'; import '../../errors/video_error.dart'; import '../../errors/video_error_composer.dart'; -import '../../logger/impl/tagged_logger.dart'; -import '../../models/models.dart'; -import '../../platform_detector/platform_detector.dart'; import '../../sfu/data/events/sfu_events.dart'; import '../../sfu/data/models/sfu_call_state.dart'; -import '../../sfu/data/models/sfu_error.dart'; import '../../sfu/data/models/sfu_model_mapper_extensions.dart'; import '../../sfu/data/models/sfu_subscription_details.dart'; -import '../../sfu/data/models/sfu_track_type.dart'; import '../../sfu/sfu_client.dart'; import '../../sfu/sfu_extensions.dart'; import '../../sfu/ws/sfu_ws.dart'; import '../../shared_emitter.dart'; import '../../utils/debounce_buffer.dart'; -import '../../utils/none.dart'; -import '../../utils/result.dart'; -import '../../webrtc/media/media_constraints.dart'; import '../../webrtc/model/rtc_model_mapper_extensions.dart'; import '../../webrtc/model/rtc_tracks_info.dart'; import '../../webrtc/peer_connection.dart'; -import '../../webrtc/peer_type.dart'; import '../../webrtc/rtc_manager.dart'; import '../../webrtc/rtc_manager_factory.dart'; -import '../../webrtc/rtc_media_device/rtc_media_device.dart'; -import '../../webrtc/rtc_track/rtc_track.dart'; import '../../webrtc/sdp/editor/sdp_editor.dart'; import '../../ws/ws.dart'; import '../state/call_state_notifier.dart'; +import '../stats/stats_reporter.dart'; import '../stats/tracer.dart'; import 'call_session_config.dart'; -import 'dynascale_manager.dart'; const _tag = 'SV:CallSession'; @@ -130,6 +118,8 @@ class CallSession extends Disposable { StreamSubscription? _eventsSubscription; StreamSubscription? _networkStatusSubscription; + StatsReporter? statsReporter; + Timer? _peerConnectionCheckTimer; sfu_models.ClientDetails? _clientDetails; @@ -150,6 +140,10 @@ class CallSession extends Disposable { _tracer.setEnabled(enabled); } + void trace(String tag, dynamic data) { + _tracer.trace(tag, data); + } + void _observeNetworkStatus() { _networkStatusSubscription = networkMonitor.onStatusChange.listen((status) { _tracer.trace('network.changed', status.name); @@ -417,6 +411,21 @@ class CallSession extends Disposable { stateManager.sfuPinsUpdated(event.callState.pins); + final environment = ClientEnvironment( + sfu: config.sfuUrl, + sdkVersion: streamVideoVersion, + webRtcVersion: switch (CurrentPlatform.type) { + PlatformType.android => androidWebRTCVersion, + PlatformType.ios => iosWebRTCVersion, + _ => '', + }, + ); + + statsReporter = StatsReporter( + rtcManager: rtcManager!, + clientEnvironment: environment, + ); + _logger.d(() => '[start] completed'); return Result.success( ( @@ -728,7 +737,7 @@ class CallSession extends Disposable { _logger.d(() => '[onPublishQualityChanged] event: $event'); final usedCodec = - stateManager.callState.publisherStats?.videoCodec?.firstOrNull; + statsReporter?.currentMetrics?.publisher?.videoCodec?.firstOrNull; for (final videoSender in event.videoSenders) { await rtcManager?.onPublishQualityChanged(videoSender, usedCodec); diff --git a/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart b/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart index 521a98bc8..e0646c5f9 100644 --- a/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart +++ b/packages/stream_video/lib/src/call/state/mixins/state_lifecycle_mixin.dart @@ -63,9 +63,6 @@ mixin StateLifecycleMixin on StateNotifier { DisconnectReason.ended(), ), sessionId: '', - localStats: LocalStats.empty(), - publisherStats: PeerConnectionStats.empty(), - subscriberStats: PeerConnectionStats.empty(), ); } @@ -201,9 +198,6 @@ mixin StateLifecycleMixin on StateNotifier { ), sessionId: '', callParticipants: const [], - localStats: LocalStats.empty(), - publisherStats: PeerConnectionStats.empty(), - subscriberStats: PeerConnectionStats.empty(), ); } @@ -231,12 +225,10 @@ mixin StateLifecycleMixin on StateNotifier { void lifecycleCallSessionStart({ required String sessionId, - LocalStats? localStats, }) { _logWithState('lifecycleCallSessionStart'); state = state.copyWith( sessionId: sessionId, - localStats: localStats, ); } @@ -247,18 +239,6 @@ mixin StateLifecycleMixin on StateNotifier { ); } - void lifecycleCallStats({ - required List latencyHistory, - PeerConnectionStats? publisherStats, - PeerConnectionStats? subscriberStats, - }) { - state = state.copyWith( - publisherStats: publisherStats, - subscriberStats: subscriberStats, - latencyHistory: latencyHistory, - ); - } - Future> validateUserId(String currentUserId) async { final stateUserId = state.currentUserId; if (currentUserId.isEmpty) { diff --git a/packages/stream_video/lib/src/call/stats/stats_reporter.dart b/packages/stream_video/lib/src/call/stats/stats_reporter.dart index 1caf73100..b8f8e493a 100644 --- a/packages/stream_video/lib/src/call/stats/stats_reporter.dart +++ b/packages/stream_video/lib/src/call/stats/stats_reporter.dart @@ -1,6 +1,9 @@ import 'dart:async'; +import 'package:battery_plus/battery_plus.dart'; import 'package:collection/collection.dart'; +import 'package:state_notifier/state_notifier.dart'; +import 'package:thermal/thermal.dart'; import '../../models/models.dart'; import '../../webrtc/model/stats/rtc_codec.dart'; @@ -10,35 +13,43 @@ import '../../webrtc/model/stats/rtc_outbound_rtp_video_stream.dart'; import '../../webrtc/model/stats/rtc_printable_stats.dart'; import '../../webrtc/peer_type.dart'; import '../../webrtc/rtc_manager.dart'; -import '../state/call_state_notifier.dart'; -class StatsReporter { +class StatsReporter extends StateNotifier { StatsReporter({ required this.rtcManager, - required this.stateManager, - }); + required this.clientEnvironment, + }) : super(null); final RtcManager rtcManager; - final CallStateNotifier stateManager; + final ClientEnvironment clientEnvironment; - Stream<({CallStats publisherStats, CallStats subscriberStats})> run({ + CallMetrics? get currentMetrics => state; + + Stream< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle, + })> run({ Duration interval = const Duration(seconds: 10), }) { - return Stream.periodic(interval, (_) => collectStats()).asyncMap( - (event) async { - final stats = await event; - _processStats(stats); - return event; + return Stream.periodic(interval, (tick) => (collectStats(), tick)).asyncMap( + (data) async { + final stats = await data.$1; + unawaited(_processStats(stats, data.$2)); + return stats; }, ); } - Future<({CallStats publisherStats, CallStats subscriberStats})> - collectStats() async { + Future< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle + })> collectStats() async { final publisherStatsBundle = await rtcManager.publisher?.getStats(); final subscriberStatsBundle = await rtcManager.subscriber.getStats(); - final publisherStats = CallStats( + final publisherStats = PeerConnectionStatsBundle( peerType: StreamPeerType.publisher, stats: publisherStatsBundle?.rtcStats ?? [], printable: publisherStatsBundle?.printable ?? @@ -46,25 +57,30 @@ class StatsReporter { raw: publisherStatsBundle?.rawStats ?? [], ); - final subscriberStats = CallStats( + final subscriberStats = PeerConnectionStatsBundle( peerType: StreamPeerType.subscriber, stats: subscriberStatsBundle.rtcStats, printable: subscriberStatsBundle.printable, raw: subscriberStatsBundle.rawStats, ); - return (publisherStats: publisherStats, subscriberStats: subscriberStats); + return ( + publisherStatsBundle: publisherStats, + subscriberStatsBundle: subscriberStats + ); } - void _processStats( - ({CallStats publisherStats, CallStats subscriberStats}) stats, - ) { - final state = stateManager.callState; - - var publisherStats = state.publisherStats ?? PeerConnectionStats.empty(); - var subscriberStats = state.subscriberStats ?? PeerConnectionStats.empty(); - - final allStats = stats.publisherStats.stats + Future _processStats( + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle + }) stats, + int tick, + ) async { + var publisherStats = state?.publisher ?? PeerConnectionStats.empty(); + var subscriberStats = state?.subscriber ?? PeerConnectionStats.empty(); + + final allStats = stats.publisherStatsBundle.stats .whereType() .map(MediaStatsInfo.fromRtcOutboundRtpVideoStream); @@ -92,7 +108,7 @@ class StatsReporter { .toList(); } - final codec = stats.publisherStats.stats + final codec = stats.publisherStatsBundle.stats .whereType() .where((c) => c.mimeType?.startsWith('video') ?? false) .where((c) => activeOutbound.any((s) => s.videoCodecId == c.id)) @@ -109,7 +125,7 @@ class StatsReporter { outboundMediaStats: allStats.toList(), ); - final inboudRtpVideo = stats.subscriberStats.stats + final inboudRtpVideo = stats.subscriberStatsBundle.stats .whereType() .firstOrNull; @@ -121,7 +137,7 @@ class StatsReporter { ? '${inboudRtpVideo.frameWidth} x ${inboudRtpVideo.frameHeight} @ ${inboudRtpVideo.framesPerSecond}fps' : null; - final codecStats = stats.subscriberStats.stats + final codecStats = stats.subscriberStatsBundle.stats .whereType() .where((c) => c.mimeType?.startsWith('video') ?? false) .firstOrNull; @@ -135,7 +151,7 @@ class StatsReporter { ); } - final subscriberCandidatePair = stats.subscriberStats.stats + final subscriberCandidatePair = stats.subscriberStatsBundle.stats .whereType() .firstOrNull; if (subscriberCandidatePair != null) { @@ -146,7 +162,7 @@ class StatsReporter { ); } - final publisherCandidatePair = stats.subscriberStats.stats + final publisherCandidatePair = stats.publisherStatsBundle.stats .whereType() .firstOrNull; if (publisherCandidatePair != null) { @@ -159,18 +175,50 @@ class StatsReporter { ); } - var latencyHistory = state.latencyHistory; + var latencyHistory = state?.latencyHistory; if (publisherStats.latency != null) { latencyHistory = [ - ...state.latencyHistory.reversed.take(19).toList().reversed, + ...state?.latencyHistory.reversed.take(19).toList().reversed ?? [], publisherStats.latency!, ]; } - stateManager.lifecycleCallStats( - publisherStats: publisherStats, - subscriberStats: subscriberStats, - latencyHistory: latencyHistory, - ); + var batteryLevelHistory = state?.batteryLevelHistory; + var thermalStatusHistory = state?.thermalStatusHistory; + var batteryLevel = 0; + + // check battery and thermal state every 10th tick (by default every 100s) + if (tick % 10 == 0) { + batteryLevel = await Battery().batteryLevel; + batteryLevelHistory = [ + ...state?.batteryLevelHistory.reversed.take(49).toList().reversed ?? [], + batteryLevel, + ]; + + final thermalStatus = await Thermal().thermalStatus; + thermalStatusHistory = [ + ...state?.thermalStatusHistory.reversed.take(49).toList().reversed ?? + [], + ThermalStatus.values.indexOf(thermalStatus), + ]; + } + + state = state?.copyWith( + publisher: publisherStats, + subscriber: subscriberStats, + latencyHistory: latencyHistory ?? [], + batteryLevelHistory: batteryLevelHistory, + thermalStatusHistory: thermalStatusHistory, + clientEnvironment: clientEnvironment, + ) ?? + CallMetrics( + publisher: publisherStats, + subscriber: subscriberStats, + clientEnvironment: clientEnvironment, + latencyHistory: latencyHistory ?? [], + batteryLevelHistory: batteryLevelHistory ?? [], + thermalStatusHistory: thermalStatusHistory ?? [], + initialBatteryLevel: batteryLevel, + ); } } diff --git a/packages/stream_video/lib/src/call_state.dart b/packages/stream_video/lib/src/call_state.dart index e35065b2a..79d725ac7 100644 --- a/packages/stream_video/lib/src/call_state.dart +++ b/packages/stream_video/lib/src/call_state.dart @@ -47,10 +47,6 @@ class CallState extends Equatable { liveStartedAt: null, liveEndedAt: null, timerEndsAt: null, - publisherStats: null, - subscriberStats: null, - localStats: null, - latencyHistory: const [], blockedUserIds: const [], participantCount: 0, anonymousParticipantCount: 0, @@ -91,10 +87,6 @@ class CallState extends Equatable { required this.liveStartedAt, required this.liveEndedAt, required this.timerEndsAt, - required this.publisherStats, - required this.subscriberStats, - required this.localStats, - required this.latencyHistory, required this.blockedUserIds, required this.participantCount, required this.anonymousParticipantCount, @@ -133,10 +125,6 @@ class CallState extends Equatable { final DateTime? liveStartedAt; final DateTime? liveEndedAt; final DateTime? timerEndsAt; - final PeerConnectionStats? publisherStats; - final PeerConnectionStats? subscriberStats; - final LocalStats? localStats; - final List latencyHistory; final List blockedUserIds; final int participantCount; final int anonymousParticipantCount; @@ -208,10 +196,6 @@ class CallState extends Equatable { DateTime? liveStartedAt, DateTime? liveEndedAt, DateTime? timerEndsAt, - PeerConnectionStats? publisherStats, - PeerConnectionStats? subscriberStats, - LocalStats? localStats, - List? latencyHistory, List? blockedUserIds, int? participantCount, int? anonymousParticipantCount, @@ -250,10 +234,6 @@ class CallState extends Equatable { liveStartedAt: liveStartedAt ?? this.liveStartedAt, liveEndedAt: liveEndedAt ?? this.liveEndedAt, timerEndsAt: timerEndsAt ?? this.timerEndsAt, - publisherStats: publisherStats ?? this.publisherStats, - subscriberStats: subscriberStats ?? this.subscriberStats, - localStats: localStats ?? this.localStats, - latencyHistory: latencyHistory ?? this.latencyHistory, blockedUserIds: blockedUserIds ?? this.blockedUserIds, participantCount: participantCount ?? this.participantCount, anonymousParticipantCount: @@ -326,10 +306,6 @@ class CallState extends Equatable { liveStartedAt, liveEndedAt, timerEndsAt, - publisherStats, - subscriberStats, - localStats, - latencyHistory, blockedUserIds, participantCount, anonymousParticipantCount, diff --git a/packages/stream_video/lib/src/models/call_stats.dart b/packages/stream_video/lib/src/models/call_stats.dart index 3ebbfa5da..86b352f00 100644 --- a/packages/stream_video/lib/src/models/call_stats.dart +++ b/packages/stream_video/lib/src/models/call_stats.dart @@ -1,4 +1,5 @@ import 'package:meta/meta.dart'; +import 'package:thermal/thermal.dart'; import '../webrtc/model/stats/rtc_outbound_rtp_video_stream.dart'; import '../webrtc/model/stats/rtc_printable_stats.dart'; @@ -6,8 +7,8 @@ import '../webrtc/model/stats/rtc_stats.dart'; import '../webrtc/peer_type.dart'; @immutable -class CallStats { - const CallStats({ +class PeerConnectionStatsBundle { + const PeerConnectionStatsBundle({ required this.peerType, required this.printable, required this.raw, @@ -27,7 +28,7 @@ class CallStats { @override bool operator ==(Object other) => identical(this, other) || - other is CallStats && + other is PeerConnectionStatsBundle && runtimeType == other.runtimeType && peerType == other.peerType && printable == other.printable && @@ -39,6 +40,51 @@ class CallStats { peerType.hashCode ^ printable.hashCode ^ raw.hashCode ^ stats.hashCode; } +class CallMetrics { + const CallMetrics({ + required this.clientEnvironment, + this.latencyHistory = const [], + this.batteryLevelHistory = const [], + this.thermalStatusHistory = const [], + this.initialBatteryLevel, + this.publisher, + this.subscriber, + }); + + final PeerConnectionStats? publisher; + final PeerConnectionStats? subscriber; + final ClientEnvironment clientEnvironment; + final List latencyHistory; + final List batteryLevelHistory; + final List thermalStatusHistory; + final int? initialBatteryLevel; + + CallMetrics copyWith({ + PeerConnectionStats? publisher, + PeerConnectionStats? subscriber, + ClientEnvironment? clientEnvironment, + List? latencyHistory, + List? batteryLevelHistory, + List? thermalStatusHistory, + int? initialBatteryLevel, + }) { + return CallMetrics( + publisher: publisher ?? this.publisher, + subscriber: subscriber ?? this.subscriber, + clientEnvironment: clientEnvironment ?? this.clientEnvironment, + latencyHistory: latencyHistory ?? this.latencyHistory, + batteryLevelHistory: batteryLevelHistory ?? this.batteryLevelHistory, + thermalStatusHistory: thermalStatusHistory ?? this.thermalStatusHistory, + initialBatteryLevel: initialBatteryLevel ?? this.initialBatteryLevel, + ); + } + + @override + String toString() { + return 'CallMetrics{publisher: $publisher, subscriber: $subscriber, clientEnvironment: $clientEnvironment, latencyHistory: $latencyHistory}'; + } +} + @immutable class PeerConnectionStats { const PeerConnectionStats({ @@ -190,14 +236,14 @@ class MediaStatsInfo { } @immutable -class LocalStats { - const LocalStats({ +class ClientEnvironment { + const ClientEnvironment({ required this.sfu, required this.sdkVersion, required this.webRtcVersion, }); - factory LocalStats.empty() => const LocalStats( + factory ClientEnvironment.empty() => const ClientEnvironment( sfu: '', sdkVersion: '', webRtcVersion: '', @@ -209,15 +255,15 @@ class LocalStats { @override String toString() { - return 'LocalStats{sfu: $sfu, sdkVersion: $sdkVersion, webRtcVersion: $webRtcVersion}'; + return 'ClientEnvironment{sfu: $sfu, sdkVersion: $sdkVersion, webRtcVersion: $webRtcVersion}'; } - LocalStats copyWith({ + ClientEnvironment copyWith({ String? sfu, String? sdkVersion, String? webRtcVersion, }) { - return LocalStats( + return ClientEnvironment( sfu: sfu ?? this.sfu, sdkVersion: sdkVersion ?? this.sdkVersion, webRtcVersion: webRtcVersion ?? this.webRtcVersion, @@ -227,7 +273,7 @@ class LocalStats { @override bool operator ==(Object other) => identical(this, other) || - other is LocalStats && + other is ClientEnvironment && runtimeType == other.runtimeType && sfu == other.sfu && sdkVersion == other.sdkVersion && diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart b/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart index 8d624b09a..ce1268e01 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart @@ -27,22 +27,25 @@ class _CallDiagnosticsContentState extends State { final _logger = taggedLogger(tag: 'SV:DiagnosticsView'); /// Represents the publisher stats. - CallStats? _publisherStats; + PeerConnectionStatsBundle? _publisherStats; /// Represents the subscriber stats. - CallStats? _subscriberStats; + PeerConnectionStatsBundle? _subscriberStats; /// Controls the subscription to the stats updates. - StreamSubscription<({CallStats publisherStats, CallStats subscriberStats})>? - _subscription; + StreamSubscription< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle + })>? _subscription; @override void initState() { super.initState(); _subscription = widget.call.stats.listen((stats) { setState(() { - _publisherStats = stats.publisherStats; - _subscriberStats = stats.subscriberStats; + _publisherStats = stats.publisherStatsBundle; + _subscriberStats = stats.subscriberStatsBundle; }); }); } @@ -131,10 +134,10 @@ class _CallStatsContent extends StatelessWidget { }); /// Represents the publisher stats. - final CallStats? publisherStats; + final PeerConnectionStatsBundle? publisherStats; /// Represents the subscriber stats. - final CallStats? subscriberStats; + final PeerConnectionStatsBundle? subscriberStats; @override Widget build(BuildContext context) { From a1c3e18644739a4fa549f051a7347196ef207b19 Mon Sep 17 00:00:00 2001 From: Brazol Date: Thu, 21 Aug 2025 09:35:13 +0200 Subject: [PATCH 2/8] tweaks --- .../lib/src/call/session/call_session.dart | 3 ++ .../lib/src/call/stats/stats_reporter.dart | 49 ++++++++++++++----- .../call_diagnostics_content.dart | 2 +- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/packages/stream_video/lib/src/call/session/call_session.dart b/packages/stream_video/lib/src/call/session/call_session.dart index d69c036bd..a5a867e0a 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -563,6 +563,9 @@ class CallSession extends Disposable { 'dart-client: $closeReason', ); + statsReporter?.dispose(); + statsReporter = null; + await rtcManager?.dispose(); rtcManager = null; _tracer.dispose(); diff --git a/packages/stream_video/lib/src/call/stats/stats_reporter.dart b/packages/stream_video/lib/src/call/stats/stats_reporter.dart index b8f8e493a..ebd2ff13c 100644 --- a/packages/stream_video/lib/src/call/stats/stats_reporter.dart +++ b/packages/stream_video/lib/src/call/stats/stats_reporter.dart @@ -6,6 +6,7 @@ import 'package:state_notifier/state_notifier.dart'; import 'package:thermal/thermal.dart'; import '../../models/models.dart'; +import '../../platform_detector/platform_detector.dart'; import '../../webrtc/model/stats/rtc_codec.dart'; import '../../webrtc/model/stats/rtc_ice_candidate_pair.dart'; import '../../webrtc/model/stats/rtc_inbound_rtp_video_stream.dart'; @@ -189,18 +190,42 @@ class StatsReporter extends StateNotifier { // check battery and thermal state every 10th tick (by default every 100s) if (tick % 10 == 0) { - batteryLevel = await Battery().batteryLevel; - batteryLevelHistory = [ - ...state?.batteryLevelHistory.reversed.take(49).toList().reversed ?? [], - batteryLevel, - ]; - - final thermalStatus = await Thermal().thermalStatus; - thermalStatusHistory = [ - ...state?.thermalStatusHistory.reversed.take(49).toList().reversed ?? - [], - ThermalStatus.values.indexOf(thermalStatus), - ]; + final batteryCheckAvailable = CurrentPlatform.isAndroid || + CurrentPlatform.isIos || + CurrentPlatform.isMacOS || + CurrentPlatform.isWindows; + + try { + if (batteryCheckAvailable) { + batteryLevel = await Battery().batteryLevel; + batteryLevelHistory = [ + ...state?.batteryLevelHistory.reversed.take(49).toList().reversed ?? + [], + batteryLevel, + ]; + } + } catch (_) { + // Ignore battery read failures + } + + final thermalStatusAvailable = + CurrentPlatform.isAndroid || CurrentPlatform.isIos; + + try { + if (thermalStatusAvailable) { + final thermalStatus = await Thermal().thermalStatus; + thermalStatusHistory = [ + ...state?.thermalStatusHistory.reversed + .take(49) + .toList() + .reversed ?? + [], + ThermalStatus.values.indexOf(thermalStatus), + ]; + } + } catch (_) { + // Ignore thermal read failures + } } state = state?.copyWith( diff --git a/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart b/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart index ce1268e01..b48f2bd71 100644 --- a/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart +++ b/packages/stream_video_flutter/lib/src/call_screen/call_diagnostics_content/call_diagnostics_content.dart @@ -52,8 +52,8 @@ class _CallDiagnosticsContentState extends State { @override Future dispose() async { - super.dispose(); await _subscription?.cancel(); + super.dispose(); } @override From 75df819d0a541a6f28bc807ff85dda2ffaf4592a Mon Sep 17 00:00:00 2001 From: Brazol Date: Thu, 21 Aug 2025 10:31:19 +0200 Subject: [PATCH 3/8] changelog --- packages/stream_video/CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index cb9132a71..46d41cbc1 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,7 +1,4 @@ -## 0.10.3 - -🐞 Fixed -* Handled SFU stats reporting failures gracefully +## Unreleased 🚧 Breaking changes * `Call.stats` payload changed. It now emits @@ -14,13 +11,20 @@ - Access `call.statsReporter?.currentMetrics` for the latest aggregated metrics instead. ✅ Added -* Added option to configure android audio configuration when initializing `StreamVideo` instance by providing `androidAudioConfiguration` to `StreamVideoOptions`. * `StatsReporter` is now exposed on `Call` as `call.statsReporter`, providing `currentMetrics` — a consolidated view of publisher/subscriber WebRTC quality, client environment, and rolling histories (latency, battery level, thermal status). * Battery level and device thermal status are now tracked and available via `call.statsReporter?.currentMetrics`. 🔄 Changed * `Call.stats` continues to emit periodically, but the record field names/types changed as noted under breaking changes. +## 0.10.3 + +🐞 Fixed +* Handled SFU stats reporting failures gracefully + +✅ Added +* Added option to configure android audio configuration when initializing `StreamVideo` instance by providing `androidAudioConfiguration` to `StreamVideoOptions`. + ## 0.10.2 ✅ Added From 898f1932f748be6f3fabc523e958d790d1062bce Mon Sep 17 00:00:00 2001 From: Brazol Date: Tue, 2 Sep 2025 10:41:06 +0200 Subject: [PATCH 4/8] trace when force migrating --- packages/stream_video/lib/src/call/call.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index ef2650dc2..f7b4e42fd 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -748,6 +748,11 @@ class Call { () => '[join] too many failures for SFU: $sfuName, migrating...', ); + + _session?.trace('call_join_migrate', { + 'migrateFrom': sfuName, + }); + sfuToForceExclude = sfuName; } } From 53ff7a8d9c1b2a0fe8e37d28bf7103e94accba3b Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 3 Sep 2025 11:36:40 +0200 Subject: [PATCH 5/8] reconnect traces tweaks --- packages/stream_video/lib/src/call/call.dart | 20 ++++++++++--------- .../call/session/call_session_factory.dart | 11 ++++++++++ .../lib/src/call/stats/trace_record.dart | 14 +++++++++++++ .../lib/src/call/stats/tracer.dart | 5 +++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index f7b4e42fd..057a9ff4d 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -868,9 +868,10 @@ class Call { networkMonitor: networkMonitor, streamVideo: _streamVideo, statsOptions: _sfuStatsOptions!, + leftoverTraceRecords: _previousSession?.getTrace().snapshot ?? [], onReconnectionNeeded: (pc, strategy) { _session?.trace('pc_reconnection_needed', { - 'peerConnectionId': pc.type, + 'peerConnectionId': pc.type.name, 'reconnectionStrategy': strategy.name, }); _reconnect(strategy); @@ -1344,10 +1345,6 @@ class Call { return; } - _session?.trace('call_reconnect', { - 'strategy': strategy.name, - }); - await _callReconnectLock.synchronized(() async { _reconnectStrategy = strategy; _awaitNetworkAvailableFuture = _awaitNetworkAvailable(); @@ -1369,13 +1366,18 @@ class Call { return; } + _session?.trace('call_reconnect', { + 'strategy': _reconnectStrategy.name, + }); + _stateManager.lifecycleCallConnecting( attempt: _reconnectAttempts, - strategy: strategy, + strategy: _reconnectStrategy, ); _logger.d( - () => '[reconnect] strategy: $strategy, attempt: $_reconnectAttempts', + () => + '[reconnect] strategy: $_reconnectStrategy, attempt: $_reconnectAttempts', ); try { @@ -1414,7 +1416,7 @@ class Call { } _session?.trace('call_reconnect_success', { - 'strategy': strategy.name, + 'strategy': _reconnectStrategy.name, }); } catch (error) { switch (error) { @@ -1424,7 +1426,7 @@ class Call { _stateManager.lifecycleCallReconnectingFailed(); _session?.trace('call_reconnect_failed', { - 'strategy': strategy.name, + 'strategy': _reconnectStrategy.name, 'error': error.toString(), }); diff --git a/packages/stream_video/lib/src/call/session/call_session_factory.dart b/packages/stream_video/lib/src/call/session/call_session_factory.dart index 92fbe19cd..86df1eac9 100644 --- a/packages/stream_video/lib/src/call/session/call_session_factory.dart +++ b/packages/stream_video/lib/src/call/session/call_session_factory.dart @@ -6,6 +6,7 @@ import '../../core/utils.dart'; import '../../webrtc/peer_connection.dart'; import '../../webrtc/sdp/editor/sdp_editor.dart'; import '../state/call_state_notifier.dart'; +import '../stats/trace_record.dart'; import '../stats/tracer.dart'; import 'call_session.dart'; import 'call_session_config.dart'; @@ -32,6 +33,7 @@ class CallSessionFactory { required StatsOptions statsOptions, required StreamVideo streamVideo, ClientPublishOptions? clientPublishOptions, + List leftoverTraceRecords = const [], }) async { final finalSessionId = sessionId ?? const Uuid().v4(); _logger.d(() => '[makeCallSession] sessionId: $finalSessionId($sessionId)'); @@ -54,6 +56,15 @@ class CallSessionFactory { final tracer = Tracer('$sessionSeq') ..setEnabled(statsOptions.enableRtcStats) + ..traceMultiple( + leftoverTraceRecords + .map( + (r) => r.copyWith( + id: '${sessionSeq - 1}', + ), + ) + .toList(), + ) ..trace('create', {'url': sfuName}); return CallSession( diff --git a/packages/stream_video/lib/src/call/stats/trace_record.dart b/packages/stream_video/lib/src/call/stats/trace_record.dart index 0ca7cd2a7..f01c7dfbc 100644 --- a/packages/stream_video/lib/src/call/stats/trace_record.dart +++ b/packages/stream_video/lib/src/call/stats/trace_record.dart @@ -37,6 +37,20 @@ class TraceRecord { return [tag, id, data, timestamp]; } + TraceRecord copyWith({ + String? tag, + String? id, + dynamic data, + int? timestamp, + }) { + return TraceRecord( + tag: tag ?? this.tag, + id: id ?? this.id, + data: data ?? this.data, + timestamp: timestamp ?? this.timestamp, + ); + } + @override String toString() { return 'TraceRecord(tag: $tag, id: $id, data: $data, timestamp: $timestamp)'; diff --git a/packages/stream_video/lib/src/call/stats/tracer.dart b/packages/stream_video/lib/src/call/stats/tracer.dart index ec314c5a5..853ba6a88 100644 --- a/packages/stream_video/lib/src/call/stats/tracer.dart +++ b/packages/stream_video/lib/src/call/stats/tracer.dart @@ -26,6 +26,11 @@ class Tracer { _buffer = []; } + void traceMultiple(List entries) { + if (!_enabled) return; + _buffer.addAll(entries); + } + void trace(String tag, dynamic data) { if (!_enabled) return; From fe795a723504a8b31108df31739032c0232d50ba Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 1 Oct 2025 14:16:32 +0200 Subject: [PATCH 6/8] fix tests --- .../stream_video/test/src/call/call_test_helpers.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/stream_video/test/src/call/call_test_helpers.dart b/packages/stream_video/test/src/call/call_test_helpers.dart index 915613865..907ef225f 100644 --- a/packages/stream_video/test/src/call/call_test_helpers.dart +++ b/packages/stream_video/test/src/call/call_test_helpers.dart @@ -9,6 +9,7 @@ import 'package:stream_video/src/call/permissions/permissions_manager.dart'; import 'package:stream_video/src/call/session/call_session_config.dart'; import 'package:stream_video/src/call/session/call_session_factory.dart'; import 'package:stream_video/src/call/state/call_state_notifier.dart'; +import 'package:stream_video/src/call/stats/tracer.dart'; import 'package:stream_video/src/coordinator/models/coordinator_models.dart'; import 'package:stream_video/src/core/client_state.dart'; import 'package:stream_video/src/sfu/data/events/sfu_events.dart'; @@ -337,6 +338,14 @@ MockCallSession setupMockCallSession() { rtcConfig: const RTCConfiguration(), ), ); + when( + callSession.getTrace, + ).thenReturn( + TraceSlice( + snapshot: [], + rollback: () {}, + ), + ); when( () => callSession.getReconnectDetails( From 1e99baa0168d15f27d283011e7bc2717130988f5 Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 1 Oct 2025 14:37:59 +0200 Subject: [PATCH 7/8] tweaks --- dogfooding/lib/screens/call_stats_screen.dart | 34 ------------------- .../call/session/call_session_factory.dart | 19 ++++++++++- .../lib/src/call/stats/stats_reporter.dart | 14 ++++++-- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/dogfooding/lib/screens/call_stats_screen.dart b/dogfooding/lib/screens/call_stats_screen.dart index 5f5dd61ea..d14dd5365 100644 --- a/dogfooding/lib/screens/call_stats_screen.dart +++ b/dogfooding/lib/screens/call_stats_screen.dart @@ -102,40 +102,6 @@ class CallStatsScreen extends StatelessWidget { ), ), if (snapshot.hasData) ...[ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon( - Icons.network_check, - color: Colors.white, - ), - const SizedBox(width: 8), - Text( - 'Call latency', - style: textTheme.title3.apply( - color: Colors.white, - ), - ), - ], - ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Very high latency values may reduce call quality, cause lag, and make the call less enjoyable.', - style: TextStyle(color: Colors.white), - ), - ), - const SizedBox( - height: 16, - ), - SizedBox( - height: 200, - child: StatsLatencyChart( - latencyHistory: state.latencyHistory, - ), - ), const SizedBox( height: 16, ), diff --git a/packages/stream_video/lib/src/call/session/call_session_factory.dart b/packages/stream_video/lib/src/call/session/call_session_factory.dart index 760d18297..c1fe1d886 100644 --- a/packages/stream_video/lib/src/call/session/call_session_factory.dart +++ b/packages/stream_video/lib/src/call/session/call_session_factory.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart'; import 'package:uuid/uuid.dart'; @@ -22,6 +24,21 @@ class CallSessionFactory { final StreamCallCid callCid; final SdpEditor sdpEditor; + /// Creates a new [CallSession] instance. + /// If [sessionId] is not provided, a new UUID will be generated. + /// [sessionSeq] is used for logging and tracing purposes. It should be incremented + /// for each new session created for the same call. + /// [credentials] are required to connect to the SFU. + /// [stateManager] manages the state of the call. + /// [dynascaleManager] manages dynascale operations. + /// [onReconnectionNeeded] is a callback invoked when reconnection is needed + /// because of PeerConnection issues. + /// [networkMonitor] monitors the internet connection. + /// [statsOptions] configures the statistics reporting. + /// [streamVideo] is the main StreamVideo instance. + /// [clientPublishOptions] are optional publish options for the client. + /// [leftoverTraceRecords] are trace records from previous sessions that were not sent + /// before the session ended. They will be included in the new session's trace. Future makeCallSession({ String? sessionId, int sessionSeq = 0, @@ -61,7 +78,7 @@ class CallSessionFactory { leftoverTraceRecords .map( (r) => r.copyWith( - id: '${sessionSeq - 1}', + id: '${max(0, sessionSeq - 1)}', ), ) .toList(), diff --git a/packages/stream_video/lib/src/call/stats/stats_reporter.dart b/packages/stream_video/lib/src/call/stats/stats_reporter.dart index 329b17621..02275a4e7 100644 --- a/packages/stream_video/lib/src/call/stats/stats_reporter.dart +++ b/packages/stream_video/lib/src/call/stats/stats_reporter.dart @@ -19,11 +19,19 @@ class StatsReporter extends StateNotifier { StatsReporter({ required this.rtcManager, required this.clientEnvironment, - }) : super(null); + Battery? battery, + Thermal? thermal, + }) : super(null) { + _battery = battery ?? Battery(); + _thermal = thermal ?? Thermal(); + } final RtcManager rtcManager; final ClientEnvironment clientEnvironment; + late Battery _battery; + late Thermal _thermal; + CallMetrics? get currentMetrics => state; Stream< @@ -207,7 +215,7 @@ class StatsReporter extends StateNotifier { try { if (batteryCheckAvailable) { - batteryLevel = await Battery().batteryLevel; + batteryLevel = await _battery.batteryLevel; batteryLevelHistory = [ ...state?.batteryLevelHistory.reversed.take(49).toList().reversed ?? [], @@ -223,7 +231,7 @@ class StatsReporter extends StateNotifier { try { if (thermalStatusAvailable) { - final thermalStatus = await Thermal().thermalStatus; + final thermalStatus = await _thermal.thermalStatus; thermalStatusHistory = [ ...state?.thermalStatusHistory.reversed .take(49) From 30f87525e60a9e5fe2be8a4ed96ddd729cde3f2a Mon Sep 17 00:00:00 2001 From: Brazol Date: Wed, 1 Oct 2025 14:50:48 +0200 Subject: [PATCH 8/8] battery drain display improvement --- dogfooding/lib/screens/call_stats_screen.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dogfooding/lib/screens/call_stats_screen.dart b/dogfooding/lib/screens/call_stats_screen.dart index d14dd5365..22545c1ab 100644 --- a/dogfooding/lib/screens/call_stats_screen.dart +++ b/dogfooding/lib/screens/call_stats_screen.dart @@ -37,8 +37,10 @@ class CallStatsScreen extends StatelessWidget { final publisherBitrate = state.publisher?.bitrateKbps; final batteryDrained = - (state.initialBatteryLevel ?? 0) - - (state.batteryLevelHistory.lastOrNull ?? 0); + state.initialBatteryLevel != null && + state.batteryLevelHistory.isNotEmpty + ? state.initialBatteryLevel! - state.batteryLevelHistory.last + : null; return SafeArea( top: false, @@ -167,7 +169,7 @@ class CallStatsScreen extends StatelessWidget { ), ), Text( - 'Battery percentage consumed during call: $batteryDrained%', + 'Battery percentage consumed during call: ${batteryDrained != null ? "$batteryDrained%" : "N/A"}', style: const TextStyle(color: Colors.white), ), const SizedBox(