Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Fake Analytics instance that uses list to save events sent #149

Merged
merged 7 commits into from
Sep 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkgs/unified_analytics/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 57 additions & 3 deletions pkgs/unified_analytics/lib/src/analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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!');
eliasyishak marked this conversation as resolved.
Show resolved Hide resolved
}

// Resolve the OS using dart:io
Expand Down Expand Up @@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should have a dartdoc explaining exactly what is different between this and AnalyticsImpl.

/// Use this list to check for events that have been emitted when
/// invoking the send method
final List<Event> sentEvents = [];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bkonyi thoughts on this so far. I figured that for the fake instance, all you really need is to have access to a list that will contain each Event we send so we only really need to override the send method to add to the list

I think all other functionality within the default implementation class should be fine?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think this looks good to me! Assuming each Event implementation has operator == and hashCode overrides, this will work perfectly :).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good call!

/// 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<Response>? 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
Expand Down
95 changes: 53 additions & 42 deletions pkgs/unified_analytics/lib/src/event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'dart:convert';

import 'enums.dart';
import 'utils.dart';

final class Event {
final DashEvent eventName;
Expand All @@ -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.
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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);
Comment on lines +387 to +395
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added these overrides with some tests to ensure it works correctly, but does this look good? @bkonyi

We essentially just need to check the event name and the data in the eventData map


@override
String toString() => jsonEncode({
'eventName': eventName.label,
Expand Down
20 changes: 20 additions & 0 deletions pkgs/unified_analytics/lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object?> a, Map<String, Object?> 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) {
Expand Down
2 changes: 1 addition & 1 deletion pkgs/unified_analytics/lib/unified_analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 0 additions & 4 deletions pkgs/unified_analytics/test/events_with_fake_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
});
});
Expand Down
57 changes: 0 additions & 57 deletions pkgs/unified_analytics/test/src/fake_analytics.dart

This file was deleted.

45 changes: 45 additions & 0 deletions pkgs/unified_analytics/test/unified_analytics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}