blob: cd548fe25d9ece2e8178515f56576d0d1dedc672 [file] [log] [blame]
// Copyright (c) 2014, 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.
/// `usage` is a wrapper around Google Analytics for both command-line apps
/// and web apps.
///
/// In order to use this library as a web app, import the `analytics_html.dart`
/// library and instantiate the [AnalyticsHtml] class.
///
/// In order to use this library as a command-line app, import the
/// `analytics_io.dart` library and instantiate the [AnalyticsIO] class.
///
/// For both classes, you need to provide a Google Analytics tracking ID, the
/// application name, and the application version.
///
/// Your application should provide an opt-in option for the user. If they
/// opt-in, set the [optIn] field to `true`. This setting will persist across
/// sessions automatically.
///
/// For more information, please see the Google Analytics Measurement Protocol
/// [Policy](https://developers.google.com/analytics/devguides/collection/protocol/policy).
library usage;
import 'dart:async';
// Matches file:/, non-ws, /, non-ws, .dart
final RegExp _pathRegex = RegExp(r'file:/\S+/(\S+\.dart)');
// Match multiple tabs or spaces.
final RegExp _tabOrSpaceRegex = RegExp(r'[\t ]+');
/// An interface to a Google Analytics session.
///
/// [AnalyticsHtml] and [AnalyticsIO] are concrete implementations of this
/// interface. [AnalyticsMock] can be used for testing or for some variants of
/// an opt-in workflow.
///
/// The analytics information is sent on a best-effort basis. So, failures to
/// send the GA information will not result in errors from the asynchronous
/// `send` methods.
abstract class Analytics {
/// Tracking ID / Property ID.
String get trackingId;
/// The application name.
String? get applicationName;
/// The application version.
String? get applicationVersion;
/// Is this the first time the tool has run?
bool get firstRun;
/// Whether the [Analytics] instance is configured in an opt-in or opt-out
/// manner.
AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;
/// Will analytics data be sent.
bool get enabled;
/// Enable or disable sending of analytics data.
set enabled(bool value);
/// Anonymous client ID in UUID v4 format.
///
/// The value is randomly-generated and should be reasonably stable for the
/// computer sending analytics data.
String get clientId;
/// Sends a screen view hit to Google Analytics.
///
/// [parameters] can be any analytics key/value pair. Useful
/// for custom dimensions, etc.
Future sendScreenView(String viewName, {Map<String, String>? parameters});
/// Sends an Event hit to Google Analytics. [label] specifies the event label.
/// [value] specifies the event value. Values must be non-negative.
///
/// [parameters] can be any analytics key/value pair. Useful
/// for custom dimensions, etc.
Future sendEvent(String category, String action,
{String? label, int? value, Map<String, String>? parameters});
/// Sends a Social hit to Google Analytics.
///
/// [network] specifies the social network, for example Facebook or Google
/// Plus. [action] specifies the social interaction action. For example on
/// Google Plus when a user clicks the +1 button, the social action is 'plus'.
/// [target] specifies the target of a
/// social interaction. This value is typically a URL but can be any text.
Future sendSocial(String network, String action, String target);
/// Sends a Timing hit to Google Analytics. [variableName] specifies the
/// variable name of the timing. [time] specifies the user timing value (in
/// milliseconds). [category] specifies the category of the timing. [label]
/// specifies the label of the timing.
Future sendTiming(String variableName, int time,
{String? category, String? label});
/// Start a timer. The time won't be calculated, and the analytics information
/// sent, until the [AnalyticsTimer.finish] method is called.
AnalyticsTimer startTimer(String variableName,
{String? category, String? label});
/// In order to avoid sending any personally identifying information, the
/// [description] field must not contain the exception message. In addition,
/// only the first 100 chars of the description will be sent.
Future sendException(String description, {bool? fatal});
/// Gets a session variable value.
dynamic getSessionValue(String param);
/// Sets a session variable value. The value is persistent for the life of the
/// [Analytics] instance. This variable will be sent in with every analytics
/// hit. A list of valid variable names can be found here:
/// https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters.
void setSessionValue(String param, dynamic value);
/// Fires events when the usage library sends any data over the network. This
/// will not fire if analytics has been disabled or if the throttling
/// algorithm has been engaged.
///
/// This method is public to allow library clients to more easily test their
/// analytics implementations.
Stream<Map<String, dynamic>> get onSend;
/// Wait for all of the outstanding analytics pings to complete. The returned
/// `Future` will always complete without errors. You can pass in an optional
/// `Duration` to specify to only wait for a certain amount of time.
///
/// This method is particularly useful for command-line clients. Outstanding
/// I/O requests will cause the VM to delay terminating the process.
/// Generally, users won't want their CLI app to pause at the end of the
/// process waiting for Google analytics requests to complete. This method
/// allows CLI apps to delay for a short time waiting for GA requests to
/// complete, and then do something like call `dart:io`'s `exit()` explicitly
/// themselves (or the [close] method below).
Future waitForLastPing({Duration? timeout});
/// Free any used resources.
///
/// The [Analytics] instance should not be used after this call.
void close();
}
enum AnalyticsOpt {
/// Users must opt-in before any analytics data is sent.
optIn,
/// Users must opt-out for analytics data to not be sent.
optOut
}
/// An object, returned by [Analytics.startTimer], that is used to measure an
/// asynchronous process.
class AnalyticsTimer {
final Analytics analytics;
final String variableName;
final String? category;
final String? label;
late final int _startMillis;
int? _endMillis;
AnalyticsTimer(this.analytics, this.variableName,
{this.category, this.label}) {
_startMillis = DateTime.now().millisecondsSinceEpoch;
}
int get currentElapsedMillis {
if (_endMillis == null) {
return DateTime.now().millisecondsSinceEpoch - _startMillis;
} else {
return _endMillis! - _startMillis;
}
}
/// Finish the timer, calculate the elapsed time, and send the information to
/// analytics. Once this is called, any future invocations are no-ops.
Future finish() {
if (_endMillis != null) return Future.value();
_endMillis = DateTime.now().millisecondsSinceEpoch;
return analytics.sendTiming(variableName, currentElapsedMillis,
category: category, label: label);
}
}
/// A no-op implementation of the [Analytics] class. This can be used as a
/// stand-in for that will never ping the GA server, or as a mock in test code.
class AnalyticsMock implements Analytics {
@override
String get trackingId => 'UA-0';
@override
String get applicationName => 'mock-app';
@override
String get applicationVersion => '1.0.0';
final bool logCalls;
/// Events are never added to this controller for the mock implementation.
final StreamController<Map<String, dynamic>> _sendController =
StreamController.broadcast();
/// Create a new [AnalyticsMock]. If [logCalls] is true, all calls will be
/// logged to stdout.
AnalyticsMock([this.logCalls = false]);
@override
bool get firstRun => false;
@override
AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;
@override
bool enabled = true;
@override
String get clientId => '00000000-0000-4000-0000-000000000000';
@override
Future sendScreenView(String viewName, {Map<String, String>? parameters}) {
parameters ??= <String, String>{};
parameters['viewName'] = viewName;
return _log('screenView', parameters);
}
@override
Future sendEvent(String category, String action,
{String? label, int? value, Map<String, String>? parameters}) {
parameters ??= <String, String>{};
return _log(
'event',
{'category': category, 'action': action, 'label': label, 'value': value}
..addAll(parameters));
}
@override
Future sendSocial(String network, String action, String target) =>
_log('social', {'network': network, 'action': action, 'target': target});
@override
Future sendTiming(String variableName, int time,
{String? category, String? label}) {
return _log('timing', {
'variableName': variableName,
'time': time,
'category': category,
'label': label
});
}
@override
AnalyticsTimer startTimer(String variableName,
{String? category, String? label}) {
return AnalyticsTimer(this, variableName, category: category, label: label);
}
@override
Future sendException(String description, {bool? fatal}) =>
_log('exception', {'description': description, 'fatal': fatal});
@override
dynamic getSessionValue(String param) => null;
@override
void setSessionValue(String param, dynamic value) {}
@override
Stream<Map<String, dynamic>> get onSend => _sendController.stream;
@override
Future waitForLastPing({Duration? timeout}) => Future.value();
@override
void close() {}
Future _log(String hitType, Map m) {
if (logCalls) {
print('analytics: ${hitType} ${m}');
}
return Future.value();
}
}
/// Sanitize a stacktrace. This will shorten file paths in order to remove any
/// PII that may be contained in the full file path. For example, this will
/// shorten `file:///Users/foobar/tmp/error.dart` to `error.dart`.
///
/// If [shorten] is `true`, this method will also attempt to compress the text
/// of the stacktrace. GA has a 100 char limit on the text that can be sent for
/// an exception. This will try and make those first 100 chars contain
/// information useful to debugging the issue.
String sanitizeStacktrace(dynamic st, {bool shorten = true}) {
var str = '${st}';
Iterable<Match> iter = _pathRegex.allMatches(str);
iter = iter.toList().reversed;
for (var match in iter) {
var replacement = match.group(1)!;
str =
str.substring(0, match.start) + replacement + str.substring(match.end);
}
if (shorten) {
// Shorten the stacktrace up a bit.
str = str.replaceAll(_tabOrSpaceRegex, ' ');
}
return str;
}