diff --git a/dogfooding/lib/screens/call_stats_screen.dart b/dogfooding/lib/screens/call_stats_screen.dart index 2d537b105..22545c1ab 100644 --- a/dogfooding/lib/screens/call_stats_screen.dart +++ b/dogfooding/lib/screens/call_stats_screen.dart @@ -4,7 +4,9 @@ import 'package:stream_video_flutter/stream_video_flutter.dart'; import '../app/user_auth_controller.dart'; import '../di/injector.dart'; import '../theme/app_palette.dart'; +import 'stats_battery_chart.dart'; import 'stats_latency_chart.dart'; +import 'stats_thermal_chart.dart'; class CallStatsScreen extends StatelessWidget { CallStatsScreen({super.key, required this.call}); @@ -19,12 +21,26 @@ 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 != null && + state.batteryLevelHistory.isNotEmpty + ? state.initialBatteryLevel! - state.batteryLevelHistory.last + : null; return SafeArea( top: false, @@ -84,104 +100,183 @@ 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), - child: Row( + if (snapshot.hasData) ...[ + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(16), + 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), + 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), + 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), + 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 != null ? "$batteryDrained%" : "N/A"}', + style: const TextStyle(color: Colors.white), + ), + const SizedBox( + height: 16, + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.bar_chart, color: Colors.white), + const SizedBox(width: 8), + Text( + 'Call performance', + style: textTheme.title3.apply( + color: Colors.white, + ), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Review the key data points below to assess call performance.', + style: TextStyle(color: Colors.white), + ), + ), + const SizedBox( + height: 16, + ), + Row( children: [ - const Icon(Icons.bar_chart, color: Colors.white), - const SizedBox(width: 8), - Text( - 'Call performance', - style: textTheme.title3.apply(color: Colors.white), + Expanded( + child: LatencyOrJitterItem( + title: 'Latency', + value: state.publisher?.latency ?? 0, + ), ), ], ), - ), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Review the key data points below to assess call performance.', - style: TextStyle(color: Colors.white), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: LatencyOrJitterItem( - title: 'Latency', - value: state.publisherStats?.latency ?? 0, - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: LatencyOrJitterItem( - title: 'Receive jitter', - value: state.subscriberStats?.jitterInMs, + Row( + children: [ + Expanded( + child: LatencyOrJitterItem( + title: 'Receive jitter', + value: state.subscriber?.jitterInMs, + ), ), - ), - Expanded( - child: LatencyOrJitterItem( - title: 'Publish jitter', - value: state.publisherStats?.jitterInMs, + Expanded( + child: LatencyOrJitterItem( + title: 'Publish jitter', + value: state.publisher?.jitterInMs, + ), ), - ), - ], - ), - Row( - children: [ - Expanded( - child: StatsItem( - title: 'Publish bitrate', - value: publisherBitrate == null - ? '--' - : '${state.publisherStats?.bitrateKbps} Kbps', + ], + ), + Row( + children: [ + Expanded( + child: StatsItem( + title: 'Publish bitrate', + value: publisherBitrate == null + ? '--' + : '${state.publisher?.bitrateKbps} Kbps', + ), ), - ), - Expanded( - child: StatsItem( - title: 'Receive bitrate', - value: subscriberBitrate == null - ? '--' - : '${state.subscriberStats?.bitrateKbps} Kbps', + Expanded( + child: StatsItem( + title: 'Receive bitrate', + value: subscriberBitrate == null + ? '--' + : '${state.subscriber?.bitrateKbps} Kbps', + ), ), - ), - ], - ), - Row( - children: [ - Expanded( - child: StatsItem( - title: 'Publish resolution', - value: - "${state.publisherStats?.resolution} | ${state.publisherStats?.videoCodec?.join('+')}", + ], + ), + Row( + children: [ + Expanded( + child: StatsItem( + title: 'Publish resolution', + value: + "${state.publisher?.resolution} | ${state.publisher?.videoCodec?.join('+')}", + ), ), - ), - Expanded( - child: StatsItem( - title: 'Reveive resolution', - value: - "${state.subscriberStats?.resolution} | ${state.subscriberStats?.videoCodec?.join('+')}", + Expanded( + child: StatsItem( + title: 'Receive resolution', + value: + "${state.subscriber?.resolution} | ${state.subscriber?.videoCodec?.join('+')}", + ), ), - ), - ], - ), - StatsItem(title: 'Region', value: state.localStats?.sfu), - StatsItem( - title: 'SDK Version', - value: state.localStats?.sdkVersion, - ), - StatsItem( - title: 'WebRTC Version', - value: state.localStats?.webRtcVersion, - ), + ], + ), + StatsItem( + title: 'Region', + value: state.clientEnvironment.sfu, + ), + StatsItem( + title: 'SDK Version', + value: state.clientEnvironment.sdkVersion, + ), + StatsItem( + title: 'WebRTC Version', + 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..b41a6827d --- /dev/null +++ b/dogfooding/lib/screens/stats_battery_chart.dart @@ -0,0 +1,112 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../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), + child: LineChart( + LineChartData( + lineTouchData: const LineTouchData(enabled: false), + gridData: FlGridData( + drawVerticalLine: false, + 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: const FlTitlesData( + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 20, + ), + ), + topTitles: AxisTitles(), + bottomTitles: AxisTitles(), + leftTitles: AxisTitles(), + ), + 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..100510a8d --- /dev/null +++ b/dogfooding/lib/screens/stats_thermal_chart.dart @@ -0,0 +1,81 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +import '../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), + child: BarChart( + BarChartData( + alignment: BarChartAlignment.start, + barTouchData: const BarTouchData(enabled: false), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: 1, + getDrawingHorizontalLine: (value) => const FlLine( + color: Color(0xff37434d), + strokeWidth: 1, + ), + ), + titlesData: const FlTitlesData( + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: 1, + ), + ), + topTitles: AxisTitles(), + bottomTitles: AxisTitles(), + leftTitles: AxisTitles(), + ), + 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.withValues(alpha: .04), + ), + ), + ], + ), + ) + .toList(), + ), + ), + ); + } +} diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index 18023763a..c5da07ee3 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -11,6 +11,23 @@ * Updated minimum Flutter version to 3.32.0 * Updated minimum supported dart SDK version to 3.8.0 +🚧 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 +* `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. + 🐞 Fixed * Fixed an issue where the leave call operation could fail if the there were some issues in parsing custom data. * Fixed an issue where the Android audio configuration could not be applied correctly for participants joining the call diff --git a/packages/stream_video/lib/src/call/call.dart b/packages/stream_video/lib/src/call/call.dart index f1e3ca383..ed0f03998 100644 --- a/packages/stream_video/lib/src/call/call.dart +++ b/packages/stream_video/lib/src/call/call.dart @@ -308,16 +308,25 @@ 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})> + SharedEmitter< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle, + }) + > get stats => _stats; late final _stats = MutableSharedEmitterImpl< - ({CallStats publisherStats, CallStats subscriberStats}) + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle, + }) >(); SharedEmitter get callEvents => _callEvents; @@ -777,6 +786,11 @@ class Call { () => '[join] too many failures for SFU: $sfuName, migrating...', ); + + _session?.trace('call_join_migrate', { + 'migrateFrom': sfuName, + }); + sfuToForceExclude = sfuName; } } @@ -893,7 +907,14 @@ class Call { networkMonitor: networkMonitor, streamVideo: _streamVideo, statsOptions: _sfuStatsOptions!, - onReconnectionNeeded: (pc, strategy) => _reconnect(strategy), + leftoverTraceRecords: _previousSession?.getTrace().snapshot ?? [], + onReconnectionNeeded: (pc, strategy) { + _session?.trace('pc_reconnection_needed', { + 'peerConnectionId': pc.type.name, + 'reconnectionStrategy': strategy.name, + }); + _reconnect(strategy); + }, clientPublishOptions: _stateManager.callState.preferences.clientPublishOptions, ); @@ -1205,20 +1226,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) { @@ -1238,22 +1247,21 @@ class Call { unifiedSessionId: _unifiedSessionId, ); - 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); + }, + ), ); } @@ -1320,6 +1328,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( @@ -1329,9 +1342,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 @@ -1344,6 +1363,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: @@ -1399,13 +1421,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 { @@ -1443,12 +1470,22 @@ class Call { _logger.v(() => '[reconnect] migrate'); await _reconnectMigrate(); } + + _session?.trace('call_reconnect_success', { + 'strategy': _reconnectStrategy.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': _reconnectStrategy.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 5cba1a929..2b8bd07cc 100644 --- a/packages/stream_video/lib/src/call/session/call_session.dart +++ b/packages/stream_video/lib/src/call/session/call_session.dart @@ -35,6 +35,7 @@ import '../../webrtc/rtc_manager_factory.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'; @@ -117,6 +118,8 @@ class CallSession extends Disposable { StreamSubscription? _eventsSubscription; StreamSubscription? _networkStatusSubscription; + StatsReporter? statsReporter; + Timer? _peerConnectionCheckTimer; sfu_models.ClientDetails? _clientDetails; @@ -137,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); @@ -430,6 +437,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( ( @@ -580,6 +602,9 @@ class CallSession extends Disposable { 'dart-client: $closeReason', ); + statsReporter?.dispose(); + statsReporter = null; + await rtcManager?.dispose(); rtcManager = null; _tracer.dispose(); @@ -756,7 +781,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/session/call_session_factory.dart b/packages/stream_video/lib/src/call/session/call_session_factory.dart index af1fb564e..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'; @@ -6,6 +8,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'; @@ -21,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, @@ -32,6 +50,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)'); @@ -55,6 +74,15 @@ class CallSessionFactory { final tracer = Tracer('$sessionSeq') ..setEnabled(statsOptions.enableRtcStats) + ..traceMultiple( + leftoverTraceRecords + .map( + (r) => r.copyWith( + id: '${max(0, sessionSeq - 1)}', + ), + ) + .toList(), + ) ..trace('create', {'url': sfuName}); return CallSession( 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 ee93ee07a..e71205c51 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(), ); } @@ -202,9 +199,6 @@ mixin StateLifecycleMixin on StateNotifier { ), sessionId: '', callParticipants: const [], - localStats: LocalStats.empty(), - publisherStats: PeerConnectionStats.empty(), - subscriberStats: PeerConnectionStats.empty(), ); } @@ -232,12 +226,10 @@ mixin StateLifecycleMixin on StateNotifier { void lifecycleCallSessionStart({ required String sessionId, - LocalStats? localStats, }) { _logWithState('lifecycleCallSessionStart'); state = state.copyWith( sessionId: sessionId, - localStats: localStats, ); } @@ -248,18 +240,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 746e4bb04..02275a4e7 100644 --- a/packages/stream_video/lib/src/call/stats/stats_reporter.dart +++ b/packages/stream_video/lib/src/call/stats/stats_reporter.dart @@ -1,8 +1,12 @@ 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 '../../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'; @@ -10,35 +14,55 @@ 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, + Battery? battery, + Thermal? thermal, + }) : super(null) { + _battery = battery ?? Battery(); + _thermal = thermal ?? Thermal(); + } final RtcManager rtcManager; - final CallStateNotifier stateManager; + final ClientEnvironment clientEnvironment; + + late Battery _battery; + late Thermal _thermal; - 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})> + 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: @@ -47,25 +71,31 @@ 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; + Future _processStats( + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle, + }) + stats, + int tick, + ) async { + var publisherStats = state?.publisher ?? PeerConnectionStats.empty(); + var subscriberStats = state?.subscriber ?? PeerConnectionStats.empty(); - var publisherStats = state.publisherStats ?? PeerConnectionStats.empty(); - var subscriberStats = state.subscriberStats ?? PeerConnectionStats.empty(); - - final allStats = stats.publisherStats.stats + final allStats = stats.publisherStatsBundle.stats .whereType() .map( MediaStatsInfo.fromRtcOutboundRtpVideoStream, @@ -95,7 +125,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)) @@ -112,7 +142,7 @@ class StatsReporter { outboundMediaStats: allStats.toList(), ); - final inboudRtpVideo = stats.subscriberStats.stats + final inboudRtpVideo = stats.subscriberStatsBundle.stats .whereType() .firstOrNull; @@ -125,7 +155,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; @@ -139,7 +169,7 @@ class StatsReporter { ); } - final subscriberCandidatePair = stats.subscriberStats.stats + final subscriberCandidatePair = stats.subscriberStatsBundle.stats .whereType() .firstOrNull; if (subscriberCandidatePair != null) { @@ -150,7 +180,7 @@ class StatsReporter { ); } - final publisherCandidatePair = stats.subscriberStats.stats + final publisherCandidatePair = stats.publisherStatsBundle.stats .whereType() .firstOrNull; if (publisherCandidatePair != null) { @@ -163,18 +193,76 @@ 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) { + 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( + 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/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; diff --git a/packages/stream_video/lib/src/call_state.dart b/packages/stream_video/lib/src/call_state.dart index 1eaa8841b..197c34d5a 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: @@ -327,10 +307,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 f0d2341ca..0e6f4604c 100644 --- a/packages/stream_video/lib/src/models/call_stats.dart +++ b/packages/stream_video/lib/src/models/call_stats.dart @@ -6,8 +6,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 +27,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 +39,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({ @@ -189,14 +234,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: '', @@ -208,15 +253,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, @@ -226,7 +271,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/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( 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 553e28da6..58c2b8ae7 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,13 +27,18 @@ 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})>? + StreamSubscription< + ({ + PeerConnectionStatsBundle publisherStatsBundle, + PeerConnectionStatsBundle subscriberStatsBundle, + }) + >? _subscription; @override @@ -41,16 +46,16 @@ class _CallDiagnosticsContentState extends State { super.initState(); _subscription = widget.call.stats.listen((stats) { setState(() { - _publisherStats = stats.publisherStats; - _subscriberStats = stats.subscriberStats; + _publisherStats = stats.publisherStatsBundle; + _subscriberStats = stats.subscriberStatsBundle; }); }); } @override Future dispose() async { - super.dispose(); await _subscription?.cancel(); + super.dispose(); } @override @@ -131,10 +136,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) {