Add some tests of analytics

Change-Id: Icd4b86346d199649b156776df9835414a1ad41cd
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/246980
Reviewed-by: Konstantin Shcheglov <scheglov@google.com>
Commit-Queue: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/analytics/google_analytics_manager.dart b/pkg/analysis_server/lib/src/analytics/google_analytics_manager.dart
index 626004f..a744db5f 100644
--- a/pkg/analysis_server/lib/src/analytics/google_analytics_manager.dart
+++ b/pkg/analysis_server/lib/src/analytics/google_analytics_manager.dart
@@ -154,6 +154,7 @@
     analytics.sendEvent('language_server', 'session', parameters: {
       'flags': sessionData.commandLineArguments,
       'clientId': sessionData.clientId,
+      'clientVersion': sessionData.clientVersion,
       'sdkVersion': sessionData.sdkVersion,
       'duration': duration.toString(),
       'plugins': _pluginData.usageCountData,
diff --git a/pkg/analysis_server/test/src/analytics/google_analytics_manager_test.dart b/pkg/analysis_server/test/src/analytics/google_analytics_manager_test.dart
new file mode 100644
index 0000000..129b7a0
--- /dev/null
+++ b/pkg/analysis_server/test/src/analytics/google_analytics_manager_test.dart
@@ -0,0 +1,321 @@
+// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:analysis_server/protocol/protocol.dart';
+import 'package:analysis_server/src/analytics/google_analytics_manager.dart';
+import 'package:analysis_server/src/analytics/percentile_calculator.dart';
+import 'package:analysis_server/src/plugin/plugin_manager.dart';
+import 'package:analyzer/dart/analysis/context_root.dart' as analyzer;
+import 'package:telemetry/telemetry.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+void main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(GoogleAnalyticsManagerTest);
+  });
+}
+
+@reflectiveTest
+class GoogleAnalyticsManagerTest {
+  final analytics = _MockAnalytics();
+  late final GoogleAnalyticsManager manager;
+
+  void setUp() {
+    manager = GoogleAnalyticsManager(analytics);
+  }
+
+  void test_plugin_request() {
+    _defaultStartup();
+    PluginManager.pluginResponseTimes[_MockPluginInfo('a')] = {
+      'analysis.getNavigation': PercentileCalculator(),
+    };
+    manager.shutdown();
+    analytics.assertEvents([
+      _ExpectedEvent.session(),
+      _ExpectedEvent.pluginRequest(parameters: {
+        'pluginId': 'a',
+        'method': 'analysis.getNavigation',
+        'duration': _IsPercentiles(),
+      }),
+    ]);
+    PluginManager.pluginResponseTimes.clear();
+  }
+
+  void test_server_request() {
+    _defaultStartup();
+    manager.startedRequest(
+        request: Request('1', 'server.shutdown'), startTime: _now());
+    manager.sentResponse(response: Response('1'));
+    manager.shutdown();
+    analytics.assertEvents([
+      _ExpectedEvent.session(),
+      _ExpectedEvent.request(parameters: {
+        'latency': _IsPercentiles(),
+        'method': 'server.shutdown',
+        'duration': _IsPercentiles(),
+      }),
+    ]);
+  }
+
+  void test_shutdownWithoutStartup() {
+    manager.shutdown();
+    analytics.assertNoEvents();
+  }
+
+  void test_startup_withoutVersion() {
+    var arguments = ['a', 'b'];
+    var clientId = 'clientId';
+    var sdkVersion = 'sdkVersion';
+    manager.startUp(
+        time: DateTime.now(),
+        arguments: arguments,
+        clientId: clientId,
+        clientVersion: null,
+        sdkVersion: sdkVersion);
+    manager.shutdown();
+    analytics.assertEvents([
+      _ExpectedEvent.session(parameters: {
+        'flags': arguments.join(' '),
+        'clientId': clientId,
+        'clientVersion': '',
+        'sdkVersion': sdkVersion,
+        'duration': _IsStringEncodedPositiveInt(),
+      }),
+    ]);
+  }
+
+  void test_startup_withPlugins() {
+    _defaultStartup();
+    manager.changedPlugins(_MockPluginManager(plugins: [
+      _MockPluginInfo('a'),
+      _MockPluginInfo('b'),
+    ]));
+    manager.shutdown();
+    analytics.assertEvents([
+      _ExpectedEvent.session(parameters: {
+        'plugins': '{"recordCount":1,"rootCounts":{"a":"1[0, 0, 0, 0, 0, 0, '
+            '0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]","b":"1[0, 0, 0, 0, 0, '
+            '0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]"}}'
+      }),
+    ]);
+  }
+
+  void test_startup_withVersion() {
+    var arguments = ['a', 'b'];
+    var clientId = 'clientId';
+    var clientVersion = 'clientVersion';
+    var sdkVersion = 'sdkVersion';
+    manager.startUp(
+        time: DateTime.now(),
+        arguments: arguments,
+        clientId: clientId,
+        clientVersion: clientVersion,
+        sdkVersion: sdkVersion);
+    manager.shutdown();
+    analytics.assertEvents([
+      _ExpectedEvent.session(parameters: {
+        'flags': arguments.join(' '),
+        'clientId': clientId,
+        'clientVersion': clientVersion,
+        '': isNull,
+        'sdkVersion': sdkVersion,
+        'duration': _IsStringEncodedPositiveInt(),
+      }),
+    ]);
+  }
+
+  void _defaultStartup() {
+    manager.startUp(
+        time: DateTime.now(),
+        arguments: [],
+        clientId: '',
+        clientVersion: null,
+        sdkVersion: '');
+  }
+
+  DateTime _now() => DateTime.now();
+}
+
+/// A record of an event that was reported to analytics.
+class _Event {
+  final String category;
+  final String action;
+  final String? label;
+  final int? value;
+  final Map<String, String>? parameters;
+
+  _Event(this.category, this.action, this.label, this.value, this.parameters);
+}
+
+/// A record of an event that was reported to analytics.
+class _ExpectedEvent {
+  final String category;
+  final String action;
+  final String? label;
+  final int? value;
+  final Map<String, Object>? parameters;
+
+  _ExpectedEvent(this.category, this.action,
+      {this.label, // ignore: unused_element
+      this.value, // ignore: unused_element
+      this.parameters});
+
+  _ExpectedEvent.pluginRequest({Map<String, Object>? parameters})
+      : this('language_server', 'pluginRequest', parameters: parameters);
+
+  _ExpectedEvent.request({Map<String, Object>? parameters})
+      : this('language_server', 'request', parameters: parameters);
+
+  _ExpectedEvent.session({Map<String, Object>? parameters})
+      : this('language_server', 'session', parameters: parameters);
+
+  /// Compare the expected event with the [actual] event, failing if the actual
+  /// doesn't match the expected.
+  void matches(_Event actual) {
+    expect(actual.category, category);
+    expect(actual.action, action);
+    if (label != null) {
+      expect(actual.label, label);
+    }
+    if (value != null) {
+      expect(actual.value, value);
+    }
+    final actualParameters = actual.parameters;
+    final expectedParameters = parameters;
+    if (expectedParameters != null) {
+      if (actualParameters == null) {
+        fail('Expected parameters but found none');
+      }
+      for (var expectedKey in expectedParameters.keys) {
+        var actualValue = actualParameters[expectedKey];
+        var expectedValue = expectedParameters[expectedKey];
+        expect(actualValue, expectedValue, reason: 'For key $expectedKey');
+      }
+    }
+  }
+}
+
+/// A matcher for strings containing positive integer values.
+class _IsPercentiles extends Matcher {
+  const _IsPercentiles();
+
+  @override
+  Description describe(Description description) =>
+      description.add('percentiles');
+
+  @override
+  bool matches(Object? item, Map matchState) {
+    if (item is! String || !item.endsWith(']')) {
+      return false;
+    }
+    var index = item.indexOf('[');
+    var count = item.substring(0, index);
+    if (!_isStringEncodedPositiveInt(count)) {
+      return false;
+    }
+    var percentiles = item.substring(index + 1, item.length - 1).split(', ');
+    if (percentiles.length != 20) {
+      return false;
+    }
+    for (var percentile in percentiles) {
+      if (!_isStringEncodedPositiveInt(percentile)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  bool _isStringEncodedPositiveInt(String item) {
+    try {
+      var value = int.parse(item);
+      return value >= 0;
+    } catch (exception) {
+      return false;
+    }
+  }
+}
+
+/// A matcher for strings containing positive integer values.
+class _IsStringEncodedPositiveInt extends Matcher {
+  const _IsStringEncodedPositiveInt();
+
+  @override
+  Description describe(Description description) =>
+      description.add('a string encoded positive integer');
+
+  @override
+  bool matches(Object? item, Map matchState) {
+    if (item is! String) {
+      return false;
+    }
+    try {
+      var value = int.parse(item);
+      return value >= 0;
+    } catch (exception) {
+      return false;
+    }
+  }
+}
+
+/// An implementation of [Analytics] specialized for testing.
+class _MockAnalytics implements Analytics {
+  List<_Event> events = [];
+
+  _MockAnalytics();
+
+  void assertEvents(List<_ExpectedEvent> expectedEvents) {
+    var expectedCount = expectedEvents.length;
+    expect(events, hasLength(expectedCount));
+    for (int i = 0; i < expectedCount; i++) {
+      expectedEvents[i].matches(events[i]);
+    }
+  }
+
+  void assertNoEvents() {
+    expect(events, isEmpty);
+  }
+
+  @override
+  void close() {
+    // ignored
+  }
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+
+  @override
+  Future sendEvent(String category, String action,
+      {String? label, int? value, Map<String, String>? parameters}) async {
+    events.add(_Event(category, action, label, value, parameters));
+  }
+
+  @override
+  Future waitForLastPing({Duration? timeout}) async {
+    // ignored
+  }
+}
+
+class _MockPluginInfo implements PluginInfo {
+  @override
+  String pluginId;
+
+  _MockPluginInfo(this.pluginId);
+
+  @override
+  Set<analyzer.ContextRoot> get contextRoots => {};
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
+
+class _MockPluginManager implements PluginManager {
+  @override
+  List<PluginInfo> plugins;
+
+  _MockPluginManager({this.plugins = const []});
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}
diff --git a/pkg/analysis_server/test/src/analytics/test_all.dart b/pkg/analysis_server/test/src/analytics/test_all.dart
index b969877..d372a69 100644
--- a/pkg/analysis_server/test/src/analytics/test_all.dart
+++ b/pkg/analysis_server/test/src/analytics/test_all.dart
@@ -4,10 +4,12 @@
 
 import 'package:test_reflective_loader/test_reflective_loader.dart';
 
+import 'google_analytics_manager_test.dart' as google_analytics_manager;
 import 'percentile_calculator_test.dart' as percentile_calculator;
 
 void main() {
   defineReflectiveSuite(() {
+    google_analytics_manager.main();
     percentile_calculator.main();
   });
 }