// 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? 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? 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> 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> _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? parameters}) { parameters ??= {}; parameters['viewName'] = viewName; return _log('screenView', parameters); } @override Future sendEvent(String category, String action, {String? label, int? value, Map? parameters}) { parameters ??= {}; 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> 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 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; }