diff --git a/packages/cli_tools/lib/analytics.dart b/packages/cli_tools/lib/analytics.dart index 2a12ffb..b219f6f 100644 --- a/packages/cli_tools/lib/analytics.dart +++ b/packages/cli_tools/lib/analytics.dart @@ -1 +1,3 @@ export 'src/analytics/analytics.dart'; +export 'src/analytics/mixpanel.dart'; +export 'src/analytics/posthog.dart'; diff --git a/packages/cli_tools/lib/src/analytics/analytics.dart b/packages/cli_tools/lib/src/analytics/analytics.dart index 0fa718a..d3367af 100644 --- a/packages/cli_tools/lib/src/analytics/analytics.dart +++ b/packages/cli_tools/lib/src/analytics/analytics.dart @@ -1,106 +1,37 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ci/ci.dart' as ci; -import 'package:http/http.dart' as http; - /// Interface for analytics services. abstract interface class Analytics { /// Clean up resources. void cleanUp(); /// Track an event. - void track({required final String event}); + void track({ + required final String event, + final Map properties = const {}, + }); } -/// Analytics service for MixPanel. -class MixPanelAnalytics implements Analytics { - static const _defaultEndpoint = 'https://api.mixpanel.com/track'; - static const _defaultTimeout = Duration(seconds: 2); - - final String _uniqueUserId; - final String _projectToken; - final String _version; - - final Uri _endpoint; - final Duration _timeout; +class CompoundAnalytics implements Analytics { + final List providers; - MixPanelAnalytics({ - required final String uniqueUserId, - required final String projectToken, - required final String version, - final String? endpoint, - final Duration timeout = _defaultTimeout, - final bool disableIpTracking = false, - }) : _uniqueUserId = uniqueUserId, - _projectToken = projectToken, - _version = version, - _endpoint = _buildEndpoint( - endpoint ?? _defaultEndpoint, - disableIpTracking, - ), - _timeout = timeout; - - static Uri _buildEndpoint( - final String baseEndpoint, - final bool disableIpTracking, - ) { - final uri = Uri.parse(baseEndpoint); - final ipValue = disableIpTracking ? '0' : '1'; - - final updatedUri = uri.replace( - queryParameters: { - ...uri.queryParameters, - 'ip': ipValue, - }, - ); - return updatedUri; - } + CompoundAnalytics(this.providers); @override - void cleanUp() {} - - @override - void track({required final String event}) { - final payload = jsonEncode({ - 'event': event, - 'properties': { - 'distinct_id': _uniqueUserId, - 'token': _projectToken, - 'platform': _getPlatform(), - 'dart_version': Platform.version, - 'is_ci': ci.isCI, - 'version': _version, - }, - }); - - _quietPost(payload); - } - - String _getPlatform() { - if (Platform.isMacOS) { - return 'MacOS'; - } else if (Platform.isWindows) { - return 'Windows'; - } else if (Platform.isLinux) { - return 'Linux'; - } else { - return 'Unknown'; + void cleanUp() { + for (final provider in providers) { + provider.cleanUp(); } } - Future _quietPost(final String payload) async { - try { - await http.post( - _endpoint, - body: 'data=$payload', - headers: { - 'Accept': 'text/plain', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ).timeout(_timeout); - } catch (e) { - return; + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + for (final provider in providers) { + provider.track( + event: event, + properties: properties, + ); } } } diff --git a/packages/cli_tools/lib/src/analytics/command_properties.dart b/packages/cli_tools/lib/src/analytics/command_properties.dart new file mode 100644 index 0000000..b80e8df --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/command_properties.dart @@ -0,0 +1,232 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; + +Map buildCommandPropertiesForAnalytics({ + required final ArgResults topLevelResults, + required final ArgParser argParser, + required final Map commands, +}) { + return _CommandPropertiesBuilder( + topLevelResults: topLevelResults, + argParser: argParser, + commands: commands, + ).build(); +} + +class _CommandPropertiesBuilder { + _CommandPropertiesBuilder({ + required this.topLevelResults, + required this.argParser, + required this.commands, + }); + + static const _maskedValue = 'xxx'; + + final ArgResults topLevelResults; + final ArgParser argParser; + final Map commands; + final Map _properties = {}; + + final List _tokens = []; + late ArgParser _currentParser = argParser; + late Map _currentCommands = commands; + var _afterDoubleDash = false; + var _expectingValue = false; + + Map build() { + _collectOptions(); + _properties['full_command'] = _buildFullCommand(); + return _properties; + } + + void _collectOptions() { + for (ArgResults? current = topLevelResults; + current != null; + current = current.command) { + _addOptions(current); + } + } + + void _addOptions(final ArgResults results) { + for (final optionName in results.options) { + if (!results.wasParsed(optionName)) { + continue; + } + final value = results[optionName]; + if (value is bool) { + _properties['flag_$optionName'] = value; + } else if (value != null) { + _properties['option_$optionName'] = value is List + ? List.filled(value.length, _maskedValue) + : _maskedValue; + } + } + } + + String _buildFullCommand() { + for (final arg in topLevelResults.arguments) { + if (_afterDoubleDash) { + _addMasked(); + continue; + } + + if (_expectingValue) { + _addMasked(); + _expectingValue = false; + continue; + } + + if (arg == '--') { + _afterDoubleDash = true; + _tokens.add('--'); + continue; + } + + if (arg.startsWith('--')) { + _handleLongOption(arg); + continue; + } + + if (arg.startsWith('-') && arg != '-') { + _handleShortOption(arg); + continue; + } + + final command = _currentCommands[arg]; + if (command != null) { + _tokens.add(arg); + _currentParser = command.argParser; + _currentCommands = command.subcommands; + continue; + } + + _addMasked(); + } + + return _tokens.join(' '); + } + + void _handleLongOption(final String arg) { + // Long options; normalize and mask any provided value. + final withoutPrefix = arg.substring(2); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + _handleInlineOption( + name: withoutPrefix.substring(0, equalIndex), + isNegated: false, + ); + return; + } + + if (withoutPrefix.startsWith('no-')) { + final name = withoutPrefix.substring(3); + _handleOption(name: name, isNegated: true); + return; + } + + _handleOption(name: withoutPrefix, isNegated: false); + } + + void _handleShortOption(final String arg) { + // Short options; expand to their long form when possible. + final withoutPrefix = arg.substring(1); + final equalIndex = withoutPrefix.indexOf('='); + if (equalIndex != -1) { + final abbreviation = withoutPrefix.substring(0, equalIndex); + final name = _optionNameForAbbreviation(abbreviation); + if (name == null) { + _addMasked(); + return; + } + _handleInlineOption(name: name, isNegated: false); + return; + } + + if (withoutPrefix.length == 1) { + final name = _optionNameForAbbreviation(withoutPrefix); + if (name == null) { + _addMasked(); + return; + } + _handleOption(name: name, isNegated: false); + return; + } + + for (var i = 0; i < withoutPrefix.length; i++) { + final abbreviation = withoutPrefix[i]; + final name = _optionNameForAbbreviation(abbreviation); + if (name == null) { + _addMasked(); + break; + } + final option = _currentParser.options[name]; + if (option == null) { + _addMasked(); + break; + } + if (option.isFlag) { + _tokens.add('--$name'); + continue; + } + _tokens.add('--$name'); + if (i < withoutPrefix.length - 1) { + _addMasked(); + } else { + _expectingValue = true; + } + break; + } + } + + void _addMasked() { + _tokens.add(_maskedValue); + } + + void _handleInlineOption({ + required final String name, + required final bool isNegated, + }) { + final handled = _handleOption( + name: name, + isNegated: isNegated, + hasInlineValue: true, + ); + if (handled) { + _addMasked(); + } + } + + String? _optionNameForAbbreviation(final String abbreviation) { + final option = _currentParser.findByAbbreviation(abbreviation); + if (option == null) { + return null; + } + for (final entry in _currentParser.options.entries) { + if (entry.value == option) { + return entry.key; + } + } + return null; + } + + bool _handleOption({ + required final String name, + required final bool isNegated, + final bool hasInlineValue = false, + }) { + final option = _currentParser.options[name]; + if (option == null) { + _addMasked(); + _expectingValue = false; + return false; + } + if (option.isFlag) { + _tokens.add(isNegated ? '--no-$name' : '--$name'); + _expectingValue = false; + return true; + } + _tokens.add('--$name'); + _expectingValue = !hasInlineValue; + return true; + } +} diff --git a/packages/cli_tools/lib/src/analytics/helpers.dart b/packages/cli_tools/lib/src/analytics/helpers.dart new file mode 100644 index 0000000..ba3b979 --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/helpers.dart @@ -0,0 +1,13 @@ +import 'dart:io'; + +String getPlatformString() { + if (Platform.isMacOS) { + return 'MacOS'; + } else if (Platform.isWindows) { + return 'Windows'; + } else if (Platform.isLinux) { + return 'Linux'; + } else { + return 'Unknown'; + } +} diff --git a/packages/cli_tools/lib/src/analytics/mixpanel.dart b/packages/cli_tools/lib/src/analytics/mixpanel.dart new file mode 100644 index 0000000..b1040be --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/mixpanel.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ci/ci.dart' as ci; +import 'package:http/http.dart' as http; + +import 'analytics.dart'; +import 'helpers.dart'; + +/// Analytics service for MixPanel. +class MixPanelAnalytics implements Analytics { + static const _defaultEndpoint = 'https://api.mixpanel.com/track'; + static const _defaultTimeout = Duration(seconds: 2); + + final String _uniqueUserId; + final String _projectToken; + final String _version; + + final Uri _endpoint; + final Duration _timeout; + + MixPanelAnalytics({ + required final String uniqueUserId, + required final String projectToken, + required final String version, + final String? endpoint, + final Duration timeout = _defaultTimeout, + final bool disableIpTracking = false, + }) : _uniqueUserId = uniqueUserId, + _projectToken = projectToken, + _version = version, + _endpoint = _buildEndpoint( + endpoint ?? _defaultEndpoint, + disableIpTracking, + ), + _timeout = timeout; + + static Uri _buildEndpoint( + final String baseEndpoint, + final bool disableIpTracking, + ) { + final uri = Uri.parse(baseEndpoint); + final ipValue = disableIpTracking ? '0' : '1'; + + final updatedUri = uri.replace( + queryParameters: { + ...uri.queryParameters, + 'ip': ipValue, + }, + ); + return updatedUri; + } + + @override + void cleanUp() {} + + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + final payload = jsonEncode({ + 'event': event, + 'properties': { + 'distinct_id': _uniqueUserId, + 'token': _projectToken, + 'platform': getPlatformString(), + 'dart_version': Platform.version, + 'is_ci': ci.isCI, + 'version': _version, + }, + }); + + _quietPost(payload); + } + + Future _quietPost(final String payload) async { + try { + await http.post( + _endpoint, + body: 'data=$payload', + headers: { + 'Accept': 'text/plain', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + ).timeout(_timeout); + } catch (e) { + return; + } + } +} diff --git a/packages/cli_tools/lib/src/analytics/posthog.dart b/packages/cli_tools/lib/src/analytics/posthog.dart new file mode 100644 index 0000000..869e983 --- /dev/null +++ b/packages/cli_tools/lib/src/analytics/posthog.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ci/ci.dart' as ci; +import 'package:http/http.dart' as http; + +import 'analytics.dart'; +import 'helpers.dart'; + +/// Analytics service for PostHog. +class PostHogAnalytics implements Analytics { + static const _defaultHost = 'https://eu.i.posthog.com'; + static const _defaultTimeout = Duration(seconds: 2); + + final String _uniqueUserId; + final String _projectApiKey; + final String _version; + + final Uri _endpoint; + final Duration _timeout; + + PostHogAnalytics({ + required final String uniqueUserId, + required final String projectApiKey, + required final String version, + final String? host, + final Duration timeout = _defaultTimeout, + }) : _uniqueUserId = uniqueUserId, + _projectApiKey = projectApiKey, + _version = version, + _endpoint = Uri.parse('${host ?? _defaultHost}/capture/'), + _timeout = timeout; + + @override + void cleanUp() {} + + @override + void track({ + required final String event, + final Map properties = const {}, + }) { + final eventData = { + 'api_key': _projectApiKey, + 'event': event, + 'distinct_id': _uniqueUserId, + 'properties': { + '\$lib': 'cli_tools', + '\$lib_version': _version, + 'platform': getPlatformString(), + 'dart_version': Platform.version, + 'is_ci': ci.isCI, + ...properties, + }, + }; + + _quietPost(eventData); + } + + Future _quietPost(final Map eventData) async { + try { + await http + .post( + _endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(eventData), + ) + .timeout(_timeout); + } catch (e) { + return; + } + } +} diff --git a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart index 9ea518a..f0b412e 100644 --- a/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart +++ b/packages/cli_tools/lib/src/better_command_runner/better_command_runner.dart @@ -5,6 +5,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:config/config.dart'; +import '../analytics/command_properties.dart'; import 'completion/completion_command.dart'; import 'completion/completion_tool.dart' show CompletionScript; @@ -48,7 +49,10 @@ typedef SetLogLevel = void Function({ }); /// A function type for tracking events. -typedef OnAnalyticsEvent = void Function(String event); +typedef OnAnalyticsEvent = void Function( + String event, + Map properties, +); /// An extension of [CommandRunner] with additional features. /// @@ -213,10 +217,13 @@ class BetterCommandRunner /// Invoked from BetterCommandRunner upon command execution /// with the event name, or command name if applicable. /// Can be overridden to customize the event sending behavior. - void sendAnalyticsEvent(final String event) { + void sendAnalyticsEvent( + final String event, [ + final Map properties = const {}, + ]) { if (analyticsEnabled()) { try { - onAnalyticsEvent?.call(event); + onAnalyticsEvent?.call(event, properties); } catch (_) { // Silently ignore analytics sending errors to not disrupt the main flow } @@ -326,7 +333,12 @@ class BetterCommandRunner // results there should always be a name specified. assert(command.name != null, 'Command name should never be null.'); sendAnalyticsEvent( - command.name ?? BetterCommandRunnerAnalyticsEvents.invalid, + command.name!, + buildCommandPropertiesForAnalytics( + topLevelResults: topLevelResults, + argParser: argParser, + commands: commands, + ), ); return; } diff --git a/packages/cli_tools/test/analytics/command_properties_test.dart b/packages/cli_tools/test/analytics/command_properties_test.dart new file mode 100644 index 0000000..e389bb0 --- /dev/null +++ b/packages/cli_tools/test/analytics/command_properties_test.dart @@ -0,0 +1,231 @@ +import 'package:args/command_runner.dart'; +import 'package:cli_tools/src/analytics/command_properties.dart'; +import 'package:test/test.dart'; + +class TemplateCommand extends Command { + static const commandName = 'template'; + + @override + String get description => 'Template subcommand'; + + @override + String get name => commandName; + + TemplateCommand() { + argParser.addOption('path', abbr: 'p'); + } + + @override + void run() {} +} + +class CreateCommand extends Command { + static const commandName = 'create'; + + @override + String get description => 'Create command'; + + @override + String get name => commandName; + + CreateCommand() { + argParser.addFlag('mini', abbr: 'm', negatable: false); + argParser.addFlag('force', abbr: 'f', defaultsTo: true); + argParser.addOption('name', abbr: 'n'); + argParser.addMultiOption('tag', abbr: 't'); + addSubcommand(TemplateCommand()); + } + + @override + void run() {} +} + +void main() { + group('Given command properties builder', () { + late CommandRunner runner; + + setUp(() { + runner = CommandRunner('tool', 'test cli') + ..addCommand(CreateCommand()); + }); + + test( + 'when running with flag and positional ' + 'then flags are preserved and positionals masked', + () { + final args = [CreateCommand.commandName, '--mini', 'project']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties['full_command'], 'create --mini xxx'); + }, + ); + + test( + 'when running with option value ' + 'then value is masked in properties and full command', + () { + final args = [CreateCommand.commandName, '--mini', '--name', 'secret']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --mini --name xxx'); + }, + ); + + test( + 'when running with inline long option value ' + 'then value is masked and no extra arg is consumed', + () { + final args = [CreateCommand.commandName, '--name=secret', 'extra']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --name xxx xxx'); + }, + ); + + test( + 'when running with inline short option value ' + 'then value is masked', + () { + final args = [CreateCommand.commandName, '-n=secret']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --name xxx'); + }, + ); + + test( + 'when running with negated flag ' + 'then full command preserves negation and property is false', + () { + final args = [CreateCommand.commandName, '--no-force']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_force', false)); + expect(properties['full_command'], 'create --no-force'); + }, + ); + + test( + 'when running with repeated multi options ' + 'then each value is masked', + () { + final args = [CreateCommand.commandName, '--tag', 'a', '--tag', 'b']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties['option_tag'], equals(['xxx', 'xxx'])); + expect(properties['full_command'], 'create --tag xxx --tag xxx'); + }, + ); + + test( + 'when running with short flag and short option ' + 'then option value is masked', + () { + final args = [CreateCommand.commandName, '-m', '-n', 'secret']; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('flag_mini', true)); + expect(properties, containsPair('option_name', 'xxx')); + expect(properties['full_command'], 'create --mini --name xxx'); + }, + ); + + test( + 'when running with subcommand option ' + 'then subcommand parser is used for masking', + () { + final args = [ + CreateCommand.commandName, + TemplateCommand.commandName, + '--path', + '/tmp/secret' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_path', 'xxx')); + expect(properties['full_command'], 'create template --path xxx'); + }, + ); + + test( + 'when running with double dash ' + 'then all following tokens are masked', + () { + final args = [ + CreateCommand.commandName, + '--name', + 'secret', + '--', + '--not-option', + 'literal' + ]; + final results = runner.parse(args); + + final properties = buildCommandPropertiesForAnalytics( + topLevelResults: results, + argParser: runner.argParser, + commands: runner.commands, + ); + + expect(properties, containsPair('option_name', 'xxx')); + expect( + properties['full_command'], + 'create --name xxx -- xxx xxx', + ); + }, + ); + }); +} diff --git a/packages/cli_tools/test/better_command_runner/analytics_test.dart b/packages/cli_tools/test/better_command_runner/analytics_test.dart index fc99d82..dadcf3b 100644 --- a/packages/cli_tools/test/better_command_runner/analytics_test.dart +++ b/packages/cli_tools/test/better_command_runner/analytics_test.dart @@ -78,7 +78,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) {}, + onAnalyticsEvent: (final event, final properties) {}, messageOutput: const MessageOutput(), ); @@ -97,7 +97,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), ); assert(runner.analyticsEnabled()); @@ -210,7 +210,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), )..addCommand(MockCommand()); assert(runner.analyticsEnabled()); @@ -275,7 +275,7 @@ void main() { runner = BetterCommandRunner( 'test', 'this is a test cli', - onAnalyticsEvent: (final event) => events.add(event), + onAnalyticsEvent: (final event, final _) => events.add(event), messageOutput: const MessageOutput(), )..addCommand(command); assert(runner.analyticsEnabled()); diff --git a/packages/cli_tools/test/better_command_runner/better_command_test.dart b/packages/cli_tools/test/better_command_runner/better_command_test.dart index d07b23c..745d8f8 100644 --- a/packages/cli_tools/test/better_command_runner/better_command_test.dart +++ b/packages/cli_tools/test/better_command_runner/better_command_test.dart @@ -59,7 +59,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test project', - onAnalyticsEvent: (final e) => analyticsEvents.add(e), + onAnalyticsEvent: (final e, final _) => analyticsEvents.add(e), messageOutput: messageOutput, )..addCommand(betterCommand); @@ -158,7 +158,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test project', - onAnalyticsEvent: (final e) => analyticsEvents.add(e), + onAnalyticsEvent: (final e, final _) => analyticsEvents.add(e), globalOptions: [], messageOutput: messageOutput, )..addCommand(betterCommand); diff --git a/packages/cli_tools/test/better_command_runner/default_flags_test.dart b/packages/cli_tools/test/better_command_runner/default_flags_test.dart index 6a8fbcd..a310a8d 100644 --- a/packages/cli_tools/test/better_command_runner/default_flags_test.dart +++ b/packages/cli_tools/test/better_command_runner/default_flags_test.dart @@ -6,7 +6,7 @@ void main() { final runner = BetterCommandRunner( 'test', 'test description', - onAnalyticsEvent: (final event) {}, + onAnalyticsEvent: (final event, final _) {}, messageOutput: const MessageOutput(), );