-
Notifications
You must be signed in to change notification settings - Fork 5
feat: Adds support for PostHog analytics. #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
4a7f175
feat: Adds support for PostHog analytics.
vlidholt 46e6b2b
fix: Removes identify method.
vlidholt fabebf6
chore: Ran dart format.
vlidholt 2b2b35e
fix: Removes debug print.
vlidholt 299ef30
fix: Breaks out internal methods in to private methods.
vlidholt ef97b6d
fix: Dart format.
vlidholt 5b3bdee
Revert "fix: Dart format."
vlidholt 248aa1f
Revert "fix: Breaks out internal methods in to private methods."
vlidholt 913f897
refactor: Uses a private class for parsing the command line options.
vlidholt 5d1e862
refactor: Simplifies the code.
vlidholt 73c90b6
refactor: Breaks duplicated code out into a new method.
vlidholt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
207
packages/cli_tools/lib/src/analytics/command_properties.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| 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(' '); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.