diff --git a/pkgs/unified_analytics/CHANGELOG.md b/pkgs/unified_analytics/CHANGELOG.md index 739d7bb1e..54f8183e1 100644 --- a/pkgs/unified_analytics/CHANGELOG.md +++ b/pkgs/unified_analytics/CHANGELOG.md @@ -4,6 +4,7 @@ - Added new method to suppress telemetry collection temporarily for current invocation via `analytics.suppressTelemetry()` - Added `SurveyHandler` feature to `Analytics` instance to fetch available surveys from remote endpoint to display to users along with functionality to dismiss them - Surveys will be disabled for any users that have been opted out +- Shipping `FakeAnalytics` for clients of this tool that need to ensure workflows are sending events in tests ## 3.0.0 diff --git a/pkgs/unified_analytics/lib/src/analytics.dart b/pkgs/unified_analytics/lib/src/analytics.dart index a1b18c62d..cb3842dc2 100644 --- a/pkgs/unified_analytics/lib/src/analytics.dart +++ b/pkgs/unified_analytics/lib/src/analytics.dart @@ -106,9 +106,12 @@ abstract class Analytics { // Ensure that the home directory has permissions enabled to write final homeDirectory = getHomeDirectory(fs); - if (homeDirectory == null || - !checkDirectoryForWritePermissions(homeDirectory)) { - return NoOpAnalytics(); + if (homeDirectory == null) { + throw Exception('Unable to determine the home directory, ' + 'ensure it is available in the environment'); + } + if (!checkDirectoryForWritePermissions(homeDirectory)) { + throw Exception('Permissions error on the home directory!'); } // Resolve the OS using dart:io @@ -627,6 +630,57 @@ class AnalyticsImpl implements Analytics { } } +/// This fake instance of [Analytics] is intended to be used by clients of +/// this package for testing purposes. It exposes a list [sentEvents] that +/// keeps track of all events that have been sent. +/// +/// This is useful for confirming that events are being sent for a given +/// workflow. Invoking the [send] method on this instance will not make any +/// network requests to Google Analytics. +class FakeAnalytics extends AnalyticsImpl { + /// Use this list to check for events that have been emitted when + /// invoking the send method + final List sentEvents = []; + + /// Class to use when you want to see which events were sent + FakeAnalytics({ + required super.tool, + required super.homeDirectory, + required super.dartVersion, + required super.platform, + required super.fs, + required super.surveyHandler, + super.flutterChannel, + super.flutterVersion, + }) : super( + gaClient: const FakeGAClient(), + enableAsserts: true, + toolsMessageVersion: kToolsMessageVersion, + ); + + @override + Future? send(Event event) { + if (!okToSend) return null; + + // Construct the body of the request + final body = generateRequestBody( + clientId: _clientId, + eventName: event.eventName, + eventData: event.eventData, + userProperty: userProperty, + ); + + if (_enableAsserts) checkBody(body); + + _logHandler.save(data: body); + + // Using this list to validate that events are being sent + // for internal methods in the `Analytics` instance + sentEvents.add(event); + return _gaClient.sendData(body); + } +} + /// An implementation that will never send events. /// /// This is for clients that opt to either not send analytics, or will migrate diff --git a/pkgs/unified_analytics/lib/src/event.dart b/pkgs/unified_analytics/lib/src/event.dart index 9410cf3cd..4533bb55b 100644 --- a/pkgs/unified_analytics/lib/src/event.dart +++ b/pkgs/unified_analytics/lib/src/event.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'enums.dart'; +import 'utils.dart'; final class Event { final DashEvent eventName; @@ -18,48 +19,6 @@ final class Event { : eventName = DashEvent.analyticsCollectionEnabled, eventData = {'status': status}; - /// Event that is emitted when a Dart CLI command has been executed. - /// - /// [name] - the name of the command that was executed - /// - /// [enabledExperiments] - a set of Dart language experiments enabled when - /// running the command. - /// - /// [exitCode] - the process exit code set as a result of running the command. - Event.dartCliCommandExecuted({ - required String name, - required String enabledExperiments, - int? exitCode, - }) : eventName = DashEvent.dartCliCommandExecuted, - eventData = { - 'name': name, - 'enabledExperiments': enabledExperiments, - if (exitCode != null) 'exitCode': exitCode, - }; - - /// Event that is emitted when `pub get` is run. - /// - /// [packageName] - the name of the package that was resolved - /// - /// [version] - the resolved, canonicalized package version - /// - /// [dependencyKind] - the kind of dependency that resulted in this package - /// being resolved (e.g., direct, transitive, or dev dependencies). - Event.pubGet({ - required String packageName, - required String version, - required String dependencyType, - }) : eventName = DashEvent.pubGet, - eventData = { - 'packageName': packageName, - 'version': version, - 'dependencyType': dependencyType, - }; - - Event.hotReloadTime({required int timeMs}) - : eventName = DashEvent.hotReloadTime, - eventData = {'timeMs': timeMs}; - /// Event that is emitted periodically to report the performance of the /// analysis server's handling of a specific kind of notification from the /// client. @@ -228,6 +187,29 @@ final class Event { 'transitiveFileUniqueLineCount': transitiveFileUniqueLineCount, }; + /// Event that is emitted when a Dart CLI command has been executed. + /// + /// [name] - the name of the command that was executed + /// + /// [enabledExperiments] - a set of Dart language experiments enabled when + /// running the command. + /// + /// [exitCode] - the process exit code set as a result of running the command. + Event.dartCliCommandExecuted({ + required String name, + required String enabledExperiments, + int? exitCode, + }) : eventName = DashEvent.dartCliCommandExecuted, + eventData = { + 'name': name, + 'enabledExperiments': enabledExperiments, + if (exitCode != null) 'exitCode': exitCode, + }; + + Event.hotReloadTime({required int timeMs}) + : eventName = DashEvent.hotReloadTime, + eventData = {'timeMs': timeMs}; + /// Event that is emitted periodically to report the number of times each lint /// has been enabled. /// @@ -309,6 +291,25 @@ final class Event { 'pluginId': pluginId, }; + /// Event that is emitted when `pub get` is run. + /// + /// [packageName] - the name of the package that was resolved + /// + /// [version] - the resolved, canonicalized package version + /// + /// [dependencyKind] - the kind of dependency that resulted in this package + /// being resolved (e.g., direct, transitive, or dev dependencies). + Event.pubGet({ + required String packageName, + required String version, + required String dependencyType, + }) : eventName = DashEvent.pubGet, + eventData = { + 'packageName': packageName, + 'version': version, + 'dependencyType': dependencyType, + }; + /// Event that is emitted on shutdown to report information about the whole /// session for which the analysis server was running. /// @@ -383,6 +384,16 @@ final class Event { 'surveyId': surveyId, }; + @override + int get hashCode => eventData.hashCode; + + @override + bool operator ==(Object other) => + other is Event && + other.runtimeType == runtimeType && + other.eventName == eventName && + compareEventData(other.eventData, eventData); + @override String toString() => jsonEncode({ 'eventName': eventName.label, diff --git a/pkgs/unified_analytics/lib/src/utils.dart b/pkgs/unified_analytics/lib/src/utils.dart index ba2556718..09e326688 100644 --- a/pkgs/unified_analytics/lib/src/utils.dart +++ b/pkgs/unified_analytics/lib/src/utils.dart @@ -35,6 +35,26 @@ bool checkDirectoryForWritePermissions(Directory directory) { return fileStat.modeString()[1] == 'w'; } +/// Utility function to take in two maps [a] and [b] and compares them +/// to ensure that they have the same keys and values +bool compareEventData(Map a, Map b) { + final keySetA = a.keys.toSet(); + final keySetB = b.keys.toSet(); + + // Ensure that the keys are the same for each object + if (keySetA.intersection(keySetB).length != keySetA.length || + keySetA.intersection(keySetB).length != keySetB.length) { + return false; + } + + // Ensure that each of the key's values are the same + for (final key in a.keys) { + if (a[key] != b[key]) return false; + } + + return true; +} + /// Format time as 'yyyy-MM-dd HH:mm:ss Z' where Z is the difference between the /// timezone of t and UTC formatted according to RFC 822. String formatDateTime(DateTime t) { diff --git a/pkgs/unified_analytics/lib/unified_analytics.dart b/pkgs/unified_analytics/lib/unified_analytics.dart index c52628ac8..9dc6abdc8 100644 --- a/pkgs/unified_analytics/lib/unified_analytics.dart +++ b/pkgs/unified_analytics/lib/unified_analytics.dart @@ -2,7 +2,7 @@ // 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, NoOpAnalytics; +export 'src/analytics.dart' show Analytics, FakeAnalytics, NoOpAnalytics; export 'src/config_handler.dart' show ToolInfo; export 'src/enums.dart' show DashTool; export 'src/event.dart' show Event; diff --git a/pkgs/unified_analytics/test/events_with_fake_test.dart b/pkgs/unified_analytics/test/events_with_fake_test.dart index 76d758481..3b0966616 100644 --- a/pkgs/unified_analytics/test/events_with_fake_test.dart +++ b/pkgs/unified_analytics/test/events_with_fake_test.dart @@ -11,8 +11,6 @@ import 'package:unified_analytics/src/enums.dart'; import 'package:unified_analytics/src/survey_handler.dart'; import 'package:unified_analytics/unified_analytics.dart'; -import 'src/fake_analytics.dart'; - void main() { // The fake analytics instance can be used to ensure events // are being sent when invoking methods on the `Analytics` instance @@ -71,14 +69,12 @@ void main() { homeDirectory: homeDirectory, dartVersion: 'dartVersion', platform: DevicePlatform.macos, - toolsMessageVersion: 1, fs: fs, surveyHandler: FakeSurveyHandler.fromList( homeDirectory: homeDirectory, fs: fs, initializedSurveys: [testSurvey], ), - enableAsserts: true, ); }); }); diff --git a/pkgs/unified_analytics/test/src/fake_analytics.dart b/pkgs/unified_analytics/test/src/fake_analytics.dart deleted file mode 100644 index aee85ed65..000000000 --- a/pkgs/unified_analytics/test/src/fake_analytics.dart +++ /dev/null @@ -1,57 +0,0 @@ -// 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:http/http.dart'; - -import 'package:unified_analytics/src/analytics.dart'; -import 'package:unified_analytics/src/asserts.dart'; -import 'package:unified_analytics/src/event.dart'; -import 'package:unified_analytics/src/ga_client.dart'; -import 'package:unified_analytics/src/log_handler.dart'; -import 'package:unified_analytics/src/utils.dart'; - -class FakeAnalytics extends AnalyticsImpl { - final List sentEvents = []; - final LogHandler _logHandler; - final FakeGAClient _gaClient; - final String _clientId = 'hard-coded-client-id'; - - /// Class to use when you want to see which events were sent - FakeAnalytics({ - required super.tool, - required super.homeDirectory, - required super.dartVersion, - required super.platform, - required super.toolsMessageVersion, - required super.fs, - required super.surveyHandler, - required super.enableAsserts, - super.flutterChannel, - super.flutterVersion, - FakeGAClient super.gaClient = const FakeGAClient(), - }) : _logHandler = LogHandler(fs: fs, homeDirectory: homeDirectory), - _gaClient = gaClient; - - @override - Future? send(Event event) { - if (!okToSend) return null; - - // Construct the body of the request - final body = generateRequestBody( - clientId: _clientId, - eventName: event.eventName, - eventData: event.eventData, - userProperty: userProperty, - ); - - checkBody(body); - - _logHandler.save(data: body); - - // Using this list to validate that events are being sent - // for internal methods in the `Analytics` instance - sentEvents.add(event); - return _gaClient.sendData(body); - } -} diff --git a/pkgs/unified_analytics/test/unified_analytics_test.dart b/pkgs/unified_analytics/test/unified_analytics_test.dart index 7bc3b95f5..e05ce5152 100644 --- a/pkgs/unified_analytics/test/unified_analytics_test.dart +++ b/pkgs/unified_analytics/test/unified_analytics_test.dart @@ -1169,4 +1169,49 @@ further information will be sent. This data is collected in accordance with the Google Privacy Policy (https://policies.google.com/privacy). ''')); }); + + test('Equality operator works for identical events', () { + final eventOne = Event.clientRequest( + duration: 'duration', + latency: 'latency', + method: 'method', + ); + final eventTwo = Event.clientRequest( + duration: 'duration', + latency: 'latency', + method: 'method', + ); + + expect(eventOne == eventTwo, true); + }); + + test('Equality operator works for non-identical events', () { + final eventOne = Event.clientRequest( + duration: 'duration', + latency: 'latency', + method: 'method', + added: 'DIFFERENT FROM EVENT TWO', + ); + final eventTwo = Event.clientRequest( + duration: 'duration', + latency: 'latency', + method: 'method', + ); + + expect(eventOne == eventTwo, false); + }); + + test('Find a match for an event in a list of events', () { + final eventList = [ + Event.analyticsCollectionEnabled(status: true), + Event.memoryInfo(rss: 500), + Event.clientRequest( + duration: 'duration', latency: 'latency', method: 'method'), + ]; + + final eventToMatch = Event.memoryInfo(rss: 500); + + expect(eventList.contains(eventToMatch), true); + expect(eventList.where((element) => element == eventToMatch).length, 1); + }); }