Skip to content

Commit a936698

Browse files
authored
feat: add usage analytics (#71)
1 parent 2163d7f commit a936698

File tree

5 files changed

+165
-19
lines changed

5 files changed

+165
-19
lines changed

lib/src/command_runner.dart

+46-7
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,65 @@
11
import 'package:args/args.dart';
22
import 'package:args/command_runner.dart';
3+
import 'package:io/ansi.dart';
34
import 'package:io/io.dart';
45
import 'package:mason/mason.dart';
6+
import 'package:usage/usage_io.dart';
57
import 'package:very_good_cli/src/commands/commands.dart';
68

79
import 'version.dart';
810

11+
// The Google Analytics tracking ID.
12+
const _gaTrackingId = 'UA-117465969-4';
13+
14+
// The Google Analytics Application Name.
15+
const _gaAppName = 'very-good-cli';
16+
917
/// {@template very_good_command_runner}
1018
/// A [CommandRunner] for the Very Good CLI.
1119
/// {@endtemplate}
1220
class VeryGoodCommandRunner extends CommandRunner<int> {
1321
/// {@macro very_good_command_runner}
14-
VeryGoodCommandRunner({Logger logger})
22+
VeryGoodCommandRunner({Analytics analytics, Logger logger})
1523
: _logger = logger ?? Logger(),
24+
_analytics =
25+
analytics ?? AnalyticsIO(_gaTrackingId, _gaAppName, packageVersion),
1626
super('very_good', '🦄 A Very Good Command Line Interface') {
17-
argParser.addFlag(
18-
'version',
19-
negatable: false,
20-
help: 'Print the current version.',
21-
);
22-
addCommand(CreateCommand(logger: logger));
27+
argParser
28+
..addFlag(
29+
'version',
30+
negatable: false,
31+
help: 'Print the current version.',
32+
)
33+
..addOption(
34+
'analytics',
35+
help: 'Opt into or out of anonymous usage statistics.',
36+
);
37+
addCommand(CreateCommand(analytics: _analytics, logger: logger));
2338
}
2439

40+
/// Standard timeout duration for the CLI.
41+
static const timeout = Duration(milliseconds: 500);
42+
2543
final Logger _logger;
44+
final Analytics _analytics;
2645

2746
@override
2847
Future<int> run(Iterable<String> args) async {
2948
try {
49+
if (_analytics.firstRun) {
50+
final response = _logger.prompt(lightGray.wrap(
51+
'''+---------------------------------------------------+
52+
| Welcome to the Very Good CLI! |
53+
+---------------------------------------------------+
54+
| We would like to collect anonymous |
55+
| usage statistics in order to improve the tool. |
56+
| Would you like to opt-into help us improve? [y/n] |
57+
+---------------------------------------------------+\n''',
58+
));
59+
final normalizedResponse = response.toLowerCase().trim();
60+
_analytics.enabled =
61+
normalizedResponse == 'y' || normalizedResponse == 'yes';
62+
}
3063
final _argResults = parse(args);
3164
return await runCommand(_argResults) ?? ExitCode.success.code;
3265
} on FormatException catch (e, stackTrace) {
@@ -52,6 +85,12 @@ class VeryGoodCommandRunner extends CommandRunner<int> {
5285
_logger.info('very_good version: $packageVersion');
5386
return ExitCode.success.code;
5487
}
88+
if (topLevelResults['analytics'] != null) {
89+
final optIn = topLevelResults['analytics'] == 'true' ? true : false;
90+
_analytics.enabled = optIn;
91+
_logger.info('analytics ${_analytics.enabled ? 'enabled' : 'disabled'}.');
92+
return ExitCode.success.code;
93+
}
5594
return super.runCommand(topLevelResults);
5695
}
5796
}

lib/src/commands/create.dart

+17-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import 'package:io/io.dart';
77
import 'package:mason/mason.dart';
88
import 'package:meta/meta.dart';
99
import 'package:path/path.dart' as path;
10-
10+
import 'package:usage/usage_io.dart';
11+
import 'package:very_good_analysis/very_good_analysis.dart';
12+
import 'package:very_good_cli/src/command_runner.dart';
1113
import 'package:very_good_cli/src/templates/very_good_core_bundle.dart';
1214

1315
// A valid Dart identifier that can be used for a package, i.e. no
@@ -24,9 +26,12 @@ typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);
2426
class CreateCommand extends Command<int> {
2527
/// {@macro create_command}
2628
CreateCommand({
29+
@required Analytics analytics,
2730
Logger logger,
2831
GeneratorBuilder generator,
29-
}) : _logger = logger ?? Logger(),
32+
}) : assert(analytics != null),
33+
_analytics = analytics,
34+
_logger = logger ?? Logger(),
3035
_generator = generator ?? MasonGenerator.fromBundle {
3136
argParser.addOption(
3237
'project-name',
@@ -36,6 +41,7 @@ class CreateCommand extends Command<int> {
3641
);
3742
}
3843

44+
final Analytics _analytics;
3945
final Logger _logger;
4046
final Future<MasonGenerator> Function(MasonBundle) _generator;
4147

@@ -62,8 +68,17 @@ class CreateCommand extends Command<int> {
6268
DirectoryGeneratorTarget(outputDirectory, _logger),
6369
vars: {'project_name': projectName},
6470
);
71+
6572
generateDone('Bootstrapping complete');
6673
_logSummary(fileCount);
74+
75+
unawaited(_analytics.sendEvent(
76+
'create',
77+
generator.id,
78+
label: generator.description,
79+
));
80+
await _analytics.waitForLastPing(timeout: VeryGoodCommandRunner.timeout);
81+
6782
return ExitCode.success.code;
6883
}
6984

pubspec.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ dependencies:
1212
mason: ^0.0.1-dev.26
1313
meta: ^1.2.4
1414
path: ^1.7.0
15+
usage: ^3.4.2
16+
very_good_analysis: ^1.0.4
1517

1618
dev_dependencies:
1719
coverage: ^0.13.4
@@ -20,7 +22,6 @@ dev_dependencies:
2022
build_version: ^2.0.1
2123
mockito: ^4.0.0
2224
test: ^1.14.3
23-
very_good_analysis: ^1.0.4
2425

2526
executables:
2627
very_good:

test/src/command_runner_test.dart

+62-6
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@ import 'package:io/io.dart';
66
import 'package:mason/mason.dart';
77
import 'package:mockito/mockito.dart';
88
import 'package:test/test.dart';
9+
import 'package:usage/usage_io.dart';
910
import 'package:very_good_cli/src/command_runner.dart';
1011
import 'package:very_good_cli/src/version.dart';
1112

13+
class MockAnalytics extends Mock implements Analytics {}
14+
1215
class MockLogger extends Mock implements Logger {}
1316

1417
void main() {
1518
group('VeryGoodCommandRunner', () {
1619
List<String> printLogs;
20+
Analytics analytics;
1721
Logger logger;
1822
VeryGoodCommandRunner commandRunner;
1923

@@ -28,16 +32,41 @@ void main() {
2832

2933
setUp(() {
3034
printLogs = [];
35+
36+
analytics = MockAnalytics();
37+
when(analytics.firstRun).thenReturn(false);
38+
when(analytics.enabled).thenReturn(false);
39+
3140
logger = MockLogger();
32-
commandRunner = VeryGoodCommandRunner(logger: logger);
41+
commandRunner = VeryGoodCommandRunner(
42+
analytics: analytics,
43+
logger: logger,
44+
);
3345
});
3446

35-
test('can be instantiated without an explicit logger instance', () {
47+
test('can be instantiated without an explicit analytics/logger instance',
48+
() {
3649
final commandRunner = VeryGoodCommandRunner();
3750
expect(commandRunner, isNotNull);
3851
});
3952

4053
group('run', () {
54+
test('prompts for analytics collection on first run (y)', () async {
55+
when(analytics.firstRun).thenReturn(true);
56+
when(logger.prompt(any)).thenReturn('y');
57+
final result = await commandRunner.run(['--version']);
58+
expect(result, equals(ExitCode.success.code));
59+
verify(analytics.enabled = true);
60+
});
61+
62+
test('prompts for analytics collection on first run (n)', () async {
63+
when(analytics.firstRun).thenReturn(true);
64+
when(logger.prompt(any)).thenReturn('n');
65+
final result = await commandRunner.run(['--version']);
66+
expect(result, equals(ExitCode.success.code));
67+
verify(analytics.enabled = false);
68+
});
69+
4170
test('handles FormatException', () async {
4271
const exception = FormatException('oops!');
4372
var isFirstInvocation = true;
@@ -75,8 +104,9 @@ void main() {
75104
'Usage: very_good <command> [arguments]\n'
76105
'\n'
77106
'Global options:\n'
78-
'-h, --help Print this usage information.\n'
79-
' --version Print the current version.\n'
107+
'-h, --help Print this usage information.\n'
108+
' --version Print the current version.\n'
109+
''' --analytics Opt into or out of anonymous usage statistics.\n'''
80110
'\n'
81111
'Available commands:\n'
82112
''' create Creates a new very good flutter application in seconds.\n'''
@@ -96,8 +126,9 @@ void main() {
96126
'Usage: very_good <command> [arguments]\n'
97127
'\n'
98128
'Global options:\n'
99-
'-h, --help Print this usage information.\n'
100-
' --version Print the current version.\n'
129+
'-h, --help Print this usage information.\n'
130+
' --version Print the current version.\n'
131+
''' --analytics Opt into or out of anonymous usage statistics.\n'''
101132
'\n'
102133
'Available commands:\n'
103134
''' create Creates a new very good flutter application in seconds.\n'''
@@ -116,6 +147,31 @@ void main() {
116147
}));
117148
});
118149

150+
group('--analytics', () {
151+
test('sets analytics.enabled to true', () async {
152+
final result = await commandRunner.run(['--analytics', 'true']);
153+
expect(result, equals(ExitCode.success.code));
154+
verify(analytics.enabled = true);
155+
});
156+
157+
test('sets analytics.enabled to false', () async {
158+
final result = await commandRunner.run(['--analytics', 'false']);
159+
expect(result, equals(ExitCode.success.code));
160+
verify(analytics.enabled = false);
161+
});
162+
163+
test('sets analytics.enabled to false (garbage value)', () async {
164+
final result = await commandRunner.run(['--analytics', 'garbage']);
165+
expect(result, equals(ExitCode.success.code));
166+
verify(analytics.enabled = false);
167+
});
168+
169+
test('exits with bad usage when missing value', () async {
170+
final result = await commandRunner.run(['--analytics']);
171+
expect(result, equals(ExitCode.usage.code));
172+
});
173+
});
174+
119175
group('--version', () {
120176
test('outputs current version', () async {
121177
final result = await commandRunner.run(['--version']);

test/src/commands/create_test.dart

+38-3
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,50 @@ import 'package:io/io.dart';
44
import 'package:mason/mason.dart';
55
import 'package:mockito/mockito.dart';
66
import 'package:test/test.dart';
7+
import 'package:usage/usage_io.dart';
78
import 'package:very_good_cli/src/command_runner.dart';
89
import 'package:very_good_cli/src/commands/create.dart';
910

1011
class MockArgResults extends Mock implements ArgResults {}
1112

13+
class MockAnalytics extends Mock implements Analytics {}
14+
1215
class MockLogger extends Mock implements Logger {}
1316

1417
class MockMasonGenerator extends Mock implements MasonGenerator {}
1518

1619
void main() {
1720
group('Create', () {
21+
Analytics analytics;
1822
Logger logger;
1923
VeryGoodCommandRunner commandRunner;
2024

2125
setUp(() {
26+
analytics = MockAnalytics();
27+
when(analytics.firstRun).thenReturn(false);
28+
when(analytics.enabled).thenReturn(false);
29+
when(analytics.sendEvent(any, any, label: anyNamed('label')))
30+
.thenAnswer((_) => Future.value());
31+
when(analytics.waitForLastPing(timeout: anyNamed('timeout')))
32+
.thenAnswer((_) => Future.value());
33+
2234
logger = MockLogger();
2335
when(logger.progress(any)).thenReturn(([_]) {});
24-
commandRunner = VeryGoodCommandRunner(logger: logger);
36+
commandRunner = VeryGoodCommandRunner(
37+
analytics: analytics,
38+
logger: logger,
39+
);
40+
});
41+
42+
test('throws AssertionError when analytics is null', () {
43+
expect(
44+
() => CreateCommand(analytics: null),
45+
throwsA(isA<AssertionError>()),
46+
);
2547
});
2648

27-
test('can be instantiated without any explicit dependencies', () {
28-
final command = CreateCommand();
49+
test('can be instantiated without explicit logger', () {
50+
final command = CreateCommand(analytics: analytics);
2951
expect(command, isNotNull);
3052
});
3153

@@ -69,11 +91,14 @@ void main() {
6991
final argResults = MockArgResults();
7092
final generator = MockMasonGenerator();
7193
final command = CreateCommand(
94+
analytics: analytics,
7295
logger: logger,
7396
generator: (_) async => generator,
7497
)..argResultOverrides = argResults;
7598
when(argResults['project-name']).thenReturn('my_app');
7699
when(argResults.rest).thenReturn(['.tmp']);
100+
when(generator.id).thenReturn('generator_id');
101+
when(generator.description).thenReturn('generator description');
77102
when(generator.generate(any, vars: anyNamed('vars')))
78103
.thenAnswer((_) async => 62);
79104
final result = await command.run();
@@ -96,6 +121,16 @@ void main() {
96121
vars: {'project_name': 'my_app'},
97122
),
98123
).called(1);
124+
verify(
125+
analytics.sendEvent(
126+
'create',
127+
'generator_id',
128+
label: 'generator description',
129+
),
130+
).called(1);
131+
verify(
132+
analytics.waitForLastPing(timeout: VeryGoodCommandRunner.timeout),
133+
).called(1);
99134
});
100135
});
101136
}

0 commit comments

Comments
 (0)