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';
124 changes: 37 additions & 87 deletions packages/cli_tools/lib/src/analytics/analytics.dart
Original file line number Diff line number Diff line change
@@ -1,106 +1,56 @@
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 {},
});

/// Identifies a user with additional properties (e.g., email).
void identify({
final String? email,
final Map<String, dynamic>? properties,
});
}

/// 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);
void cleanUp() {
for (final provider in providers) {
provider.cleanUp();
}
}

String _getPlatform() {
if (Platform.isMacOS) {
return 'MacOS';
} else if (Platform.isWindows) {
return 'Windows';
} else if (Platform.isLinux) {
return 'Linux';
} else {
return 'Unknown';
@override
void track({
required final String event,
final Map<String, dynamic> properties = const {},
}) {
for (final provider in providers) {
provider.track(
event: event,
properties: properties,
);
}
}

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 identify({
final String? email,
final Map<String, dynamic>? properties,
}) {
for (final provider in providers) {
provider.identify(
email: email,
properties: properties,
);
}
}
}
207 changes: 207 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,207 @@
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,
}) {
const maskedValue = 'xxx';
final properties = <String, dynamic>{};

// Collect explicitly-provided options/flags and mask any values.
void addOptions(final ArgResults results) {
Comment thread
vlidholt marked this conversation as resolved.
Outdated
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;
}
}
}

for (ArgResults? current = topLevelResults;
current != null;
current = current.command) {
addOptions(current);
}

// Reconstruct the command in user input order, masking values.
properties['full_command'] = _buildFullCommandForAnalytics(
arguments: topLevelResults.arguments,
maskedValue: maskedValue,
argParser: argParser,
commands: commands,
);

return properties;
}

String _buildFullCommandForAnalytics({
required final List<String> arguments,
required final String maskedValue,
required final ArgParser argParser,
required final Map<String, Command> commands,
}) {
final tokens = <String>[];
var currentParser = argParser;
var currentCommands = commands;
var afterDoubleDash = false;
var expectingValue = false;

// Use a consistent placeholder for any sensitive tokens.
void addMasked() {
tokens.add(maskedValue);
}

String? optionNameForAbbreviation(
final ArgParser parser,
final String abbreviation,
) {
final option = parser.findByAbbreviation(abbreviation);
if (option == null) {
return null;
}
for (final entry in parser.options.entries) {
if (entry.value == option) {
return entry.key;
}
}
return null;
}

// Normalizes option tokens and tracks whether a value is expected next.
bool handleOption(
final String name, {
required final bool isNegated,
final bool hasInlineValue = false,
}) {
final option = currentParser.options[name];
if (option == null) {
addMasked();
return false;
}
if (option.isFlag) {
tokens.add(isNegated ? '--no-$name' : '--$name');
return true;
}
tokens.add('--$name');
if (!hasInlineValue) {
expectingValue = true;
}
return true;
}

for (final arg in arguments) {
if (afterDoubleDash) {
addMasked();
continue;
}

if (expectingValue) {
addMasked();
expectingValue = false;
continue;
}

if (arg == '--') {
afterDoubleDash = true;
tokens.add('--');
continue;
}

if (arg.startsWith('--')) {
// Long options; normalize and mask any provided value.
final withoutPrefix = arg.substring(2);
final equalIndex = withoutPrefix.indexOf('=');
if (equalIndex != -1) {
final name = withoutPrefix.substring(0, equalIndex);
if (handleOption(name, isNegated: false, hasInlineValue: true)) {
addMasked();
}
continue;
}

if (withoutPrefix.startsWith('no-')) {
final name = withoutPrefix.substring(3);
handleOption(name, isNegated: true);
continue;
}

handleOption(withoutPrefix, isNegated: false);
continue;
}

if (arg.startsWith('-') && 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(currentParser, abbreviation);
if (name == null) {
addMasked();
continue;
}
if (handleOption(name, isNegated: false, hasInlineValue: true)) {
addMasked();
}
continue;
}

if (withoutPrefix.length == 1) {
final name = optionNameForAbbreviation(currentParser, withoutPrefix);
if (name == null) {
addMasked();
continue;
}
handleOption(name, isNegated: false);
continue;
}

for (var i = 0; i < withoutPrefix.length; i++) {
final abbreviation = withoutPrefix[i];
final name = optionNameForAbbreviation(currentParser, 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;
}
continue;
}

final command = currentCommands[arg];
if (command != null) {
tokens.add(arg);
currentParser = command.argParser;
currentCommands = command.subcommands;
continue;
}

addMasked();
}

return tokens.join(' ');
}
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
Loading