diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5509140f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.DS_Store diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..613896b21 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# The CODEOWNERS file helps to define individuals or teams that responsible +# for code within the repository +# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +/pkgs/dash_analytics/ @eliasyishak diff --git a/pkgs/dash_analytics/.gitignore b/pkgs/dash_analytics/.gitignore new file mode 100644 index 000000000..9377e382e --- /dev/null +++ b/pkgs/dash_analytics/.gitignore @@ -0,0 +1,14 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock + +.vscode/ +coverage/ +*.DS_Store diff --git a/pkgs/dash_analytics/CHANGELOG.md b/pkgs/dash_analytics/CHANGELOG.md new file mode 100644 index 000000000..a0712a79e --- /dev/null +++ b/pkgs/dash_analytics/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial version. diff --git a/pkgs/dash_analytics/LICENSE b/pkgs/dash_analytics/LICENSE new file mode 100644 index 000000000..ac9003195 --- /dev/null +++ b/pkgs/dash_analytics/LICENSE @@ -0,0 +1,27 @@ +Copyright 2023, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/pkgs/dash_analytics/README.md b/pkgs/dash_analytics/README.md new file mode 100644 index 000000000..22105dcd8 --- /dev/null +++ b/pkgs/dash_analytics/README.md @@ -0,0 +1,10 @@ +## What's This? + +This package is intended to be used on Dash (Flutter, Dart, etc.) related tooling only. +It provides APIs to send events to Google Analytics using the Measurement Protocol. + +This is not intended to be general purpose or consumed by the community. It is responsible for toggling analytics collection for Dash related tooling on each developer's machine. + +## Using This Package As A Dash Tool + +Refer to the [guide](USAGE_GUIDE.md) \ No newline at end of file diff --git a/pkgs/dash_analytics/USAGE_GUIDE.md b/pkgs/dash_analytics/USAGE_GUIDE.md new file mode 100644 index 000000000..746509cfc --- /dev/null +++ b/pkgs/dash_analytics/USAGE_GUIDE.md @@ -0,0 +1,160 @@ +This package is intended to be used on Dash (Flutter, Dart, etc.) related tooling only. +It provides APIs to send events to Google Analytics using the Measurement Protocol. + +## Usage + +To get started using this package, import at the entrypoint dart file and +initialize with the required parameters + +```dart +import 'dash_analytics'; + +// Constants that should be resolved by the client using package +final DashTool tool = DashTool.flutterTools; // Restricted to enum provided by package +final String measurementId = 'xxxxxxxxxxxx'; // To be provided to client +final String apiSecret = 'xxxxxxxxxxxx'; // To be provided to client + +// Values that need to be provided by the client that may +// need to be calculated +final String branch = ...; +final String flutterVersion = ...; +final String dartVersion = ...; + +// Initialize the [Analytics] class with the required parameters; +// preferably outside of the [main] method +final Analytics analytics = Analytics( + tool: tool, + measurementId: measurementId, + apiSecret: apiSecret, + branch: branch, + flutterVersion: flutterVersion, + dartVersion: dartVersion, +); + +// Timing a process and sending the event +void main() { + DateTime start = DateTime.now(); + int count = 0; + + // Example of long running process + for (int i = 0; i < 2000; i++) { + count += i; + } + + // Calculate the metric to send + final int runTime = DateTime.now().difference(start).inMilliseconds; + + // Generate the body for the event data + final Map eventData = { + 'time_ns': runTime, + }; + + // Choose one of the enum values for [DashEvent] which should + // have all possible events; if not there, open an issue for the + // team to add + final DashEvent eventName = ...; // Select appropriate DashEvent enum value + + // Make a call to the [Analytics] api to send the data + analytics.sendEvent( + eventName: eventName, + eventData: eventData, + ); + + // Close the client connection on exit + analytics.close(); +} +``` + +## Opting In and Out of Analytics Collection + +It will be important for each Dash tool to expose a trivial method to +disabling or enabling analytics collection. Based on how the user interacts +with the tool, this can be done through the CLI, IDE, etc. The Dash tool will +then pass a boolean to an API exposed by the package as shown below + +```dart +// Begin by initializing the class +final Analytics analytics = Analytics(...); + +// The boolean below simulates the user deciding to opt-out +// of Analytics collection +final bool status = false; + +// Call the method to pass the boolean +analytics.setTelemetry(status); +``` + +## Informing Users About Analytics Opt-In Status + +When a user first uses any Dash tool with this package enabled, they +will be enrolled into Analytics collection. It will be the responsiblity +of the Dash tool using this package to display the proper Analytics messaging +and inform them on how to Opt-Out of Analytics collection if they wish. The +package will expose APIs that will make it easy to configure Opt-In status. + +```dart +// Begin by initializing the class +final Analytics analytics = Analytics(...); + +// This should be performed every time the Dash tool starts up +if (analytics.shouldShowMessage) { + + // How each Dash tool displays the message will be unique, + // print statement used for trivial usage example + print(analytics.toolsMessage); +} +``` + +## Checking User Opt-In Status + +Some Dash tools may need to know if the user has opted in for Analytics +collection in order to enable additional functionality. The example below +shows how to check the status + +```dart +// Begin by initializing the class +final Analytics analytics = Analytics(...); + +// This getter will return a boolean showing the status; +// print statement used for trivial usage example +print('This user's status: ${analytics.telemetryEnabled}'); // true if opted-in +``` + +## Advanced Usage: Querying Locally Persisted Logs + +This package enables dash tools to persist the events that have been +sent to Google Analytics for logging by default. This can be very helpful if +dash tools would like to understand the user's activity level across all +dash related tooling. For example, if querying the locally persisted logs +shows that the user has not been active for N number of days, a dash tool that +works within an IDE can prompt the user with a survey to understand why their +level of activity has dropped. + +The snippet below shows how to invoke the query and a sample response + +```dart +// Begin by initializing the class +final Analytics analytics = Analytics(...); + +// Printing the query results returns json formatted +// string to view; data can also be accessed through +// [LogFileStats] getters +print(analytics.logFileStats()); + +// Prints out the below +// { +// "startDateTime": "2023-02-08 15:07:10.293728", +// "endDateTime": "2023-02-08 15:07:10.299678", +// "sessionCount": 1, +// "flutterChannelCount": 1, +// "toolCount": 1 +// } +``` + +Explanation of the each key above + +- startDateTime: the earliest event that was sent +- endDateTime: the latest, most recent event that was sent +- sessionCount: count of sessions; sessions have a minimum time of 30 minutes +- flutterChannelCount: count of flutter channels (can be 0 if developer is a Dart dev only) +- toolCount: count of the dash tools sending analytics \ No newline at end of file diff --git a/pkgs/dash_analytics/analysis_options.yaml b/pkgs/dash_analytics/analysis_options.yaml new file mode 100644 index 000000000..38d35cf7b --- /dev/null +++ b/pkgs/dash_analytics/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - always_declare_return_types + - always_specify_types + - camel_case_types + - prefer_single_quotes + - unawaited_futures + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/pkgs/dash_analytics/coverage_runner.sh b/pkgs/dash_analytics/coverage_runner.sh new file mode 100755 index 000000000..4043614be --- /dev/null +++ b/pkgs/dash_analytics/coverage_runner.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# Generate `coverage/lcov.info` file +flutter test --coverage + +# Generate HTML report +# Note: on macOS you need to have lcov installed on your system (`brew install lcov`) to use this: +genhtml coverage/lcov.info -o coverage/html + +# Open the report +open coverage/html/index.html diff --git a/pkgs/dash_analytics/example/dash_analytics_example.dart b/pkgs/dash_analytics/example/dash_analytics_example.dart new file mode 100644 index 000000000..c7ec99fe3 --- /dev/null +++ b/pkgs/dash_analytics/example/dash_analytics_example.dart @@ -0,0 +1,36 @@ +// 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 'package:dash_analytics/dash_analytics.dart'; + +final String measurementId = 'G-N1NXG28J5B'; +final String apiSecret = '4yT8__oER3Cd84dtx6r-_A'; + +// Globally instantiate the analytics class at the entry +// point of the tool +final Analytics analytics = Analytics( + tool: DashTool.flutterTools, + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: 'ey-test-channel', + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', +); + +void main() { + DateTime start = DateTime.now(); + print('###### START ###### $start'); + + print(analytics.telemetryEnabled); + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, + eventData: {'time_ns': 345}, + ); + print(analytics.logFileStats()); + analytics.close(); + + DateTime end = DateTime.now(); + print( + '###### DONE ###### ${DateTime.now()} ${end.difference(start).inMilliseconds}ms'); +} diff --git a/pkgs/dash_analytics/lib/dash_analytics.dart b/pkgs/dash_analytics/lib/dash_analytics.dart new file mode 100644 index 000000000..85c227a38 --- /dev/null +++ b/pkgs/dash_analytics/lib/dash_analytics.dart @@ -0,0 +1,7 @@ +// 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. + +export 'src/analytics.dart' show Analytics; +export 'src/enums.dart'; +export 'src/log_handler.dart' show LogFileStats; diff --git a/pkgs/dash_analytics/lib/src/analytics.dart b/pkgs/dash_analytics/lib/src/analytics.dart new file mode 100644 index 000000000..fa678c942 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/analytics.dart @@ -0,0 +1,311 @@ +// 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:io' as io; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:file/memory.dart'; +import 'package:http/http.dart'; +import 'package:path/path.dart' as p; + +import 'config_handler.dart'; +import 'constants.dart'; +import 'enums.dart'; +import 'ga_client.dart'; +import 'initializer.dart'; +import 'log_handler.dart'; +import 'session.dart'; +import 'user_property.dart'; +import 'utils.dart'; + +abstract class Analytics { + /// The default factory constructor that will return an implementation + /// of the [Analytics] abstract class using the [LocalFileSystem] + factory Analytics({ + required DashTool tool, + required String measurementId, + required String apiSecret, + String? flutterChannel, + String? flutterVersion, + required String dartVersion, + }) { + // Create the instance of the file system so clients don't need + // resolve on their own + const FileSystem fs = LocalFileSystem(); + + // Resolve the OS using dart:io + final DevicePlatform platform; + if (io.Platform.operatingSystem == 'linux') { + platform = DevicePlatform.linux; + } else if (io.Platform.operatingSystem == 'macos') { + platform = DevicePlatform.macos; + } else { + platform = DevicePlatform.windows; + } + + return AnalyticsImpl( + tool: tool.label, + homeDirectory: getHomeDirectory(fs), + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: flutterChannel, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + platform: platform, + toolsMessage: kToolsMessage, + toolsMessageVersion: kToolsMessageVersion, + fs: fs, + ); + } + + /// Factory constructor to return the [AnalyticsImpl] class with a + /// [MemoryFileSystem] to use for testing + factory Analytics.test({ + required String tool, + required Directory homeDirectory, + required String measurementId, + required String apiSecret, + String? flutterChannel, + String? flutterVersion, + required String dartVersion, + int toolsMessageVersion = kToolsMessageVersion, + String toolsMessage = kToolsMessage, + FileSystem? fs, + required DevicePlatform platform, + }) => + TestAnalytics( + tool: tool, + homeDirectory: homeDirectory, + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + platform: platform, + fs: fs ?? + MemoryFileSystem.test( + style: io.Platform.isWindows + ? FileSystemStyle.windows + : FileSystemStyle.posix, + ), + ); + + /// Returns a map object with all of the tools that have been parsed + /// out of the configuration file + Map get parsedTools; + + /// Boolean that lets the client know if they should display the message + bool get shouldShowMessage; + + /// Boolean indicating whether or not telemetry is enabled + bool get telemetryEnabled; + + /// Returns the message that should be displayed to the users if + /// [shouldShowMessage] returns true + String get toolsMessage; + + /// Returns a map representation of the [UserProperty] for the [Analytics] instance + /// + /// This is what will get sent to Google Analytics with every request + Map> get userPropertyMap; + + /// Call this method when the tool using this package is closed + /// + /// Prevents the tool from hanging when if there are still requests + /// that need to be sent off + void close(); + + /// Query the persisted event data stored on the user's machine + /// + /// Returns null if there are no persisted logs + LogFileStats? logFileStats(); + + /// API to send events to Google Analytics to track usage + Future? sendEvent({ + required DashEvent eventName, + required Map eventData, + }); + + /// Pass a boolean to either enable or disable telemetry and make + /// the necessary changes in the persisted configuration file + Future setTelemetry(bool reportingBool); +} + +class AnalyticsImpl implements Analytics { + final FileSystem fs; + late final ConfigHandler _configHandler; + late bool _showMessage; + late final GAClient _gaClient; + late final String _clientId; + late final UserProperty userProperty; + late final LogHandler _logHandler; + + @override + final String toolsMessage; + + AnalyticsImpl({ + required String tool, + required Directory homeDirectory, + required String measurementId, + required String apiSecret, + String? flutterChannel, + String? flutterVersion, + required String dartVersion, + required DevicePlatform platform, + required this.toolsMessage, + required int toolsMessageVersion, + required this.fs, + }) { + // This initializer class will let the instance know + // if it was the first run; if it is, nothing will be sent + // on the first run + final Initializer initializer = Initializer( + fs: fs, + tool: tool, + homeDirectory: homeDirectory, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + ); + initializer.run(); + _showMessage = initializer.firstRun; + + // Create the config handler that will parse the config file + _configHandler = ConfigHandler( + fs: fs, + homeDirectory: homeDirectory, + initializer: initializer, + ); + + // Initialize the config handler class and check if the + // tool message and version have been updated from what + // is in the current file; if there is a new message version + // make the necessary updates + if (!_configHandler.parsedTools.containsKey(tool)) { + _configHandler.addTool(tool: tool); + _showMessage = true; + } + if (_configHandler.parsedTools[tool]!.versionNumber < toolsMessageVersion) { + _configHandler.incrementToolVersion(tool: tool); + _showMessage = true; + } + _clientId = fs + .file(p.join( + homeDirectory.path, kDartToolDirectoryName, kClientIdFileName)) + .readAsStringSync(); + + // Create the instance of the GA Client which will create + // an [http.Client] to send requests + _gaClient = GAClient( + measurementId: measurementId, + apiSecret: apiSecret, + ); + + // Initialize the user property class that will be attached to + // each event that is sent to Google Analytics -- it will be responsible + // for getting the session id or rolling the session if the duration + // exceeds [kSessionDurationMinutes] + userProperty = UserProperty( + session: Session(homeDirectory: homeDirectory, fs: fs), + flutterChannel: flutterChannel, + host: platform.label, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + tool: tool, + ); + + // Initialize the log handler to persist events that are being sent + _logHandler = LogHandler(fs: fs, homeDirectory: homeDirectory); + } + + @override + Map get parsedTools => _configHandler.parsedTools; + + @override + bool get shouldShowMessage => _showMessage; + + @override + bool get telemetryEnabled => _configHandler.telemetryEnabled; + + @override + Map> get userPropertyMap => + userProperty.preparePayload(); + + @override + void close() => _gaClient.close(); + + @override + LogFileStats? logFileStats() => _logHandler.logFileStats(); + + @override + Future? sendEvent({ + required DashEvent eventName, + required Map eventData, + }) { + if (!telemetryEnabled) return null; + + // Construct the body of the request + final Map body = generateRequestBody( + clientId: _clientId, + eventName: eventName, + eventData: eventData, + userProperty: userProperty, + ); + + _logHandler.save(data: body); + + // Pass to the google analytics client to send + return _gaClient.sendData(body); + } + + @override + Future setTelemetry(bool reportingBool) => + _configHandler.setTelemetry(reportingBool); +} + +/// This class extends [AnalyticsImpl] and subs out any methods that +/// are not suitable for tests; the following have been altered from the +/// default implementation. All other methods are included +/// +/// - `sendEvent(...)` has been altered to prevent data from being sent to GA +/// during testing +class TestAnalytics extends AnalyticsImpl { + TestAnalytics({ + required super.tool, + required super.homeDirectory, + required super.measurementId, + required super.apiSecret, + super.flutterChannel, + super.flutterVersion, + required super.dartVersion, + required super.platform, + required super.toolsMessage, + required super.toolsMessageVersion, + required super.fs, + }); + + @override + Future? sendEvent({ + required DashEvent eventName, + required Map eventData, + }) { + if (!telemetryEnabled) return null; + + // Calling the [generateRequestBody] method will ensure that the + // session file is getting updated without actually making any + // POST requests to Google Analytics + final Map body = generateRequestBody( + clientId: _clientId, + eventName: eventName, + eventData: eventData, + userProperty: userProperty, + ); + + _logHandler.save(data: body); + + return null; + } +} diff --git a/pkgs/dash_analytics/lib/src/config_handler.dart b/pkgs/dash_analytics/lib/src/config_handler.dart new file mode 100644 index 000000000..83ecd7d8c --- /dev/null +++ b/pkgs/dash_analytics/lib/src/config_handler.dart @@ -0,0 +1,237 @@ +// 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 'package:clock/clock.dart'; +import 'package:file/file.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as p; + +import 'constants.dart'; +import 'initializer.dart'; + +/// The regex pattern used to parse the disable analytics line +const String telemetryFlagPattern = r'^reporting=([0|1]) *$'; + +/// The regex pattern used to parse the tools info +/// from the configuration file +/// +/// Example: +/// flutter-tools=2022-10-26,1 +const String toolPattern = + r'^([A-Za-z0-9]+-*[A-Za-z0-9]*)=([0-9]{4}-[0-9]{2}-[0-9]{2}),([0-9]+)$'; + +class ConfigHandler { + /// Regex pattern implementation for matching a line in the config file + /// + /// Example: + /// flutter-tools=2022-10-26,1 + static RegExp telemetryFlagRegex = + RegExp(telemetryFlagPattern, multiLine: true); + static RegExp toolRegex = RegExp(toolPattern, multiLine: true); + + /// Get a string representation of the current date in the following format + /// yyyy-MM-dd (2023-01-09) + static String get dateStamp { + return DateFormat('yyyy-MM-dd').format(clock.now()); + } + + final FileSystem fs; + final Directory homeDirectory; + final Initializer initializer; + final File configFile; + + final Map parsedTools = {}; + + late DateTime configFileLastModified; + + /// Reporting enabled unless specified by user + bool _telemetryEnabled = true; + + ConfigHandler({ + required this.fs, + required this.homeDirectory, + required this.initializer, + }) : configFile = fs.file(p.join( + homeDirectory.path, + kDartToolDirectoryName, + kConfigFileName, + )) { + // Get the last time the file was updated and check this + // datestamp whenever the client asks for the telemetry enabled boolean + configFileLastModified = configFile.lastModifiedSync(); + + // Call the method to parse the contents of the config file when + // this class is initialized + parseConfig(); + } + + /// Returns the telemetry state from the config file + /// + /// Method will reparse the config file if it detects that the + /// last modified datetime is different from what was parsed when + /// the class was initialized + bool get telemetryEnabled { + if (configFileLastModified.isBefore(configFile.lastModifiedSync())) { + parseConfig(); + configFileLastModified = configFile.lastModifiedSync(); + } + + return _telemetryEnabled; + } + + /// Responsibe for the creation of the configuration line + /// for the tool being passed in by the user and adding a + /// [ToolInfo] object + void addTool({required String tool}) { + // Create the new instance of [ToolInfo] to be added + // to the [parsedTools] map + parsedTools[tool] = ToolInfo(lastRun: clock.now(), versionNumber: 1); + + // New string to be appended to the bottom of the configuration file + // with a newline character for new tools to be added + String newTool = '$tool=$dateStamp,1\n'; + if (!configFile.readAsStringSync().endsWith('\n')) { + newTool = '\n$newTool'; + } + configFile.writeAsStringSync(newTool, mode: FileMode.append); + configFileLastModified = configFile.lastModifiedSync(); + } + + /// Will increment the version number and update the date + /// in the config file for the provided tool name while + /// also incrementing the version number in [ToolInfo] + void incrementToolVersion({required String tool}) { + if (!parsedTools.containsKey(tool)) { + return; + } + + // Read in the config file contents and use a regex pattern to + // match the line for the current tool (ie. flutter-tools=2023-01-05,1) + final String configString = configFile.readAsStringSync(); + final String pattern = '^($tool)=([0-9]{4}-[0-9]{2}-[0-9]{2}),([0-9]+)\$'; + + final RegExp regex = RegExp(pattern, multiLine: true); + final Iterable matches = regex.allMatches(configString); + + // If there isn't exactly one match for the given tool, that suggests the + // file has been altered and needs to be reset + if (matches.length != 1) { + resetConfig(); + return; + } + + final RegExpMatch match = matches.first; + + // Extract the groups from the regex match to prep for parsing + final int newVersionNumber = int.parse(match.group(3) as String) + 1; + + // Construct the new tool line for the config line and replace it + // in the original config string to prep for writing back out + final String newToolString = '$tool=$dateStamp,$newVersionNumber'; + final String newConfigString = + configString.replaceAll(regex, newToolString); + configFile.writeAsStringSync(newConfigString); + + final ToolInfo? toolInfo = parsedTools[tool]; + if (toolInfo == null) { + return; + } + + // Update the [ToolInfo] object for the current tool + toolInfo.lastRun = clock.now(); + toolInfo.versionNumber = newVersionNumber; + } + + /// Method responsible for reading in the config file stored on + /// user's machine and parsing out the following: all the tools that + /// have been logged in the file, the dates they were last run, and + /// determining if telemetry is enabled by parsing the file + void parseConfig() { + // Begin with the assumption that telemetry is always enabled + _telemetryEnabled = true; + + // Read the configuration file as a string and run the two regex patterns + // on it to get information around which tools have been parsed and whether + // or not telemetry has been disabled by the user + final String configString = configFile.readAsStringSync(); + + // Collect the tools logged in the configuration file + toolRegex.allMatches(configString).forEach((RegExpMatch element) { + // Extract the information relevant for the [ToolInfo] class + final String tool = element.group(1) as String; + final DateTime lastRun = DateTime.parse(element.group(2) as String); + final int versionNumber = int.parse(element.group(3) as String); + + // Initialize an instance of the [ToolInfo] class to store + // in the [parsedTools] map object + parsedTools[tool] = ToolInfo( + lastRun: lastRun, + versionNumber: versionNumber, + ); + }); + + // Check for lines signaling that the user has disabled analytics, + // if multiple lines are found, the more conservative value will be used + telemetryFlagRegex.allMatches(configString).forEach((RegExpMatch element) { + // Conditional for recording telemetry as being disabled + if (element.group(1) == '0') { + _telemetryEnabled = false; + } + }); + } + + /// This will reset the configuration file and clear the + /// [parsedTools] map and trigger parsing the config again + void resetConfig() { + initializer.run(forceReset: true); + parsedTools.clear(); + parseConfig(); + } + + /// Disables the reporting capabilities if false is passed + Future setTelemetry(bool reportingBool) async { + final String flag = reportingBool ? '1' : '0'; + final String configString = await configFile.readAsString(); + + final Iterable matches = + telemetryFlagRegex.allMatches(configString); + + // If there isn't exactly one match for the reporting flag, that suggests the + // file has been altered and needs to be reset + if (matches.length != 1) { + resetConfig(); + return; + } + + final String newTelemetryString = 'reporting=$flag'; + + final String newConfigString = + configString.replaceAll(telemetryFlagRegex, newTelemetryString); + + await configFile.writeAsString(newConfigString); + configFileLastModified = configFile.lastModifiedSync(); + + _telemetryEnabled = reportingBool; + } +} + +class ToolInfo { + DateTime lastRun; + int versionNumber; + + ToolInfo({ + required this.lastRun, + required this.versionNumber, + }); + + @override + String toString() { + return json.encode({ + 'lastRun': DateFormat('yyyy-MM-dd').format(lastRun), + 'versionNumber': versionNumber, + }); + } +} diff --git a/pkgs/dash_analytics/lib/src/constants.dart b/pkgs/dash_analytics/lib/src/constants.dart new file mode 100644 index 000000000..78e3a1a85 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/constants.dart @@ -0,0 +1,93 @@ +// 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. + +/// URL endpoint for sending Google Analytics Events +const String kAnalyticsUrl = 'https://www.google-analytics.com/mp/collect'; + +/// Name for the text file that will contain the user's randomly generated +/// client id +const String kClientIdFileName = 'CLIENT_ID'; + +/// Name for the file where telemetry status and tools data will be stored +const String kConfigFileName = 'dash-analytics.config'; + +/// The string that will provide the boilerplate for the configuration file +/// stored on the user's machine +const String kConfigString = ''' +# INTRODUCTION +# +# This is the Flutter and Dart telemetry reporting +# configuration file. +# +# Lines starting with a #" are documentation that +# the tools maintain automatically. +# +# All other lines are configuration lines. They have +# the form "name=value". If multiple lines contain +# the same configuration name with different values, +# the parser will default to a conservative value. + +# DISABLING TELEMETRY REPORTING +# +# To disable telemetry reporting, set "reporting" to +# the value "0" and to enable, set to "1": +reporting=1 + +# NOTIFICATIONS +# +# Each tool records when it last informed the user about +# analytics reporting and the privacy policy. +# +# The following tools have so far read this file: +# +# dart-tools (Dart CLI developer tool) +# devtools (DevTools debugging and performance tools) +# flutter-tools (Flutter CLI developer tool) +# +# For each one, the file may contain a configuration line +# where the name is the code in the list above, e.g. "dart-tool", +# and the value is a date in the form YYYY-MM-DD, a comma, and +# a number representing the version of the message that was +# displayed.'''; + +/// Name of the directory where all of the files necessary for this package +/// will be located +const String kDartToolDirectoryName = '.dart'; + +/// How many data records to store in the log file +const int kLogFileLength = 2500; + +/// Filename for the log file to persist sent events on user's machine +const String kLogFileName = 'dash-analytics.log'; + +/// The current version of the package, should be in line with pubspec version +const String kPackageVersion = '0.1.0'; + +/// The minimum length for a session +const int kSessionDurationMinutes = 30; + +/// Name for the json file where the session details will be stored +const String kSessionFileName = 'dash-analytics-session.json'; + +/// The message that should be shown to the user +const String kToolsMessage = ''' +Flutter and Dart related tooling uses Google Analytics to report usage and +diagnostic data along with package dependencies, and crash reporting to +send basic crash reports. This data is used to help improve the Dart +platform, Flutter framework, and related tools. + +Telemetry is not sent on the very first run. +To disable reporting of telemetry, run this terminal command: + +[dart|flutter] --disable-telemetry. +If you opt out of telemetry, an opt-out event will be sent, and then no further +information will be sent. This data is collected in accordance with the +Google Privacy Policy (https://policies.google.com/privacy). +'''; + +/// The version number for the message below +/// +/// If the message below is altered, the version should be incremented so that +/// users can be prompted with the updated messaging +const int kToolsMessageVersion = 1; diff --git a/pkgs/dash_analytics/lib/src/enums.dart b/pkgs/dash_analytics/lib/src/enums.dart new file mode 100644 index 000000000..cab435abe --- /dev/null +++ b/pkgs/dash_analytics/lib/src/enums.dart @@ -0,0 +1,57 @@ +// 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. + +/// Values for the event name to be sent to Google Analytics +/// +/// The [label] for each enum value is what will be logged, the [description] +/// is here for documentation purposes +enum DashEvent { + hotReloadTime( + label: 'hot_reload_time', + description: 'Hot reload duration', + toolOwner: DashTool.flutterTools, + ), + ; + + final String label; + final String description; + final DashTool toolOwner; + const DashEvent({ + required this.label, + required this.description, + required this.toolOwner, + }); +} + +/// Officially-supported clients of this package. +/// +/// All [label] values should use a hyphen as a delimiter +enum DashTool { + flutterTools( + label: 'flutter-tools', + description: 'Runs flutter applications from CLI', + ), + dartAnalyzer( + label: 'dart-analyzer', + description: 'Analyzes dart code in workspace', + ); + + final String label; + final String description; + const DashTool({ + required this.label, + required this.description, + }); +} + +/// Enumerate options for platform +enum DevicePlatform { + windows('Windows'), + macos('macOS'), + linux('Linux'), + ; + + final String label; + const DevicePlatform(this.label); +} diff --git a/pkgs/dash_analytics/lib/src/ga_client.dart b/pkgs/dash_analytics/lib/src/ga_client.dart new file mode 100644 index 000000000..ad4cfd741 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/ga_client.dart @@ -0,0 +1,38 @@ +// 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 'package:http/http.dart' as http; + +import 'constants.dart'; + +class GAClient { + final String measurementId; + final String apiSecret; + final String postUrl; + final http.Client _client; + + GAClient({ + required this.measurementId, + required this.apiSecret, + }) : postUrl = + '$kAnalyticsUrl?measurement_id=$measurementId&api_secret=$apiSecret', + _client = http.Client(); + + /// Closes the http client's connection to prevent lingering requests + void close() => _client.close(); + + /// Receive the payload in Map form and parse + /// into JSON to send to GA + Future sendData(Map body) { + return _client.post( + Uri.parse(postUrl), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode(body), + ); + } +} diff --git a/pkgs/dash_analytics/lib/src/initializer.dart b/pkgs/dash_analytics/lib/src/initializer.dart new file mode 100644 index 000000000..d6eca5000 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/initializer.dart @@ -0,0 +1,134 @@ +// 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 'package:clock/clock.dart'; +import 'package:file/file.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart' as p; + +import 'constants.dart'; +import 'utils.dart'; + +class Initializer { + final FileSystem fs; + final String tool; + final Directory homeDirectory; + final int toolsMessageVersion; + final String toolsMessage; + bool firstRun = false; + + /// Responsibe for the initialization of the files + /// necessary for analytics reporting + /// + /// Creates the configuration file that allows the user to + /// mannually opt out of reporting along with the file containing + /// the client ID to be used across all relevant tooling + /// + /// Updating of the config file with new versions will + /// not be handled by the [Initializer] + Initializer({ + required this.fs, + required this.tool, + required this.homeDirectory, + required this.toolsMessageVersion, + required this.toolsMessage, + }); + + /// Get a string representation of the current date in the following format + /// yyyy-MM-dd (2023-01-09) + String get dateStamp { + return DateFormat('yyyy-MM-dd').format(clock.now()); + } + + /// Creates the text file that will contain the client ID + /// which will be used across all related tools for analytics + /// reporting in GA + void createClientIdFile({required File clientFile}) { + clientFile.createSync(recursive: true); + clientFile.writeAsStringSync(Uuid().generateV4()); + } + + /// Creates the configuration file with the default message + /// in the user's home directory + void createConfigFile({ + required File configFile, + required String dateStamp, + required String tool, + required String toolsMessage, + required int toolsMessageVersion, + }) { + configFile.createSync(recursive: true); + configFile.writeAsStringSync(''' +$kConfigString +$tool=$dateStamp,$toolsMessageVersion +'''); + } + + /// Creates that log file that will store the record formatted + /// events locally on the user's machine + void createLogFile({required File logFile}) { + logFile.createSync(recursive: true); + } + + /// Creates the session json file which will contain + /// the current session id along with the timestamp for + /// the last ping which will be used to increment the session + /// if current timestamp is greater than the session window + void createSessionFile({required File sessionFile}) { + final DateTime now = clock.now(); + sessionFile.createSync(recursive: true); + sessionFile.writeAsStringSync(jsonEncode({ + 'session_id': now.millisecondsSinceEpoch, + 'last_ping': now.millisecondsSinceEpoch, + })); + } + + /// This will check that there is a client ID populated in + /// the user's home directory under the dart-tool directory. + /// If it doesn't exist, one will be created there + /// + /// Passing [forceReset] as true will only reset the configuration + /// file, it won't recreate the client id and session files + void run({bool forceReset = false}) { + // Begin by checking for the config file + final File configFile = fs.file( + p.join(homeDirectory.path, kDartToolDirectoryName, kConfigFileName)); + + // When the config file doesn't exist, initialize it with the default tools + // and the current date + if (!configFile.existsSync() || forceReset) { + firstRun = true; + createConfigFile( + configFile: configFile, + dateStamp: dateStamp, + tool: tool, + toolsMessage: toolsMessage, + toolsMessageVersion: toolsMessageVersion, + ); + } + + // Begin initialization checks for the client id + final File clientFile = fs.file( + p.join(homeDirectory.path, kDartToolDirectoryName, kClientIdFileName)); + if (!clientFile.existsSync()) { + createClientIdFile(clientFile: clientFile); + } + + // Begin initialization checks for the session file + final File sessionFile = fs.file( + p.join(homeDirectory.path, kDartToolDirectoryName, kSessionFileName)); + if (!sessionFile.existsSync()) { + createSessionFile(sessionFile: sessionFile); + } + + // Begin initialization checks for the log file to persist events locally + final File logFile = fs + .file(p.join(homeDirectory.path, kDartToolDirectoryName, kLogFileName)); + if (!logFile.existsSync()) { + createLogFile(logFile: logFile); + } + } +} diff --git a/pkgs/dash_analytics/lib/src/log_handler.dart b/pkgs/dash_analytics/lib/src/log_handler.dart new file mode 100644 index 000000000..872fe3139 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/log_handler.dart @@ -0,0 +1,261 @@ +// 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 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'constants.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; + + /// The latest timestamp in the log file + final DateTime endDateTime; + + /// The number of unique session ids found in the log file + final int sessionCount; + + /// The number of unique flutter channels found in the log file + final int flutterChannelCount; + + /// The number of unique tools found in the log file + final int toolCount; + + /// Contains the data from the [LogHandler.logFileStats] method + const LogFileStats({ + required this.startDateTime, + required this.endDateTime, + required this.sessionCount, + required this.flutterChannelCount, + required this.toolCount, + }); + + @override + String toString() => jsonEncode({ + 'startDateTime': startDateTime.toString(), + 'endDateTime': endDateTime.toString(), + 'sessionCount': sessionCount, + 'flutterChannelCount': flutterChannelCount, + 'toolCount': toolCount, + }); +} + +/// This class is responsible for writing to a log +/// file that has been initialized by the [Initializer] +/// +/// It will be treated as an append only log and will be limited +/// to have has many data records as specified by [kLogFileLength] +class LogHandler { + final FileSystem fs; + final Directory homeDirectory; + final File logFile; + + /// A log handler constructor that will delegate saving + /// logs and retrieving stats from the persisted log + LogHandler({ + required this.fs, + required this.homeDirectory, + }) : logFile = fs.file(p.join( + homeDirectory.path, + kDartToolDirectoryName, + kLogFileName, + )); + + /// 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` + final List records = logFile + .readAsLinesSync() + .map((String e) => LogItem.fromRecord(jsonDecode(e))) + .whereType() + .toList(); + + if (records.isEmpty) return null; + + // Get the start and end dates for the log file + final DateTime startDateTime = records.first.localTime; + final DateTime endDateTime = records.last.localTime; + + // Collection of unique sessions + final Map> counter = >{ + 'sessions': {}, + 'flutter_channel': {}, + 'tool': {}, + }; + for (LogItem record in records) { + counter['sessions']!.add(record.sessionId); + counter['tool']!.add(record.tool); + if (record.flutterChannel != null) { + counter['flutter_channel']!.add(record.flutterChannel!); + } + } + + return LogFileStats( + startDateTime: startDateTime, + endDateTime: endDateTime, + sessionCount: counter['sessions']!.length, + flutterChannelCount: counter['flutter_channel']!.length, + toolCount: counter['tool']!.length, + ); + } + + /// Saves the data passed in as a single line in the log file + /// + /// This will keep the max number of records limited to equal to + /// or less than [kLogFileLength] records + void save({required Map data}) { + List records = logFile.readAsLinesSync(); + final String content = '${jsonEncode(data)}\n'; + + // When the record count is less than the max, add as normal; + // else drop the oldest records until equal to max + if (records.length < kLogFileLength) { + logFile.writeAsStringSync(content, mode: FileMode.writeOnlyAppend); + } else { + records.add(content); + records = records.skip(records.length - kLogFileLength).toList(); + + logFile.writeAsStringSync(records.join('\n')); + } + } +} + +/// Data class for each record persisted on the client's machine +class LogItem { + final int sessionId; + final String? flutterChannel; + final String host; + final String? flutterVersion; + final String dartVersion; + final String tool; + final DateTime localTime; + + LogItem({ + required this.sessionId, + this.flutterChannel, + required this.host, + this.flutterVersion, + required this.dartVersion, + required this.tool, + required this.localTime, + }); + + /// 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": "d40133a0-7ea6-4347-b668-ffae94bb8774", + /// "events": [ + /// { + /// "name": "hot_reload_time", + /// "params": { + /// "time_ns": 345 + /// } + /// } + /// ], + /// "user_properties": { + /// "session_id": { + /// "value": 1675193534342 + /// }, + /// "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" + /// }, + /// "tool": { + /// "value": "flutter-tools" + /// }, + /// "local_time": { + /// "value": "2023-01-31 14:32:14.592898" + /// } + /// } + /// } + /// ``` + static LogItem? fromRecord(Map record) { + if (!record.containsKey('user_properties')) return null; + + // Using a try/except here to parse out the fields if possible, + // if not, it will quietly return null and won't get processed + // downstream + try { + // Parse the data out of the `user_properties` value + final Map userProps = + record['user_properties'] as Map; + + // Parse out the values from the top level key = 'user_properties` + final int? sessionId = + (userProps['session_id']! as Map)['value'] as int?; + final String? flutterChannel = (userProps['flutter_channel']! + as Map)['value'] as String?; + final String? host = + (userProps['host']! as Map)['value'] as String?; + final String? flutterVersion = (userProps['flutter_version']! + as Map)['value'] as String?; + final String? dartVersion = (userProps['dart_version']! + as Map)['value'] as String?; + final String? tool = + (userProps['tool']! as Map)['value'] as String?; + final String? localTimeString = (userProps['local_time']! + as Map)['value'] as String?; + + // If any of the above values are null, return null since that + // indicates the record is malformed; note that `flutter_version` + // and `flutter_channel` are nullable fields in the log file + final List values = [ + sessionId, + host, + dartVersion, + tool, + localTimeString, + ]; + for (Object? value in values) { + if (value == null) return null; + } + + // Parse the local time from the string extracted + final DateTime localTime = DateTime.parse(localTimeString!); + + return LogItem( + sessionId: sessionId!, + flutterChannel: flutterChannel, + host: host!, + flutterVersion: flutterVersion, + dartVersion: dartVersion!, + tool: tool!, + localTime: localTime, + ); + } on TypeError { + return null; + } on FormatException { + return null; + } + } +} diff --git a/pkgs/dash_analytics/lib/src/session.dart b/pkgs/dash_analytics/lib/src/session.dart new file mode 100644 index 000000000..6a6bf6b22 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/session.dart @@ -0,0 +1,108 @@ +// 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 'package:clock/clock.dart'; +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'constants.dart'; + +class Session { + final Directory homeDirectory; + final FileSystem fs; + final File _sessionFile; + int _sessionId; + int _lastPing; + + /// This constructor will go to the session json file in + /// the user's home directory and extract the necessary fields from + /// the json file + factory Session({ + required Directory homeDirectory, + required FileSystem fs, + }) { + final File sessionFile = fs.file( + p.join(homeDirectory.path, kDartToolDirectoryName, kSessionFileName)); + + final String sessionFileContents = sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + final int sessionId = sessionObj['session_id'] as int; + final int lastPing = sessionObj['last_ping'] as int; + + return Session._( + homeDirectory: homeDirectory, + fs: fs, + sessionFile: sessionFile, + sessionId: sessionId, + lastPing: lastPing, + ); + } + + /// Private constructor that will have the variables necessary already parsed + Session._({ + required this.homeDirectory, + required this.fs, + required File sessionFile, + required int sessionId, + required int lastPing, + }) : _sessionFile = sessionFile, + _sessionId = sessionId, + _lastPing = lastPing; + + /// This will use the data parsed from the + /// session json file in the dart-tool directory + /// to get the session id if the last ping was within + /// [sessionDurationMinutes] + /// + /// If time since last ping exceeds the duration, then the file + /// will be updated with a new session id and that will be returned + /// + /// Note, the file will always be updated when calling this method + /// because the last ping variable will always need to be persisted + int getSessionId() { + _refreshSessionData(); + final DateTime now = clock.now(); + + // Convert the epoch time from the last ping into datetime and + // check if we are within the [sessionDurationMinutes] + final DateTime lastPingDateTime = + DateTime.fromMillisecondsSinceEpoch(_lastPing); + if (now.difference(lastPingDateTime).inMinutes > kSessionDurationMinutes) { + // In this case, we will need to change both the session id + // and the last ping value + _sessionId = now.millisecondsSinceEpoch; + } + + // Update the last ping to reflect session activity continuing + _lastPing = now.millisecondsSinceEpoch; + + // Rewrite the session object back to the file to persist + // for future events + _sessionFile.writeAsStringSync(toJson()); + + return _sessionId; + } + + /// Return a json formatted representation of the class + String toJson() => jsonEncode({ + 'session_id': _sessionId, + 'last_ping': _lastPing, + }); + + /// This will go to the session file within the dart-tool + /// directory and fetch the latest data from the json to update + /// the class's variables + /// + /// This allows the session data in this class to always be up + /// to date incase another tool is also calling this package and + /// making updates to the session file + void _refreshSessionData() { + final String sessionFileContents = _sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + _sessionId = sessionObj['session_id'] as int; + _lastPing = sessionObj['last_ping'] as int; + } +} diff --git a/pkgs/dash_analytics/lib/src/user_property.dart b/pkgs/dash_analytics/lib/src/user_property.dart new file mode 100644 index 000000000..8da04fbc9 --- /dev/null +++ b/pkgs/dash_analytics/lib/src/user_property.dart @@ -0,0 +1,63 @@ +// 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 'package:clock/clock.dart'; + +import 'constants.dart'; +import 'session.dart'; + +class UserProperty { + final Session session; + final String? flutterChannel; + final String host; + final String? flutterVersion; + final String dartVersion; + final String tool; + + /// This class is intended to capture all of the user's + /// metadata when the class gets initialized as well as collecting + /// session data to send in the json payload to Google Analytics + UserProperty({ + required this.session, + required this.flutterChannel, + required this.host, + required this.flutterVersion, + required this.dartVersion, + required this.tool, + }); + + /// This method will take the data in this class and convert it into + /// a Map that is suitable for the POST request schema + /// + /// This will call the [Session] object's [getSessionId] method which will + /// update the session file and get a new session id if necessary + /// + /// https://developers.google.com/analytics/devguides/collection/protocol/ga4/user-properties?client_type=gtag + Map> preparePayload() { + return >{ + for (MapEntry entry in _toMap().entries) + entry.key: {'value': entry.value} + }; + } + + @override + String toString() { + return jsonEncode(_toMap()); + } + + /// Convert the data stored in this class into a map while also + /// getting the latest session id using the [Session] class + Map _toMap() => { + 'session_id': session.getSessionId(), + 'flutter_channel': flutterChannel, + 'host': host, + 'flutter_version': flutterVersion, + 'dart_version': dartVersion, + 'dash_analytics_version': kPackageVersion, + 'tool': tool, + 'local_time': '${clock.now()}', + }; +} diff --git a/pkgs/dash_analytics/lib/src/utils.dart b/pkgs/dash_analytics/lib/src/utils.dart new file mode 100644 index 000000000..91f81191d --- /dev/null +++ b/pkgs/dash_analytics/lib/src/utils.dart @@ -0,0 +1,103 @@ +// 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:io' as io; +import 'dart:math' show Random; + +import 'package:file/file.dart'; + +import 'enums.dart'; +import 'user_property.dart'; + +/// Construct the Map that will be converted to json for the +/// body of the request +/// +/// Follows the following schema +/// +/// ``` +/// { +/// "client_id": "46cc0ba6-f604-4fd9-aa2f-8a20beb24cd4", +/// "events": [{ "name": "testing_from_dash", "params": { "time_ns": 345 } }], +/// "user_properties": { +/// "session_id": { "value": 1673466750423 }, +/// "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" }, +/// "tool": { "value": "flutter-tools" }, +/// "local_time": { "value": "2023-01-11 14:53:31.471816" } +/// } +/// } +/// ``` +/// https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag +Map generateRequestBody({ + required String clientId, + required DashEvent eventName, + required Map eventData, + required UserProperty userProperty, +}) => + { + 'client_id': clientId, + 'events': >[ + { + 'name': eventName.label, + 'params': eventData, + } + ], + 'user_properties': userProperty.preparePayload() + }; + +/// This will use environment variables to get the user's +/// home directory where all the directory will be created that will +/// contain all of the analytics files +Directory getHomeDirectory(FileSystem fs) { + String? home; + Map envVars = io.Platform.environment; + + if (io.Platform.isMacOS) { + home = envVars['HOME']; + } else if (io.Platform.isLinux) { + home = envVars['HOME']; + } else if (io.Platform.isWindows) { + home = envVars['UserProfile']; + } + + return fs.directory(home!); +} + +/// A UUID generator. +/// +/// This will generate unique IDs in the format: +/// +/// f47ac10b-58cc-4372-a567-0e02b2c3d479 +/// +/// The generated uuids are 128 bit numbers encoded in a specific string format. +/// For more information, see +/// [en.wikipedia.org/wiki/Universally_unique_identifier](http://en.wikipedia.org/wiki/Universally_unique_identifier). +/// +/// This class was taken from the previous `usage` package (https://github.com/dart-lang/usage/blob/master/lib/uuid/uuid.dart) +class Uuid { + final Random _random = Random(); + + /// Generate a version 4 (random) uuid. This is a uuid scheme that only uses + /// random numbers as the source of the generated uuid. + String generateV4() { + // Generate xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx / 8-4-4-4-12. + int special = 8 + _random.nextInt(4); + + return '${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}-' + '${_bitsDigits(16, 4)}-' + '4${_bitsDigits(12, 3)}-' + '${_printDigits(special, 1)}${_bitsDigits(12, 3)}-' + '${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}${_bitsDigits(16, 4)}'; + } + + String _bitsDigits(int bitCount, int digitCount) => + _printDigits(_generateBits(bitCount), digitCount); + + int _generateBits(int bitCount) => _random.nextInt(1 << bitCount); + + String _printDigits(int value, int count) => + value.toRadixString(16).padLeft(count, '0'); +} diff --git a/pkgs/dash_analytics/pubspec.yaml b/pkgs/dash_analytics/pubspec.yaml new file mode 100644 index 000000000..8b23d04f6 --- /dev/null +++ b/pkgs/dash_analytics/pubspec.yaml @@ -0,0 +1,19 @@ +name: dash_analytics +description: A package for logging analytics for all dash related tooling to Google Analytics +version: 0.1.0 +repository: https://github.com/dart-lang/tools + +environment: + sdk: '>=2.19.0 <3.0.0' + +dependencies: + clock: ^1.1.1 + file: ^6.1.4 + http: ^0.13.5 + intl: ^0.17.0 + path: ^1.8.0 + +dev_dependencies: + lints: ^2.0.0 + test: ^1.16.0 + yaml: ^3.1.1 diff --git a/pkgs/dash_analytics/test/dash_analytics_test.dart b/pkgs/dash_analytics/test/dash_analytics_test.dart new file mode 100644 index 000000000..426e30a66 --- /dev/null +++ b/pkgs/dash_analytics/test/dash_analytics_test.dart @@ -0,0 +1,854 @@ +// 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:file/memory.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import 'package:dash_analytics/dash_analytics.dart'; +import 'package:dash_analytics/src/config_handler.dart'; +import 'package:dash_analytics/src/constants.dart'; +import 'package:dash_analytics/src/session.dart'; +import 'package:dash_analytics/src/user_property.dart'; +import 'package:dash_analytics/src/utils.dart'; + +void main() { + late FileSystem fs; + late Directory home; + late Directory dartToolDirectory; + late Analytics analytics; + late File clientIdFile; + late File sessionFile; + late File configFile; + late File logFile; + late UserProperty userProperty; + + const String homeDirName = 'home'; + const String initialToolName = 'initialTool'; + const String secondTool = 'newTool'; + const String measurementId = 'measurementId'; + const String apiSecret = 'apiSecret'; + const int toolsMessageVersion = 1; + const String toolsMessage = 'toolsMessage'; + const String flutterChannel = 'flutterChannel'; + const String flutterVersion = 'flutterVersion'; + const String dartVersion = 'dartVersion'; + const DevicePlatform platform = DevicePlatform.macos; + + setUp(() { + // Setup the filesystem with the home directory + final FileSystemStyle fsStyle = + io.Platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix; + fs = MemoryFileSystem.test(style: fsStyle); + home = fs.directory(homeDirName); + dartToolDirectory = home.childDirectory(kDartToolDirectoryName); + + // The main analytics instance, other instances can be spawned within tests + // to test how to instances running together work + analytics = Analytics.test( + tool: initialToolName, + homeDirectory: home, + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + fs: fs, + platform: platform, + ); + + // The 3 files that should have been generated + clientIdFile = home + .childDirectory(kDartToolDirectoryName) + .childFile(kClientIdFileName); + sessionFile = + home.childDirectory(kDartToolDirectoryName).childFile(kSessionFileName); + configFile = + home.childDirectory(kDartToolDirectoryName).childFile(kConfigFileName); + logFile = + home.childDirectory(kDartToolDirectoryName).childFile(kLogFileName); + + // Create the user property object that is also + // created within analytics for testing + userProperty = UserProperty( + session: Session(homeDirectory: home, fs: fs), + flutterChannel: flutterChannel, + host: platform.label, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + tool: initialToolName, + ); + }); + + test('Initializer properly sets up on first run', () { + expect(dartToolDirectory.existsSync(), true, + reason: 'The directory should have been created'); + expect(clientIdFile.existsSync(), true, + reason: 'The $kClientIdFileName file was not found'); + expect(sessionFile.existsSync(), true, + reason: 'The $kSessionFileName file was not found'); + expect(configFile.existsSync(), true, + reason: 'The $kConfigFileName was not found'); + expect(logFile.existsSync(), true, + reason: 'The $kLogFileName file was not found'); + expect(dartToolDirectory.listSync().length, equals(4), + reason: + 'There should only be 4 files in the $kDartToolDirectoryName directory'); + expect(analytics.shouldShowMessage, true, + reason: 'For the first run, analytics should default to being enabled'); + }); + + test('New tool is successfully added to config file', () { + // Create a new instance of the analytics class with the new tool + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: 'ey-test-channel', + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + expect(secondAnalytics.parsedTools.length, equals(2), + reason: 'There should be only 2 tools that have ' + 'been parsed into the config file'); + expect(secondAnalytics.parsedTools.containsKey(initialToolName), true, + reason: 'The first tool: $initialToolName should be in the map'); + expect(secondAnalytics.parsedTools.containsKey(secondTool), true, + reason: 'The second tool: $secondAnalytics should be in the map'); + expect(configFile.readAsStringSync().startsWith(kConfigString), true, + reason: + 'The config file should have the same message from the constants file'); + }); + + test('Toggling telemetry boolean through Analytics class api', () async { + expect(analytics.telemetryEnabled, true, + reason: 'Telemetry should be enabled by default ' + 'when initialized for the first time'); + + // Use the API to disable analytics + await analytics.setTelemetry(false); + expect(analytics.telemetryEnabled, false, + reason: 'Analytics telemetry should be disabled'); + + // Toggle it back to being enabled + await analytics.setTelemetry(true); + expect(analytics.telemetryEnabled, true, + reason: 'Analytics telemetry should be enabled'); + }); + + test( + 'Telemetry has been disabled by one ' + 'tool and second tool correctly shows telemetry is disabled', () async { + expect(analytics.telemetryEnabled, true, + reason: 'Analytics telemetry should be enabled on initialization'); + // Use the API to disable analytics + await analytics.setTelemetry(false); + expect(analytics.telemetryEnabled, false, + reason: 'Analytics telemetry should be disabled'); + + // Initialize a second analytics class, which simulates a second tool + // Create a new instance of the analytics class with the new tool + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: 'ey-test-channel', + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + expect(secondAnalytics.telemetryEnabled, false, + reason: 'Analytics telemetry should be disabled by the first class ' + 'and the second class should show telemetry is disabled'); + }); + + test( + 'Two concurrent instances are running ' + 'and reflect an accurate up to date telemetry status', () async { + // Initialize a second analytics class, which simulates a second tool + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: 'ey-test-channel', + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + expect(analytics.telemetryEnabled, true, + reason: 'Telemetry should be enabled on initialization for ' + 'first analytics instance'); + expect(secondAnalytics.telemetryEnabled, true, + reason: 'Telemetry should be enabled on initialization for ' + 'second analytics instance'); + + // Use the API to disable analytics on the first instance + await analytics.setTelemetry(false); + expect(analytics.telemetryEnabled, false, + reason: 'Analytics telemetry should be disabled on first instance'); + + expect(secondAnalytics.telemetryEnabled, false, + reason: 'Analytics telemetry should be disabled by the first class ' + 'and the second class should show telemetry is disabled' + ' by checking the timestamp on the config file'); + }); + + test('New line character is added if missing', () { + String currentConfigFileString; + + expect(configFile.readAsStringSync().endsWith('\n'), true, + reason: 'When initialized, the tool should correctly ' + 'add a trailing new line character'); + + // Remove the trailing new line character before initializing a second + // analytics class; the new class should correctly format the config file + currentConfigFileString = configFile.readAsStringSync(); + currentConfigFileString = currentConfigFileString.substring( + 0, currentConfigFileString.length - 1); + + // Write back out to the config file to be processed again + configFile.writeAsStringSync(currentConfigFileString); + + expect(configFile.readAsStringSync().endsWith('\n'), false, + reason: 'The trailing new line should be missing'); + + // Initialize a second analytics class, which simulates a second tool + // which should correct the missing trailing new line character + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: 'ey-test-channel', + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + expect(secondAnalytics.telemetryEnabled, true); + + expect(configFile.readAsStringSync().endsWith('\n'), true, + reason: 'The second analytics class will correct ' + 'the missing new line character'); + }); + + test('Incrementing the version for a tool is successful', () { + expect(analytics.parsedTools[initialToolName]?.versionNumber, + toolsMessageVersion, + reason: 'On initialization, the first version number should ' + 'be what is set in the setup method'); + + // Initialize a second analytics class for the same tool as + // the first analytics instance except with a newer version for + // the tools message and version + final Analytics secondAnalytics = Analytics.test( + tool: initialToolName, + homeDirectory: home, + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion + 1, + toolsMessage: toolsMessage, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + fs: fs, + platform: platform, + ); + + expect(secondAnalytics.parsedTools[initialToolName]?.versionNumber, + toolsMessageVersion + 1, + reason: + 'The second analytics instance should have incremented the version'); + }); + + test( + 'Config file resets when there is not exactly one match for the reporting flag', + () async { + // Write to the config file a string that is not formatted correctly + // (ie. there is more than one match for the reporting flag) + configFile.writeAsStringSync(''' +# INTRODUCTION +# +# This is the Flutter and Dart telemetry reporting +# configuration file. +# +# Lines starting with a #" are documentation that +# the tools maintain automatically. +# +# All other lines are configuration lines. They have +# the form "name=value". If multiple lines contain +# the same configuration name with different values, +# the parser will default to a conservative value. + +# DISABLING TELEMETRY REPORTING +# +# To disable telemetry reporting, set "reporting" to +# the value "0" and to enable, set to "1": +reporting=1 +reporting=1 + +# NOTIFICATIONS +# +# Each tool records when it last informed the user about +# analytics reporting and the privacy policy. +# +# The following tools have so far read this file: +# +# dart-tools (Dart CLI developer tool) +# devtools (DevTools debugging and performance tools) +# flutter-tools (Flutter CLI developer tool) +# +# For each one, the file may contain a configuration line +# where the name is the code in the list above, e.g. "dart-tool", +# and the value is a date in the form YYYY-MM-DD, a comma, and +# a number representing the version of the message that was +# displayed.'''); + + // Disable telemetry which should result in a reset of the config file + await analytics.setTelemetry(false); + + expect(configFile.readAsStringSync().startsWith(kConfigString), true, + reason: 'The tool should have reset the config file ' + 'because it was not formatted correctly'); + }); + + test('Config file resets when there is not exactly one match for the tool', + () { + // Write to the config file a string that is not formatted correctly + // (ie. there is more than one match for the reporting flag) + configFile.writeAsStringSync(''' +# INTRODUCTION +# +# This is the Flutter and Dart telemetry reporting +# configuration file. +# +# Lines starting with a #" are documentation that +# the tools maintain automatically. +# +# All other lines are configuration lines. They have +# the form "name=value". If multiple lines contain +# the same configuration name with different values, +# the parser will default to a conservative value. + +# DISABLING TELEMETRY REPORTING +# +# To disable telemetry reporting, set "reporting" to +# the value "0" and to enable, set to "1": +reporting=1 + +# NOTIFICATIONS +# +# Each tool records when it last informed the user about +# analytics reporting and the privacy policy. +# +# The following tools have so far read this file: +# +# dart-tools (Dart CLI developer tool) +# devtools (DevTools debugging and performance tools) +# flutter-tools (Flutter CLI developer tool) +# +# For each one, the file may contain a configuration line +# where the name is the code in the list above, e.g. "dart-tool", +# and the value is a date in the form YYYY-MM-DD, a comma, and +# a number representing the version of the message that was +# displayed. +$initialToolName=${ConfigHandler.dateStamp},$toolsMessageVersion +$initialToolName=${ConfigHandler.dateStamp},$toolsMessageVersion +'''); + + // Initialize a second analytics class for the same tool as + // the first analytics instance except with a newer version for + // the tools message and version + // + // This second instance should reset the config file when it goes + // to increment the version in the file + final Analytics secondAnalytics = Analytics.test( + tool: initialToolName, + homeDirectory: home, + measurementId: measurementId, + apiSecret: apiSecret, + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion + 1, + toolsMessage: toolsMessage, + flutterVersion: flutterVersion, + dartVersion: dartVersion, + fs: fs, + platform: platform, + ); + + expect( + configFile.readAsStringSync().endsWith( + '# displayed.\n$initialToolName=${ConfigHandler.dateStamp},${toolsMessageVersion + 1}\n'), + true, + reason: 'The config file ends with the correctly formatted ending ' + 'after removing the duplicate lines for a given tool', + ); + expect( + secondAnalytics.parsedTools[initialToolName]?.versionNumber, + toolsMessageVersion + 1, + reason: 'The new version should have been incremented', + ); + }); + + test('Check that UserProperty class has all the necessary keys', () { + const List userPropertyKeys = [ + 'session_id', + 'flutter_channel', + 'host', + 'flutter_version', + 'dart_version', + 'dash_analytics_version', + 'tool', + 'local_time', + ]; + expect(analytics.userPropertyMap.keys.length, userPropertyKeys.length, + reason: 'There should only be ${userPropertyKeys.length} keys'); + for (String key in userPropertyKeys) { + expect(analytics.userPropertyMap.keys.contains(key), true, + reason: 'The $key variable is required'); + } + }); + + test('The minimum session duration should be at least 30 minutes', () { + expect(kSessionDurationMinutes < 30, false, + reason: 'Session is less than 30 minutes'); + }); + + test( + 'The session id stays the same when duration' + ' is less than the constraint', () { + // For this test, we will need control clock time so we will delete + // the [dartToolDirectory] and all of its contents and reconstruct a + // new [Analytics] instance at a specific time + dartToolDirectory.deleteSync(recursive: true); + expect(dartToolDirectory.existsSync(), false, + reason: 'The directory should have been cleared'); + + // Define the initial time to start + final DateTime start = DateTime(1995, 3, 3, 12, 0); + + // Set the clock to the start value defined above + withClock(Clock.fixed(start), () { + // This class will be constructed at a fixed time + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Read the contents of the session file + final String sessionFileContents = sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + + expect(secondAnalytics.userPropertyMap['session_id']?['value'], + start.millisecondsSinceEpoch); + expect(sessionObj['last_ping'], start.millisecondsSinceEpoch); + }); + + // Add time to the start time that is less than the duration + final DateTime end = + start.add(Duration(minutes: kSessionDurationMinutes - 1)); + + // Use a new clock to ensure that the session id didn't change + withClock(Clock.fixed(end), () { + // A new instance will need to be created since the second + // instance in the previous block is scoped - this new instance + // should not reset the files generated by the second instance + final Analytics thirdAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Calling the send event method will result in the session file + // getting updated but because we use the `Analytics.test()` constructor + // no events will be sent + thirdAnalytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + + // Read the contents of the session file + final String sessionFileContents = sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + + expect(thirdAnalytics.userPropertyMap['session_id']?['value'], + start.millisecondsSinceEpoch, + reason: 'The session id should not have changed since it was made ' + 'within the duration'); + expect(sessionObj['last_ping'], end.millisecondsSinceEpoch, + reason: 'The last_ping value should have been updated'); + }); + }); + + test('The session id is refreshed once event is sent after duration', () { + // For this test, we will need control clock time so we will delete + // the [dartToolDirectory] and all of its contents and reconstruct a + // new [Analytics] instance at a specific time + dartToolDirectory.deleteSync(recursive: true); + expect(dartToolDirectory.existsSync(), false, + reason: 'The directory should have been cleared'); + + // Define the initial time to start + final DateTime start = DateTime(1995, 3, 3, 12, 0); + + // Set the clock to the start value defined above + withClock(Clock.fixed(start), () { + // This class will be constructed at a fixed time + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Read the contents of the session file + final String sessionFileContents = sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + + expect(secondAnalytics.userPropertyMap['session_id']?['value'], + start.millisecondsSinceEpoch); + expect(sessionObj['last_ping'], start.millisecondsSinceEpoch); + + secondAnalytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + }); + + // Add time to the start time that is less than the duration + final DateTime end = + start.add(Duration(minutes: kSessionDurationMinutes + 1)); + + // Use a new clock to ensure that the session id didn't change + withClock(Clock.fixed(end), () { + // A new instance will need to be created since the second + // instance in the previous block is scoped - this new instance + // should not reset the files generated by the second instance + final Analytics thirdAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Calling the send event method will result in the session file + // getting updated but because we use the `Analytics.test()` constructor + // no events will be sent + thirdAnalytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + + // Read the contents of the session file + final String sessionFileContents = sessionFile.readAsStringSync(); + final Map sessionObj = jsonDecode(sessionFileContents); + + expect(thirdAnalytics.userPropertyMap['session_id']?['value'], + end.millisecondsSinceEpoch, + reason: 'The session id should have changed since it was made ' + 'outside the duration'); + expect(sessionObj['last_ping'], end.millisecondsSinceEpoch, + reason: 'The last_ping value should have been updated'); + }); + }); + + test('Validate the available enum types for DevicePlatform', () { + expect(DevicePlatform.values.length, 3, + reason: 'There should only be 3 supported device platforms'); + expect(DevicePlatform.values.contains(DevicePlatform.windows), true); + expect(DevicePlatform.values.contains(DevicePlatform.macos), true); + expect(DevicePlatform.values.contains(DevicePlatform.linux), true); + }); + + test('Validate the request body', () { + // Sample map for event data + final Map eventData = { + 'time': 5, + 'command': 'run', + }; + + final Map body = generateRequestBody( + clientId: Uuid().generateV4(), + eventName: DashEvent.hotReloadTime, + eventData: eventData, + userProperty: userProperty, + ); + + // Checks for the top level keys + expect(body.containsKey('client_id'), true, + reason: '"client_id" is required at the top level'); + expect(body.containsKey('events'), true, + reason: '"events" is required at the top level'); + expect(body.containsKey('user_properties'), true, + reason: '"user_properties" is required at the top level'); + + // Regex for the client id + final RegExp clientIdPattern = RegExp( + r'^[0-9a-z]{8}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{4}\-[0-9a-z]{12}$'); + + // Checks for the top level values + expect(body['client_id'].runtimeType, String, + reason: 'The client id must be a string'); + expect(clientIdPattern.hasMatch(body['client_id']), true, + reason: 'The client id is not properly formatted, ie ' + '46cc0ba6-f604-4fd9-aa2f-8a20beb24cd4'); + expect( + (body['events'][0] as Map).containsKey('name'), true, + reason: 'Each event in the events array needs a name'); + expect( + (body['events'][0] as Map).containsKey('params'), true, + reason: 'Each event in the events array needs a params key'); + }); + + test( + 'All DashTools labels are made of characters that are letters or hyphens', + () { + // Regex pattern to match only letters or hyphens + final RegExp toolLabelPattern = RegExp(r'^[a-zA-Z\-]+$'); + bool valid = true; + for (DashTool tool in DashTool.values) { + if (!toolLabelPattern.hasMatch(tool.label)) { + valid = false; + } + } + + expect(valid, true, + reason: 'All tool labels should have letters and hyphens ' + 'as a delimiter if needed'); + }); + + test('Check that log file is correctly persisting events sent', () { + final int numberOfEvents = max((kLogFileLength * 0.1).floor(), 5); + + for (int i = 0; i < numberOfEvents; i++) { + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + } + + expect(logFile.readAsLinesSync().length, numberOfEvents, + reason: 'The number of events should be $numberOfEvents'); + + // Add the max number of events to confirm it does not exceed the max + for (int i = 0; i < kLogFileLength; i++) { + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + } + + expect(logFile.readAsLinesSync().length, kLogFileLength, + reason: 'The number of events should be capped at $kLogFileLength'); + }); + + test('Check the query on the log file works as expected', () { + expect(analytics.logFileStats(), isNull, + reason: 'The result for the log file stats should be null when ' + 'there are no logs'); + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + + final LogFileStats firstQuery = analytics.logFileStats()!; + expect(firstQuery.sessionCount, 1, + reason: + 'There should only be one session after the initial send event'); + expect(firstQuery.flutterChannelCount, 1, + reason: 'There should only be one flutter channel logged'); + expect(firstQuery.toolCount, 1, + reason: 'There should only be one tool logged'); + + // Define a new clock that is outside of the session duration + final DateTime firstClock = + clock.now().add(Duration(minutes: kSessionDurationMinutes + 1)); + + // Use the new clock to send an event that will change the session identifier + withClock(Clock.fixed(firstClock), () { + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + }); + + final LogFileStats secondQuery = analytics.logFileStats()!; + expect(secondQuery.sessionCount, 2, + reason: 'There should be 2 sessions after the second event'); + }); + + test('Check that the log file shows two different tools being used', () { + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + flutterChannel: flutterChannel, + toolsMessageVersion: toolsMessageVersion, + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Send events with both instances of the classes + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + secondAnalytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + + // Query the log file stats to verify that there are two tools + LogFileStats query = analytics.logFileStats()!; + + expect(query.toolCount, 2, + reason: 'There should have been two tools in the persisted logs'); + }); + + test('Check that log data missing some keys results in null for stats', () { + // The following string represents a log item that is malformed (missing the `tool` key) + const String malformedLog = + '{"client_id":"d40133a0-7ea6-4347-b668-ffae94bb8774",' + '"events":[{"name":"hot_reload_time","params":{"time_ns":345}}],' + '"user_properties":{' + '"session_id":{"value":1675193534342},' + '"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"},' + // '"tool":{"value":"flutter-tools"},' NEEDS REMAIN REMOVED + '"local_time":{"value":"2023-01-31 14:32:14.592898"}}}'; + + logFile.writeAsStringSync(malformedLog); + final LogFileStats? query = analytics.logFileStats(); + + expect(query, isNull, + reason: + 'The query should be null because `tool` is missing under `user_properties`'); + }); + + test('Malformed local_time string should result in null for stats', () { + // The following string represents a log item that is malformed (missing the `tool` key) + const String malformedLog = + '{"client_id":"d40133a0-7ea6-4347-b668-ffae94bb8774",' + '"events":[{"name":"hot_reload_time","params":{"time_ns":345}}],' + '"user_properties":{' + '"session_id":{"value":1675193534342},' + '"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"},' + '"tool":{"value":"flutter-tools"},' + '"local_time":{"value":"2023-xx-31 14:32:14.592898"}}}'; // PURPOSEFULLY MALFORMED + + logFile.writeAsStringSync(malformedLog); + final LogFileStats? query = analytics.logFileStats(); + + expect(query, isNull, + reason: + 'The query should be null because the `local_time` value is malformed'); + }); + + test('Check that the constant kPackageVersion matches pubspec version', () { + // Parse the contents of the pubspec.yaml + final String pubspecYamlString = io.File('pubspec.yaml').readAsStringSync(); + + // Parse into a yaml document to extract the version number + final YamlMap doc = loadYaml(pubspecYamlString); + final String version = doc['version']; + + expect(version, kPackageVersion, + reason: 'The package version in the pubspec and ' + 'constants.dart need to match\n' + 'Pubspec: $version && constants.dart: $kPackageVersion\n\n' + 'Make sure both are the same'); + }); + + test('Null values for flutter parameters is reflected properly in log file', + () { + final Analytics secondAnalytics = Analytics.test( + tool: secondTool, + homeDirectory: home, + measurementId: 'measurementId', + apiSecret: 'apiSecret', + // flutterChannel: flutterChannel, THIS NEEDS TO REMAIN REMOVED + // toolsMessageVersion: toolsMessageVersion, THIS NEEDS TO REMAIN REMOVED + toolsMessage: toolsMessage, + flutterVersion: 'Flutter 3.6.0-7.0.pre.47', + dartVersion: 'Dart 2.19.0', + fs: fs, + platform: platform, + ); + + // Send an event and check that the query stats reflects what is expected + secondAnalytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + + // Query the log file stats to verify that there are two tools + LogFileStats query = analytics.logFileStats()!; + + expect(query.toolCount, 1, + reason: 'There should have only been on tool that sent events'); + expect(query.flutterChannelCount, 0, + reason: + 'The instance does not have flutter information so it should be 0'); + + // Sending a query with the first analytics instance which has flutter information + // available should reflect in the query that there is 1 flutter channel present + analytics.sendEvent( + eventName: DashEvent.hotReloadTime, eventData: {}); + LogFileStats? query2 = analytics.logFileStats()!; + + expect(query2.toolCount, 2, + reason: 'Two different analytics instances have ' + 'been initialized and sent events'); + expect(query2.sessionCount, query.sessionCount, + reason: 'The session should have remained the same'); + expect(query2.flutterChannelCount, 1, + reason: 'The first instance has flutter information initialized'); + }); +} diff --git a/pkgs/placeholder.txt b/pkgs/placeholder.txt deleted file mode 100644 index f77dac7ce..000000000 --- a/pkgs/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -placeholder to create this directory in git