blob: 9187d02a5258d8a23e201673a1a13bdd36228825 [file] [log] [blame]
// 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:intl/date_symbol_data_local.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'asserts.dart';
import 'config_handler.dart';
import 'constants.dart';
import 'enums.dart';
import 'error_handler.dart';
import 'event.dart';
import 'ga_client.dart';
import 'initializer.dart';
import 'log_handler.dart';
import 'session.dart';
import 'survey_handler.dart';
import 'user_property.dart';
import 'utils.dart';
/// For passing the [Analytics.send] method to classes created by [Analytics].
typedef SendFunction = void Function(Event event);
abstract class Analytics {
/// The default factory constructor that will return an implementation
/// of the [Analytics] abstract class using the [LocalFileSystem].
///
/// If [enableAsserts] is set to `true`, then asserts for GA4 limitations
/// will be enabled.
///
/// [flutterChannel] and [flutterVersion] are nullable in case the client
/// using this package is unable to resolve those values.
///
/// An optional parameter [clientIde] is also available for dart and flutter
/// tooling that are running from IDEs can be resolved. Such as "VSCode"
/// running the flutter-tool.
///
/// [enabledFeatures] is also an optional field that can be added to collect
/// any features that are enabled for a user. For example,
/// "enable-linux-desktop,cli-animations" are two features that can be enabled
/// for the flutter-tool.
factory Analytics({
required DashTool tool,
required String dartVersion,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
bool enableAsserts = false,
}) {
// Create the instance of the file system so clients don't need
// resolve on their own
const FileSystem fs = LocalFileSystem();
// Ensure that the home directory has permissions enabled to write
final homeDirectory = getHomeDirectory(fs);
if (homeDirectory == null ||
!checkDirectoryForWritePermissions(homeDirectory)) {
return const NoOpAnalytics();
}
// 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;
}
// Create the instance of the GA Client which will create
// an [http.Client] to send requests
final gaClient = GAClient(
measurementId: kGoogleAnalyticsMeasurementId,
apiSecret: kGoogleAnalyticsApiSecret,
);
return AnalyticsImpl(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
toolsMessageVersion: kToolsMessageVersion,
fs: fs,
gaClient: gaClient,
surveyHandler: SurveyHandler(homeDirectory: homeDirectory, fs: fs),
enableAsserts: enableAsserts,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
);
}
/// Factory constructor to return the [AnalyticsImpl] class with
/// Google Analytics credentials that point to a test instance and
/// not the production instance where live data will be sent.
///
/// By default, [enableAsserts] is set to `true` to check against
/// GA4 limitations.
///
/// [flutterChannel] and [flutterVersion] are nullable in case the client
/// using this package is unable to resolve those values.
factory Analytics.development({
required DashTool tool,
required String dartVersion,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
bool enableAsserts = true,
}) {
// Create the instance of the file system so clients don't need
// resolve on their own
const FileSystem fs = LocalFileSystem();
// Ensure that the home directory has permissions enabled to write
final homeDirectory = getHomeDirectory(fs);
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
final DevicePlatform platform;
if (io.Platform.operatingSystem == 'linux') {
platform = DevicePlatform.linux;
} else if (io.Platform.operatingSystem == 'macos') {
platform = DevicePlatform.macos;
} else {
platform = DevicePlatform.windows;
}
// Credentials defined below for the test Google Analytics instance
const kTestMeasurementId = 'G-N1NXG28J5B';
const kTestApiSecret = '4yT8__oER3Cd84dtx6r-_A';
// Create the instance of the GA Client which will create
// an [http.Client] to send requests
final gaClient = GAClient(
measurementId: kTestMeasurementId,
apiSecret: kTestApiSecret,
);
return AnalyticsImpl(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
toolsMessageVersion: kToolsMessageVersion,
fs: fs,
gaClient: gaClient,
surveyHandler: SurveyHandler(homeDirectory: homeDirectory, fs: fs),
enableAsserts: enableAsserts,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
);
}
/// Factory constructor to return the [AnalyticsImpl] class with a
/// [MemoryFileSystem] to use for testing.
@visibleForTesting
factory Analytics.test({
required DashTool tool,
required Directory homeDirectory,
required String measurementId,
required String apiSecret,
required String dartVersion,
required FileSystem fs,
required DevicePlatform platform,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
SurveyHandler? surveyHandler,
GAClient? gaClient,
int toolsMessageVersion = kToolsMessageVersion,
String toolsMessage = kToolsMessage,
}) =>
FakeAnalytics(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
toolsMessageVersion: toolsMessageVersion,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
fs: fs,
surveyHandler: surveyHandler ??
FakeSurveyHandler.fromList(
homeDirectory: homeDirectory,
fs: fs,
initializedSurveys: [],
),
gaClient: gaClient ?? const FakeGAClient(),
clientIde: clientIde,
enabledFeatures: enabledFeatures,
);
/// The shared identifier for Flutter and Dart related tooling using
/// package:unified_analytics.
String get clientId;
/// Retrieves the consent message to prompt users with on first
/// run or when the message has been updated.
String get getConsentMessage;
/// Returns true if it is OK to send an analytics message. Do not cache,
/// as this depends on factors that can change, such as the configuration
/// file contents.
bool get okToSend;
/// Returns a map object with all of the tools that have been parsed
/// out of the configuration file.
Map<String, ToolInfo> 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 a map representation of the [UserProperty] for the [Analytics]
/// instance.
///
/// This is what will get sent to Google Analytics with every request.
Map<String, Map<String, Object?>> get userPropertyMap;
/// Method to be invoked by the client using this package to confirm
/// that the client has shown the message and that it can be added to
/// the config file and start sending events the next time it starts up.
void clientShowedMessage();
/// 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.
///
/// Providing [delayDuration] in milliseconds will allow the instance
/// to wait the provided time before closing the http connection. Keeping
/// the connection open for some time will allow any pending events that
/// are waiting to be sent to the Google Analytics server. Default value
/// of 250 ms applied.
Future<void> close({int delayDuration = kDelayDuration});
/// Method to fetch surveys from the endpoint [kContextualSurveyUrl].
///
/// Any survey that is returned by this method has already passed
/// the survey conditions specified in the remote survey metadata file.
///
/// If the method returns an empty list, then there are no surveys to be
/// shared with the user.
Future<List<Survey>> fetchAvailableSurveys();
/// Query the persisted event data stored on the user's machine.
///
/// Returns null if there are no persisted logs.
LogFileStats? logFileStats();
/// Send preconfigured events using specific named constructors
/// on the [Event] class.
///
/// Example
/// ```dart
/// analytics.send(Event.memory(periodSec: 123));
/// ```
void send(Event event);
/// Pass a boolean to either enable or disable telemetry and make
/// the necessary changes in the persisted configuration file.
///
/// Setting the telemetry status will also send an event to GA
/// indicating the latest status of the telemetry from [reportingBool].
Future<void> setTelemetry(bool reportingBool);
/// Calling this will result in telemetry collection being suppressed for
/// the current invocation.
///
/// If you would like to permanently disable telemetry
/// collection use:
///
/// ```dart
/// analytics.setTelemetry(false)
/// ```
void suppressTelemetry();
/// Method to run after interacting with a [Survey] instance.
///
/// Pass a [Survey] instance which can be retrieved from
/// [Analytics.fetchAvailableSurveys].
///
/// [surveyButton] is the button that was interacted with by the user.
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
});
/// Method to be called after a survey has been shown to the user.
///
/// Calling this will snooze the survey so it won't be shown immediately.
///
/// The snooze period is defined by the [Survey.snoozeForMinutes] field.
void surveyShown(Survey survey);
}
class AnalyticsImpl implements Analytics {
final DashTool tool;
final FileSystem fs;
late final ConfigHandler _configHandler;
final GAClient _gaClient;
final SurveyHandler _surveyHandler;
late String _clientId;
late final File _clientIdFile;
late final UserProperty userProperty;
late final LogHandler _logHandler;
late final Session _sessionHandler;
late final ErrorHandler _errorHandler;
final int toolsMessageVersion;
/// Tells the client if they need to show a message to the
/// user; this will return true if it is the first time the
/// package is being used for a developer or if the consent
/// message has been updated by the package.
late bool _showMessage;
/// When set to `true`, various assert statements will be enabled
/// to ensure usage of this class is within GA4 limitations.
final bool _enableAsserts;
/// Telemetry suppression flag that is set via [Analytics.suppressTelemetry].
bool _telemetrySuppressed = false;
/// Indicates if this is the first run for a given tool.
bool _firstRun = false;
/// The list of futures that will contain all of the send events
/// from the [GAClient].
final _futures = <Future<Response>>[];
AnalyticsImpl({
required this.tool,
required Directory homeDirectory,
required String? flutterChannel,
required String? flutterVersion,
required String? clientIde,
required String? enabledFeatures,
required String dartVersion,
required DevicePlatform platform,
required this.toolsMessageVersion,
required this.fs,
required GAClient gaClient,
required SurveyHandler surveyHandler,
required bool enableAsserts,
}) : _gaClient = gaClient,
_surveyHandler = surveyHandler,
_enableAsserts = enableAsserts {
// Initialize date formatting for `package:intl` within constructor
// so clients using this package won't need to
initializeDateFormatting();
// 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(
fs: fs,
tool: tool.label,
homeDirectory: homeDirectory,
toolsMessageVersion: toolsMessageVersion,
);
initializer.run();
if (initializer.firstRun) {
_showMessage = true;
_firstRun = true;
} else {
_showMessage = false;
_firstRun = false;
}
// Create the config handler that will parse the config file
_configHandler = ConfigHandler(
fs: fs,
homeDirectory: homeDirectory,
initializer: initializer,
);
// Check if the tool has already been onboarded, and if it
// has, check if the latest message version is greater to
// prompt the client to show a message
//
// If the tool has not been added to the config file, then
// we will show the message as well
final currentVersion =
_configHandler.parsedTools[tool.label]?.versionNumber ?? -1;
if (currentVersion < toolsMessageVersion) {
_showMessage = true;
// If the message version has been updated, it will be considered
// as if it was a first run and any events attempting to get sent
// will be blocked
_firstRun = true;
}
_clientIdFile = fs.file(
p.join(homeDirectory.path, kDartToolDirectoryName, kClientIdFileName));
_clientId = _clientIdFile.readAsStringSync();
// Initialization for the error handling class that will prevent duplicate
// [Event.analyticsException] events from being sent to GA4
_errorHandler = ErrorHandler(sendFunction: send);
// 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]
_sessionHandler = Session(
homeDirectory: homeDirectory,
fs: fs,
errorHandler: _errorHandler,
);
userProperty = UserProperty(
session: _sessionHandler,
flutterChannel: flutterChannel,
host: platform.label,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
tool: tool.label,
// We truncate this to a maximum of 36 characters since this can
// a very long string for some operating systems
hostOsVersion:
truncateStringToLength(io.Platform.operatingSystemVersion, 36),
locale: io.Platform.localeName,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
);
// Initialize the log handler to persist events that are being sent
_logHandler = LogHandler(
fs: fs,
homeDirectory: homeDirectory,
errorHandler: _errorHandler,
);
// Initialize the session handler with the session_id
// by parsing the json file
_sessionHandler.initialize(telemetryEnabled);
}
@override
String get clientId => _clientId;
@override
String get getConsentMessage {
// The command to swap in the consent message
final commandString =
tool == DashTool.flutterTool || tool == DashTool.devtools
? 'flutter'
: 'dart';
return kToolsMessage
.replaceAll('{{ toolDescription }}', tool.description)
.replaceAll('{{ toolName }}', commandString);
}
/// Checking the [telemetryEnabled] boolean reflects what the
/// config file reflects.
///
/// Checking the [_showMessage] boolean indicates if the consent
/// message has been shown for the user, this boolean is set to `true`
/// when the tool using this package invokes the [clientShowedMessage]
/// method.
///
/// If the user has suppressed telemetry [_telemetrySuppressed] will
/// return `true` to prevent events from being sent for current invocation.
///
/// Checking if it is the first time a tool is running with this package
/// as indicated by [_firstRun].
@override
bool get okToSend =>
telemetryEnabled && !_showMessage && !_telemetrySuppressed && !_firstRun;
@override
Map<String, ToolInfo> get parsedTools => _configHandler.parsedTools;
@override
bool get shouldShowMessage => _showMessage;
@override
bool get telemetryEnabled => _configHandler.telemetryEnabled;
@override
Map<String, Map<String, Object?>> get userPropertyMap =>
userProperty.preparePayload();
@override
void clientShowedMessage() {
// Check the tool needs to be added to the config file
if (!_configHandler.parsedTools.containsKey(tool.label)) {
_configHandler.addTool(
tool: tool.label,
versionNumber: toolsMessageVersion,
);
}
// When the tool already exists but the consent message version
// has been updated
if (_configHandler.parsedTools[tool.label]!.versionNumber <
toolsMessageVersion) {
_configHandler.incrementToolVersion(
tool: tool.label,
newVersionNumber: toolsMessageVersion,
);
}
_showMessage = false;
}
@override
Future<void> close({int delayDuration = kDelayDuration}) async {
await Future.wait(_futures).timeout(
Duration(milliseconds: delayDuration),
onTimeout: () => [],
);
_gaClient.close();
}
@override
Future<List<Survey>> fetchAvailableSurveys() async {
final surveysToShow = <Survey>[];
if (!okToSend) return surveysToShow;
final logFileStats = _logHandler.logFileStats();
// Call for surveys that have already been dismissed from
// persisted survey ids on disk
final persistedSurveyMap = _surveyHandler.fetchPersistedSurveys();
for (final survey in await _surveyHandler.fetchSurveyList()) {
// If the survey has listed the tool running this package in the exclude
// list, it will not be returned
if (survey.excludeDashToolList.contains(tool)) continue;
// Apply the survey's sample rate; if the generated value from
// the client id and survey's uniqueId are less, it will not get
// sent to the user
if (survey.samplingRate < sampleRate(_clientId, survey.uniqueId)) {
continue;
}
// If the survey has been permanently dismissed or has temporarily
// been snoozed, skip it
if (surveySnoozedOrDismissed(survey, persistedSurveyMap)) continue;
// Counter to check each survey condition, if all are met, then
// this integer will be equal to the number of conditions in
// [Survey.conditionList]
var conditionsMet = 0;
if (logFileStats != null) {
for (final condition in survey.conditionList) {
// Retrieve the value from the [LogFileStats] with
// the label provided in the condtion
final logFileStatsValue =
logFileStats.getValueByString(condition.field);
if (logFileStatsValue == null) continue;
switch (condition.operatorString) {
case '>=':
if (logFileStatsValue >= condition.value) conditionsMet++;
case '<=':
if (logFileStatsValue <= condition.value) conditionsMet++;
case '>':
if (logFileStatsValue > condition.value) conditionsMet++;
case '<':
if (logFileStatsValue < condition.value) conditionsMet++;
case '==':
if (logFileStatsValue == condition.value) conditionsMet++;
case '!=':
if (logFileStatsValue != condition.value) conditionsMet++;
}
}
}
if (conditionsMet == survey.conditionList.length) {
surveysToShow.add(survey);
}
}
return surveysToShow;
}
@override
LogFileStats? logFileStats() => _logHandler.logFileStats();
@override
void send(Event event) {
if (!okToSend) return;
// 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);
final gaClientFuture = _gaClient.sendData(body);
_futures.add(gaClientFuture);
gaClientFuture.whenComplete(() => _futures.remove(gaClientFuture));
}
@override
Future<void> setTelemetry(bool reportingBool) {
_configHandler.setTelemetry(reportingBool);
// Creation of the [Event] for opting out
final collectionEvent =
Event.analyticsCollectionEnabled(status: reportingBool);
// The body of the request that will be sent to GA4
final Map<String, Object?> body;
if (reportingBool) {
// Recreate the session and client id file; no need to
// recreate the log file since it will only receives events
// to persist from events sent
Initializer.createClientIdFile(clientIdFile: _clientIdFile);
Initializer.createSessionFile(sessionFile: _sessionHandler.sessionFile);
// Reread the client ID string so an empty string is not being
// sent to GA4 since the persisted files are cleared when a user
// decides to opt out of telemetry collection
_clientId = _clientIdFile.readAsStringSync();
// We must construct the body at this point after we have read in the
// new client id string that was generated
body = generateRequestBody(
clientId: _clientId,
eventName: collectionEvent.eventName,
eventData: collectionEvent.eventData,
userProperty: userProperty,
);
_logHandler.save(data: body);
} else {
// Construct the body of the request to signal
// telemetry status toggling
body = generateRequestBody(
clientId: _clientId,
eventName: collectionEvent.eventName,
eventData: collectionEvent.eventData,
userProperty: userProperty,
);
// For opted out users, data in the persisted files is cleared
_sessionHandler.sessionFile.writeAsStringSync('');
_logHandler.logFile.writeAsStringSync('');
_clientIdFile.writeAsStringSync('');
_clientId = _clientIdFile.readAsStringSync();
}
// Pass to the google analytics client to send with a
// timeout incase http clients hang
return _gaClient.sendData(body).timeout(
const Duration(milliseconds: kDelayDuration),
onTimeout: () => Response('', 200),
);
}
@override
void suppressTelemetry() => _telemetrySuppressed = true;
@override
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
}) {
// Any action, except for 'snooze' will permanently dismiss a given survey
final permanentlyDismissed = surveyButton.action == 'snooze' ? false : true;
_surveyHandler.dismiss(survey, permanentlyDismissed);
send(Event.surveyAction(
surveyId: survey.uniqueId,
status: surveyButton.action,
));
}
@override
void surveyShown(Survey survey) {
_surveyHandler.dismiss(survey, false);
send(Event.surveyShown(surveyId: survey.uniqueId));
}
}
/// 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<Event> 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.clientIde,
super.enabledFeatures,
int? toolsMessageVersion,
GAClient? gaClient,
}) : super(
gaClient: gaClient ?? const FakeGAClient(),
enableAsserts: true,
toolsMessageVersion: toolsMessageVersion ?? kToolsMessageVersion,
);
@override
void send(Event event) {
if (!okToSend) return;
// 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);
}
}
/// An implementation that will never send events.
///
/// This is for clients that opt to either not send analytics, or will migrate
/// to use [AnalyticsImpl] at a later time.
class NoOpAnalytics implements Analytics {
/// The hard-coded client ID value for each NoOp instance.
static String get staticClientId => 'xxxx-xxxx';
@override
final String getConsentMessage = '';
@override
final bool okToSend = false;
@override
final Map<String, ToolInfo> parsedTools = const <String, ToolInfo>{};
@override
final bool shouldShowMessage = false;
@override
final bool telemetryEnabled = false;
@override
final Map<String, Map<String, Object?>> userPropertyMap =
const <String, Map<String, Object?>>{};
const NoOpAnalytics();
@override
String get clientId => staticClientId;
@override
void clientShowedMessage() {}
@override
Future<void> close({int delayDuration = kDelayDuration}) async {}
@override
Future<List<Survey>> fetchAvailableSurveys() async => const <Survey>[];
@override
LogFileStats? logFileStats() => null;
@override
Future<Response>? send(Event event) => null;
@override
Future<void> setTelemetry(bool reportingBool) async {}
@override
void suppressTelemetry() {}
@override
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
}) {}
@override
void surveyShown(Survey survey) {}
}