Skip to content
2 changes: 2 additions & 0 deletions packages/cli_tools/lib/analytics.dart
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export 'src/analytics/analytics.dart';
export 'src/analytics/mixpanel.dart';
export 'src/analytics/posthog.dart';
109 changes: 20 additions & 89 deletions packages/cli_tools/lib/src/analytics/analytics.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> 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<Analytics> 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<void> _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<String, dynamic> properties = const {},
}) {
for (final provider in providers) {
provider.track(
event: event,
properties: properties,
);
}
}
}
232 changes: 232 additions & 0 deletions packages/cli_tools/lib/src/analytics/command_properties.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import 'package:args/args.dart';
import 'package:args/command_runner.dart';

Map<String, dynamic> buildCommandPropertiesForAnalytics({
required final ArgResults topLevelResults,
required final ArgParser argParser,
required final Map<String, Command> 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<String, Command> commands;
final Map<String, dynamic> _properties = <String, dynamic>{};

final List<String> _tokens = <String>[];
late ArgParser _currentParser = argParser;
late Map<String, Command> _currentCommands = commands;
var _afterDoubleDash = false;
var _expectingValue = false;

Map<String, dynamic> 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;
}
}
13 changes: 13 additions & 0 deletions packages/cli_tools/lib/src/analytics/helpers.dart
Original file line number Diff line number Diff line change
@@ -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';
}
}
Loading