| // Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:convert'; | |
| import 'dart:io' as io; | |
| import 'dart:math'; | |
| import 'package:clock/clock.dart'; | |
| import 'package:file/file.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'constants.dart'; | |
| import 'event.dart'; | |
| import 'initializer.dart'; | |
| /// Data class that will be returned when analyzing the | |
| /// persisted log file on the client's machine. | |
| class LogFileStats { | |
| /// The oldest timestamp in the log file. | |
| final DateTime startDateTime; | |
| /// Number of minutes from [startDateTime] to now using [clock]. | |
| final int minsFromStartDateTime; | |
| /// The latest timestamp in the log file. | |
| final DateTime endDateTime; | |
| /// Number of minutes from [endDateTime] to now using [clock]. | |
| final int minsFromEndDateTime; | |
| /// The number of unique session ids found in the log file. | |
| final int sessionCount; | |
| /// The map containing all of the flutter channels and a count | |
| /// of how many events were under each channel. | |
| /// | |
| /// ``` | |
| /// { | |
| /// 'stable': 123, | |
| /// 'beta': 50, | |
| /// 'master': 5, | |
| /// } | |
| /// ``` | |
| final Map<String, int> flutterChannelCount; | |
| /// The map containing all of the tools that have sent events | |
| /// and how many events were sent by each tool. | |
| /// | |
| /// ``` | |
| /// { | |
| /// 'flutter-tool': 500, | |
| /// 'dart-tool': 45, | |
| /// 'vscode-plugins': 321, | |
| /// } | |
| /// ``` | |
| final Map<String, int> toolCount; | |
| /// The map containing all of the events in the file along with | |
| /// how many times they have occured. | |
| /// | |
| /// ``` | |
| /// { | |
| /// 'client_request': 345, | |
| /// 'hot_reload_time': 765, | |
| /// 'memory_info': 90, | |
| /// } | |
| /// ``` | |
| final Map<String, int> eventCount; | |
| /// Total number of records in the log file. | |
| final int recordCount; | |
| /// Contains the data from the [LogHandler.logFileStats] method. | |
| const LogFileStats({ | |
| required this.startDateTime, | |
| required this.minsFromStartDateTime, | |
| required this.endDateTime, | |
| required this.minsFromEndDateTime, | |
| required this.sessionCount, | |
| required this.flutterChannelCount, | |
| required this.toolCount, | |
| required this.recordCount, | |
| required this.eventCount, | |
| }); | |
| /// Pass in a string label for one of the instance variables | |
| /// and return the integer value of that label. | |
| /// | |
| /// If label passed for [DateTime] instance variable, integer | |
| /// in the form of [DateTime.millisecondsSinceEpoch] will be | |
| /// returned. | |
| /// | |
| /// Returns null if the label passed does not match anything. | |
| int? getValueByString(String label) { | |
| // When querying counts, the label will include the | |
| // key for the appropriate map | |
| // | |
| // Example: logFileStats.toolCount.flutter-tool is asking | |
| // for the number of events sent via flutter cli | |
| final parts = label.split('.'); | |
| String? key; | |
| if (parts.length >= 3) { | |
| // Assign the first two parts of the string as the label | |
| // ie. logFileStats.toolCount.flutter-tool -> logFileStats.toolCount | |
| label = parts.sublist(0, 2).join('.'); | |
| key = parts.sublist(2, parts.length).join('.'); | |
| } | |
| switch (label) { | |
| case 'logFileStats.startDateTime': | |
| return startDateTime.millisecondsSinceEpoch; | |
| case 'logFileStats.minsFromStartDateTime': | |
| return minsFromStartDateTime; | |
| case 'logFileStats.endDateTime': | |
| return endDateTime.millisecondsSinceEpoch; | |
| case 'logFileStats.minsFromEndDateTime': | |
| return minsFromEndDateTime; | |
| case 'logFileStats.sessionCount': | |
| return sessionCount; | |
| case 'logFileStats.recordCount': | |
| return recordCount; | |
| case 'logFileStats.flutterChannelCount': | |
| if (key != null && flutterChannelCount.containsKey(key)) { | |
| return flutterChannelCount[key]; | |
| } | |
| case 'logFileStats.toolCount': | |
| if (key != null && toolCount.containsKey(key)) { | |
| return toolCount[key]; | |
| } | |
| case 'logFileStats.eventCount': | |
| if (key != null && eventCount.containsKey(key)) { | |
| return eventCount[key]; | |
| } | |
| } | |
| return null; | |
| } | |
| @override | |
| String toString() { | |
| const encoder = JsonEncoder.withIndent(' '); | |
| return encoder.convert({ | |
| 'startDateTime': startDateTime.toString(), | |
| 'minsFromStartDateTime': minsFromStartDateTime, | |
| 'endDateTime': endDateTime.toString(), | |
| 'minsFromEndDateTime': minsFromEndDateTime, | |
| 'sessionCount': sessionCount, | |
| 'recordCount': recordCount, | |
| 'eventCount': eventCount, | |
| 'toolCount': toolCount, | |
| 'flutterChannelCount': flutterChannelCount, | |
| }); | |
| } | |
| } | |
| /// This class is responsible for writing to a log | |
| /// file that has been initialized by the [createLogFile]. | |
| /// | |
| /// It will be treated as an append only log, trimmed to the newest | |
| /// [kLogFileLength] records when it grows past [kLogFileTrimSize]. | |
| class LogHandler { | |
| final File logFile; | |
| final int _trimSize; | |
| /// Contains instances of [Event.analyticsException] that were encountered | |
| /// during a workflow and will be sent to GA4 for collection. | |
| final Set<Event> errorSet = {}; | |
| /// A log handler constructor that will delegate saving | |
| /// logs and retrieving stats from the persisted log. | |
| LogHandler({ | |
| required this.logFile, | |
| @visibleForTesting int trimSize = kLogFileTrimSize, | |
| }) : _trimSize = trimSize; | |
| /// Get stats from the persisted log file. | |
| /// | |
| /// Note that some developers may only be Dart | |
| /// developers and will not have any data for flutter | |
| /// related metrics. | |
| LogFileStats? logFileStats() { | |
| // Parse each line of the log file through [LogItem], | |
| // some returned records may be null if malformed, they will be | |
| // removed later through `whereType<LogItem>` | |
| final lines = logFile.readAsLinesSync(); | |
| final records = lines | |
| .skip(max(0, lines.length - kLogFileLength)) | |
| .map((String e) { | |
| try { | |
| return LogItem.fromRecord(jsonDecode(e) as Map<String, Object?>); | |
| } on FormatException catch (err) { | |
| errorSet.add( | |
| Event.analyticsException( | |
| workflow: 'LogFileStats.logFileStats', | |
| error: err.runtimeType.toString(), | |
| description: 'message: ${err.message}\nsource: ${err.source}', | |
| ), | |
| ); | |
| return null; | |
| // ignore: avoid_catching_errors | |
| } on TypeError catch (err) { | |
| errorSet.add( | |
| Event.analyticsException( | |
| workflow: 'LogFileStats.logFileStats', | |
| error: err.runtimeType.toString(), | |
| ), | |
| ); | |
| return null; | |
| } | |
| }) | |
| .whereType<LogItem>() | |
| .toList(); | |
| if (records.isEmpty) return null; | |
| // Get the start and end dates for the log file | |
| final startDateTime = records.first.localTime; | |
| final endDateTime = records.last.localTime; | |
| // Map with counters for user properties | |
| final counter = <String, Set<Object>>{ | |
| 'sessions': <int>{}, | |
| 'flutter_channel': <String>{}, | |
| 'tool': <String>{}, | |
| }; | |
| // Map of counters for each event | |
| final eventCount = <String, int>{}; | |
| final flutterChannelCount = <String, int>{}; | |
| final toolCount = <String, int>{}; | |
| for (final record in records) { | |
| counter['sessions']!.add(record.sessionId); | |
| counter['tool']!.add(record.tool); | |
| if (record.flutterChannel != null) { | |
| counter['flutter_channel']!.add(record.flutterChannel!); | |
| } | |
| // Count each event, if it doesn't exist in the [eventCount] | |
| // it will be added first | |
| if (!eventCount.containsKey(record.eventName)) { | |
| eventCount[record.eventName] = 0; | |
| } | |
| eventCount[record.eventName] = eventCount[record.eventName]! + 1; | |
| // Counting how many events were recorded for each tool | |
| if (!toolCount.containsKey(record.tool)) { | |
| toolCount[record.tool] = 0; | |
| } | |
| toolCount[record.tool] = toolCount[record.tool]! + 1; | |
| // Necessary to perform a null check for flutter channel because | |
| // not all events will have information about flutter | |
| if (record.flutterChannel != null) { | |
| final flutterChannel = record.flutterChannel!; | |
| if (!flutterChannelCount.containsKey(flutterChannel)) { | |
| flutterChannelCount[flutterChannel] = 0; | |
| } | |
| flutterChannelCount[flutterChannel] = | |
| flutterChannelCount[flutterChannel]! + 1; | |
| } | |
| } | |
| final now = clock.now(); | |
| return LogFileStats( | |
| startDateTime: startDateTime, | |
| minsFromStartDateTime: now.difference(startDateTime).inMinutes, | |
| endDateTime: endDateTime, | |
| minsFromEndDateTime: now.difference(endDateTime).inMinutes, | |
| sessionCount: counter['sessions']!.length, | |
| flutterChannelCount: flutterChannelCount, | |
| toolCount: toolCount, | |
| eventCount: eventCount, | |
| recordCount: records.length, | |
| ); | |
| } | |
| /// Saves the data passed in as a single line in the log file. | |
| /// | |
| /// This only appends to the file, unless the file has grown past the trim | |
| /// size, see [kLogFileTrimSize]; then it is first trimmed to the newest | |
| /// [kLogFileLength] records. | |
| void save({required Map<String, Object?> data}) { | |
| try { | |
| final stat = logFile.statSync(); | |
| if (stat.size > kMaxLogFileSize) { | |
| logFile.deleteSync(); | |
| logFile.createSync(); | |
| } else if (stat.size > _trimSize) { | |
| _trim(); | |
| } | |
| logFile.writeAsStringSync( | |
| '${jsonEncode(data)}\n', | |
| mode: FileMode.writeOnlyAppend, | |
| ); | |
| } on Object { | |
| // Logging isn't important enough to warrant raising an | |
| // exception or error that will surprise consumers of this package. | |
| // Reset the log file on exception or error. | |
| if (logFile.existsSync()) { | |
| logFile.deleteSync(); | |
| } | |
| logFile.createSync(); | |
| } | |
| } | |
| /// Rewrites the log file keeping only its newest records. | |
| /// | |
| /// Keeps at most [kLogFileLength] records and at most about half the trim | |
| /// size, so that trims stay rare however large the records are. | |
| /// | |
| /// The new content is written to a temporary file which is then renamed | |
| /// over the log file, so other processes see the old or the new file and | |
| /// never a partial one. A record that another process appends during the | |
| /// trim can be lost; the log is only a local record, so that is accepted. | |
| void _trim() { | |
| File? tempFile; | |
| try { | |
| final lines = logFile.readAsLinesSync(); | |
| final maxBytes = _trimSize ~/ 2; | |
| var start = lines.length; | |
| var bytes = 0; | |
| while (start > 0 && lines.length - start < kLogFileLength) { | |
| // UTF-16 length approximates UTF-8 bytes; log records are almost | |
| // entirely ASCII. | |
| bytes += lines[start - 1].length + 1; | |
| if (bytes > maxBytes) break; | |
| start--; | |
| } | |
| final kept = lines.sublist(start); | |
| // The random part keeps trims in different isolates of one process | |
| // apart. | |
| final suffix = '${io.pid}.${Random().nextInt(1 << 32)}'; | |
| tempFile = logFile.fileSystem.file('${logFile.path}.$suffix.tmp'); | |
| tempFile.writeAsStringSync(kept.isEmpty ? '' : '${kept.join('\n')}\n'); | |
| tempFile.renameSync(logFile.path); | |
| } on Object { | |
| // Trimming is best effort; the next save tries again. If it keeps | |
| // failing, the file grows until [kMaxLogFileSize] and is deleted. | |
| try { | |
| if (tempFile != null && tempFile.existsSync()) tempFile.deleteSync(); | |
| } on Object { | |
| // Nothing more to do. | |
| } | |
| } | |
| } | |
| } | |
| /// Data class for each record persisted on the client's machine. | |
| class LogItem { | |
| final String eventName; | |
| final int sessionId; | |
| final String? flutterChannel; | |
| final String host; | |
| final String? flutterVersion; | |
| final String dartVersion; | |
| final String tool; | |
| final DateTime localTime; | |
| final String hostOsVersion; | |
| final String locale; | |
| final String? clientIde; | |
| LogItem({ | |
| required this.eventName, | |
| required this.sessionId, | |
| this.flutterChannel, | |
| required this.host, | |
| this.flutterVersion, | |
| required this.dartVersion, | |
| required this.tool, | |
| required this.localTime, | |
| required this.hostOsVersion, | |
| required this.locale, | |
| required this.clientIde, | |
| }); | |
| /// Serves a parser for each record in the log file. | |
| /// | |
| /// Using this method guarantees that we have parsed out | |
| /// fields that are necessary for the [LogHandler.logFileStats] | |
| /// method. | |
| /// | |
| /// If the returned value is null, that indicates a malformed | |
| /// record which can be discarded during analysis. | |
| /// | |
| /// Example of what a record looks like: | |
| /// ``` | |
| /// { | |
| /// "client_id": "ffcea97b-db5e-4c66-98c2-3942de4fac40", | |
| /// "events": [ | |
| /// { | |
| /// "name": "hot_reload_time", | |
| /// "params": { | |
| /// "timeMs": 135 | |
| /// } | |
| /// } | |
| /// ], | |
| /// "user_properties": { | |
| /// "session_id": { | |
| /// "value": 1699385899950 | |
| /// }, | |
| /// "flutter_channel": { | |
| /// "value": "ey-test-channel" | |
| /// }, | |
| /// "host": { | |
| /// "value": "macOS" | |
| /// }, | |
| /// "flutter_version": { | |
| /// "value": "Flutter 3.6.0-7.0.pre.47" | |
| /// }, | |
| /// "dart_version": { | |
| /// "value": "Dart 2.19.0" | |
| /// }, | |
| /// "analytics_pkg_version": { | |
| /// "value": "5.2.0" | |
| /// }, | |
| /// "tool": { | |
| /// "value": "flutter-tool" | |
| /// }, | |
| /// "local_time": { | |
| /// "value": "2023-11-07 15:09:03.025559 -0500" | |
| /// }, | |
| /// "host_os_version": { | |
| /// "value": "Version 14.1 (Build 23B74)" | |
| /// }, | |
| /// "locale": { | |
| /// "value": "en" | |
| /// }, | |
| /// "clientIde": { | |
| /// "value": "VSCode" | |
| /// } | |
| /// } | |
| /// } | |
| /// ``` | |
| static LogItem? fromRecord(Map<String, Object?> record) { | |
| if (!record.containsKey('user_properties') || | |
| !record.containsKey('events')) { | |
| return null; | |
| } | |
| // Parse out values from the top level key = 'events' and return | |
| // a map for the one event in the value | |
| final eventProp = | |
| (record['events']! as List<Object?>).first as Map<String, Object?>; | |
| final eventName = _logItemEventName( | |
| eventProp['name'] as String, | |
| event: eventProp, | |
| ); | |
| // Parse the data out of the `user_properties` value | |
| final userProps = record['user_properties'] as Map<String, Object?>; | |
| // Parse out the values from the top level key = 'user_properties` | |
| final sessionId = | |
| (userProps['session_id']! as Map<String, Object?>)['value'] as int?; | |
| final flutterChannel = | |
| (userProps['flutter_channel']! as Map<String, Object?>)['value'] | |
| as String?; | |
| final host = | |
| (userProps['host']! as Map<String, Object?>)['value'] as String?; | |
| final flutterVersion = | |
| (userProps['flutter_version']! as Map<String, Object?>)['value'] | |
| as String?; | |
| final dartVersion = | |
| (userProps['dart_version']! as Map<String, Object?>)['value'] | |
| as String?; | |
| final tool = | |
| (userProps['tool']! as Map<String, Object?>)['value'] as String?; | |
| final localTimeString = | |
| (userProps['local_time']! as Map<String, Object?>)['value'] as String?; | |
| final hostOsVersion = | |
| (userProps['host_os_version']! as Map<String, Object?>)['value'] | |
| as String?; | |
| final locale = | |
| (userProps['locale']! as Map<String, Object?>)['value'] as String?; | |
| final clientIde = | |
| (userProps['client_ide']! as Map<String, Object?>)['value'] as String?; | |
| // If any of the above values are null, return null since that | |
| // indicates the record is malformed; note that `flutter_version`, | |
| // `flutter_channel`, and `client_ide` are nullable fields in the log file | |
| final values = <Object?>[ | |
| // Values associated with the top level key = 'events' | |
| eventName, | |
| // Values associated with the top level key = 'events' | |
| sessionId, | |
| host, | |
| dartVersion, | |
| tool, | |
| localTimeString, | |
| hostOsVersion, | |
| locale, | |
| ]; | |
| for (final value in values) { | |
| if (value == null) return null; | |
| } | |
| // Parse the local time from the string extracted | |
| final localTime = DateTime.parse(localTimeString!).toLocal(); | |
| return LogItem( | |
| eventName: eventName, | |
| sessionId: sessionId!, | |
| flutterChannel: flutterChannel, | |
| host: host!, | |
| flutterVersion: flutterVersion, | |
| dartVersion: dartVersion!, | |
| tool: tool!, | |
| localTime: localTime, | |
| hostOsVersion: hostOsVersion!, | |
| locale: locale!, | |
| clientIde: clientIde, | |
| ); | |
| } | |
| } | |
| /// Logic for creating a more granular event name for the [LogItem]. | |
| String _logItemEventName( | |
| String eventName, { | |
| required Map<String, Object?> event, | |
| }) { | |
| switch (eventName) { | |
| case devtoolsEventLabel: | |
| return _granularDevToolsEventName(eventName, event: event); | |
| default: | |
| return eventName; | |
| } | |
| } | |
| String _granularDevToolsEventName( | |
| String eventName, { | |
| required Map<String, Object?> event, | |
| }) { | |
| final params = event['params'] as Map<String, Object?>; | |
| final screen = params['screen']; | |
| if (screen is String && screen == propertyEditorId) { | |
| return propertyEditorLogStatsName; | |
| } | |
| return eventName; | |
| } |