| // 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 'dart:convert'; |
| |
| import 'package:analysis_server/lsp_protocol/protocol.dart'; |
| import 'package:analysis_server/protocol/protocol_constants.dart'; |
| import 'package:analysis_server/src/analytics/active_request_data.dart'; |
| import 'package:analysis_server/src/analytics/notification_data.dart'; |
| import 'package:analysis_server/src/analytics/plugin_data.dart'; |
| import 'package:analysis_server/src/analytics/request_data.dart'; |
| import 'package:analysis_server/src/analytics/session_data.dart'; |
| import 'package:analysis_server/src/lsp/lsp_analysis_server.dart'; |
| import 'package:analysis_server/src/plugin/plugin_manager.dart'; |
| import 'package:analysis_server/src/protocol_server.dart'; |
| import 'package:analysis_server/src/status/pages.dart'; |
| import 'package:analyzer/dart/analysis/analysis_context.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:telemetry/telemetry.dart'; |
| |
| /// An interface for managing and reporting analytics. |
| /// |
| /// Individual methods can either send an analytics event immediately or can |
| /// collect and even consolidate information to be reported later. Clients are |
| /// required to invoke the [shutdown] method before the server shuts down in |
| /// order to send any cached data. |
| class AnalyticsManager { |
| /// The object used to send analytics. |
| final Analytics analytics; |
| |
| /// Data about the current session, or `null` if the [startUp] method has not |
| /// been invoked. |
| SessionData? _sessionData; |
| |
| final PluginData _pluginData = PluginData(); |
| |
| /// A map from the id of a request to data about the request. |
| final Map<String, ActiveRequestData> _activeRequests = {}; |
| |
| /// A map from the name of a request to data about all such requests that have |
| /// been responded to. |
| final Map<String, RequestData> _completedRequests = {}; |
| |
| /// A map from the name of a notification to data about all such notifications |
| /// that have been handled. |
| final Map<String, NotificationData> _completedNotifications = {}; |
| |
| /// A map from the name of a lint to the number of contexts in which the lint |
| /// was enabled. |
| final Map<String, int> _lintUsageCounts = {}; |
| |
| /// A map from the name of a diagnostic to a map whose values are the number |
| /// of times that the severity of the diagnostic was changed to the severity |
| /// represented by the key. |
| final Map<String, Map<String, int>> _severityAdjustments = {}; |
| |
| /// Initialize a newly created analytics manager to report to the [analytics] |
| /// service. |
| AnalyticsManager(this.analytics); |
| |
| /// Record that the set of plugins known to the [pluginManager] has changed. |
| void changedPlugins(PluginManager pluginManager) { |
| _pluginData.recordPlugins(pluginManager); |
| } |
| |
| /// Record the number of [added] folders and [removed] folders. |
| void changedWorkspaceFolders( |
| {required List<String> added, required List<String> removed}) { |
| var requestData = |
| _getRequestData(Method.workspace_didChangeWorkspaceFolders.toString()); |
| requestData.addValue('added', added.length); |
| requestData.addValue('removed', removed.length); |
| } |
| |
| /// Record that the [contexts] have been created. |
| void createdAnalysisContexts(List<AnalysisContext> contexts) { |
| for (var context in contexts) { |
| for (var rule in context.analysisOptions.lintRules) { |
| var name = rule.name; |
| _lintUsageCounts[name] = (_lintUsageCounts[name] ?? 0) + 1; |
| } |
| for (var processor in context.analysisOptions.errorProcessors) { |
| var severity = processor.severity?.name ?? 'ignore'; |
| var severityCounts = |
| _severityAdjustments.putIfAbsent(processor.code, () => {}); |
| severityCounts[severity] = (severityCounts[severity] ?? 0) + 1; |
| } |
| } |
| } |
| |
| /// Record that the given [notification] was received and has been handled. |
| void handledNotificationMessage( |
| {required NotificationMessage notification, |
| required DateTime startTime, |
| required DateTime endTime}) { |
| var method = notification.method.toString(); |
| var requestTime = notification.clientRequestTime; |
| var start = startTime.millisecondsSinceEpoch; |
| var end = endTime.millisecondsSinceEpoch; |
| var data = _completedNotifications.putIfAbsent( |
| method, () => NotificationData(method)); |
| if (requestTime != null) { |
| data.latencyTimes.addValue(start - requestTime); |
| } |
| data.handlingTimes.addValue(end - start); |
| } |
| |
| /// Record the parameters passed on initialize. |
| void initialize(InitializeParams params) { |
| var options = LspInitializationOptions(params.initializationOptions); |
| var paramNames = <String>[ |
| if (options.closingLabels) 'closingLabels', |
| if (options.flutterOutline) 'flutterOutline', |
| if (options.onlyAnalyzeProjectsWithOpenFiles) |
| 'onlyAnalyzeProjectsWithOpenFiles', |
| if (options.outline) 'outline', |
| if (options.suggestFromUnimportedLibraries) |
| 'suggestFromUnimportedLibraries', |
| ]; |
| _sessionData?.initializeParams = paramNames.join(','); |
| } |
| |
| /// Record the number of [openWorkspacePaths]. |
| void initialized({required List<String> openWorkspacePaths}) { |
| var requestData = _getRequestData(Method.initialized.toString()); |
| requestData.addValue('openWorkspacePaths', openWorkspacePaths.length); |
| } |
| |
| /// Record that the given [response] was sent to the client. |
| void sentResponse({required Response response}) { |
| var sendTime = DateTime.now(); |
| _recordResponseData(response.id, sendTime); |
| } |
| |
| /// Record that the given [response] was sent to the client. |
| void sentResponseMessage({required ResponseMessage response}) { |
| var sendTime = DateTime.now(); |
| var id = response.id?.asString; |
| if (id == null) { |
| return; |
| } |
| _recordResponseData(id, sendTime); |
| } |
| |
| /// The server is shutting down. Report any accumulated analytics data. |
| void shutdown() { |
| final sessionData = _sessionData; |
| if (sessionData == null) { |
| return; |
| } |
| _sendSessionData(sessionData); |
| _sendServerResponseTimes(); |
| _sendPluginResponseTimes(); |
| _sendNotificationHandlingTimes(); |
| _sendLintUsageCounts(); |
| _sendSeverityAdjustments(); |
| |
| analytics.waitForLastPing(timeout: Duration(milliseconds: 200)).then((_) { |
| analytics.close(); |
| }); |
| } |
| |
| /// Record data from the given [params]. |
| void startedGetRefactoring(EditGetRefactoringParams params) { |
| var requestData = _getRequestData(EDIT_REQUEST_GET_REFACTORING); |
| requestData.addEnumValue( |
| EDIT_REQUEST_GET_REFACTORING_KIND, params.kind.name); |
| } |
| |
| /// Record that the server started working on the give [request] at the given |
| /// [startTime]. |
| void startedRequest({required Request request, required DateTime startTime}) { |
| var method = request.method; |
| _activeRequests[request.id] = |
| ActiveRequestData(method, request.clientRequestTime, startTime); |
| } |
| |
| /// Record that the server started working on the give [request] at the given |
| /// [startTime]. |
| void startedRequestMessage( |
| {required RequestMessage request, required DateTime startTime}) { |
| _activeRequests[request.id.asString] = ActiveRequestData( |
| request.method.toString(), request.clientRequestTime, startTime); |
| } |
| |
| /// Record data from the given [params]. |
| void startedSetAnalysisRoots(AnalysisSetAnalysisRootsParams params) { |
| var requestData = _getRequestData(ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS); |
| requestData.addValue( |
| ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS_INCLUDED, params.included.length); |
| requestData.addValue( |
| ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS_EXCLUDED, params.excluded.length); |
| } |
| |
| /// Record data from the given [params]. |
| void startedSetPriorityFiles(AnalysisSetPriorityFilesParams params) { |
| var requestData = _getRequestData(ANALYSIS_REQUEST_SET_PRIORITY_FILES); |
| requestData.addValue( |
| ANALYSIS_REQUEST_SET_PRIORITY_FILES_FILES, params.files.length); |
| } |
| |
| /// Record that the server was started at the given [time], that it was passed |
| /// the given command-line [arguments], that it was started by the client with |
| /// the given [clientId] and [clientVersion], and that it was invoked from an |
| /// SDK with the given [sdkVersion]. |
| void startUp( |
| {required DateTime time, |
| required List<String> arguments, |
| required String clientId, |
| required String? clientVersion, |
| required String sdkVersion}) { |
| _sessionData = SessionData( |
| startTime: time, |
| commandLineArguments: arguments.join(','), |
| clientId: clientId, |
| clientVersion: clientVersion ?? '', |
| sdkVersion: sdkVersion); |
| } |
| |
| /// Return an HTML representation of the data that has been recorded. |
| String? toHtml(StringBuffer buffer) { |
| var sessionData = _sessionData; |
| if (sessionData == null) { |
| return null; |
| } |
| |
| void h3(String title) { |
| buffer.writeln('<h3>${escape(title)}</h3>'); |
| } |
| |
| void h4(String title) { |
| buffer.writeln('<h4>${escape(title)}</h4>'); |
| } |
| |
| void h5(String title) { |
| buffer.writeln('<h5>${escape(title)}</h5>'); |
| } |
| |
| void li(String item) { |
| buffer.writeln('<li>${escape(item)}</li>'); |
| } |
| |
| List<MapEntry<String, V>> sorted<V>( |
| Iterable<MapEntry<String, V>> entries) => |
| entries.sortedBy((entry) => entry.key); |
| |
| buffer.writeln('<hr>'); |
| |
| var endTime = DateTime.now().millisecondsSinceEpoch; |
| var duration = endTime - sessionData.startTime.millisecondsSinceEpoch; |
| h3('Session data'); |
| buffer.writeln('<ul>'); |
| li('clientId: ${sessionData.clientId}'); |
| li('clientVersion: ${sessionData.clientVersion}'); |
| li('duration: ${duration.toString()}'); |
| li('flags: ${sessionData.commandLineArguments}'); |
| li('parameters: ${sessionData.initializeParams}'); |
| li('sdkVersion: ${sessionData.sdkVersion}'); |
| li('plugins: ${_pluginData.usageCountData}'); |
| buffer.writeln('</ul>'); |
| |
| if (_completedRequests.isNotEmpty) { |
| h3('Server response times'); |
| var entries = sorted(_completedRequests.entries); |
| for (var entry in entries) { |
| var data = entry.value; |
| h4(data.method); |
| buffer.writeln('<ul>'); |
| li('latency: ${data.latencyTimes.toAnalyticsString()}'); |
| li('duration: ${data.responseTimes.toAnalyticsString()}'); |
| for (var field in data.additionalPercentiles.entries) { |
| li('${field.key}: ${field.value.toAnalyticsString()}'); |
| } |
| for (var field in data.additionalEnumCounts.entries) { |
| li('${field.key}: ${json.encode(field.value)}'); |
| } |
| buffer.writeln('</ul>'); |
| } |
| } |
| |
| var responseTimes = PluginManager.pluginResponseTimes; |
| if (responseTimes.isNotEmpty) { |
| h3('Plugin response times'); |
| for (var pluginEntry in responseTimes.entries) { |
| h4(pluginEntry.key.pluginId); |
| var entries = sorted(pluginEntry.value.entries); |
| for (var responseEntry in entries) { |
| h5(responseEntry.key); |
| buffer.writeln('<ul>'); |
| li('duration: ${responseEntry.value.toAnalyticsString()}'); |
| buffer.writeln('</ul>'); |
| } |
| } |
| } |
| |
| if (_completedNotifications.isNotEmpty) { |
| h3('Notification handling times'); |
| buffer.writeln('<ul>'); |
| var entries = sorted(_completedNotifications.entries); |
| for (var entry in entries) { |
| var data = entry.value; |
| li('latency: ${data.latencyTimes.toAnalyticsString()}'); |
| li('method: ${data.method}'); |
| li('duration: ${data.handlingTimes.toAnalyticsString()}'); |
| } |
| buffer.writeln('</ul>'); |
| } |
| |
| if (_lintUsageCounts.isNotEmpty) { |
| h3('Lint usage counts'); |
| buffer.writeln('<ul>'); |
| li('usageCounts: ${json.encode(_lintUsageCounts)}'); |
| buffer.writeln('</ul>'); |
| } |
| |
| if (_severityAdjustments.isNotEmpty) { |
| h3('Severity adjustments'); |
| buffer.writeln('<ul>'); |
| li('adjustmentCounts: ${json.encode(_severityAdjustments)}'); |
| buffer.writeln('</ul>'); |
| } |
| |
| return buffer.toString(); |
| } |
| |
| /// Return the request data for requests that have the given [method]. |
| RequestData _getRequestData(String method) { |
| return _completedRequests.putIfAbsent(method, () => RequestData(method)); |
| } |
| |
| /// Record that the request with the given [id] was responded to at the given |
| /// [sendTime]. |
| void _recordResponseData(String id, DateTime sendTime) { |
| var data = _activeRequests.remove(id); |
| if (data == null) { |
| return; |
| } |
| |
| var method = data.method; |
| var clientRequestTime = data.clientRequestTime; |
| var startTime = data.startTime.millisecondsSinceEpoch; |
| |
| var requestData = _getRequestData(method); |
| |
| if (clientRequestTime != null) { |
| var latencyTime = startTime - clientRequestTime; |
| requestData.latencyTimes.addValue(latencyTime); |
| } |
| |
| var responseTime = sendTime.millisecondsSinceEpoch - startTime; |
| requestData.responseTimes.addValue(responseTime); |
| } |
| |
| void _sendLintUsageCounts() { |
| if (_lintUsageCounts.isNotEmpty) { |
| analytics.sendEvent('language_server', 'lintUsageCounts', parameters: { |
| 'usageCounts': json.encode(_lintUsageCounts), |
| }); |
| } |
| } |
| |
| /// Send information about the notifications handled by the server. |
| void _sendNotificationHandlingTimes() { |
| for (var data in _completedNotifications.values) { |
| analytics.sendEvent('language_server', 'notification', parameters: { |
| 'latency': data.latencyTimes.toAnalyticsString(), |
| 'method': data.method, |
| 'duration': data.handlingTimes.toAnalyticsString(), |
| }); |
| } |
| } |
| |
| /// Send information about the response times of plugins. |
| void _sendPluginResponseTimes() { |
| var responseTimes = PluginManager.pluginResponseTimes; |
| for (var pluginEntry in responseTimes.entries) { |
| for (var responseEntry in pluginEntry.value.entries) { |
| analytics.sendEvent('language_server', 'pluginRequest', parameters: { |
| 'pluginId': pluginEntry.key.pluginId, |
| 'method': responseEntry.key, |
| 'duration': responseEntry.value.toAnalyticsString(), |
| }); |
| } |
| } |
| } |
| |
| /// Send information about the response times of server. |
| void _sendServerResponseTimes() { |
| for (var data in _completedRequests.values) { |
| analytics.sendEvent('language_server', 'request', parameters: { |
| 'latency': data.latencyTimes.toAnalyticsString(), |
| 'method': data.method, |
| 'duration': data.responseTimes.toAnalyticsString(), |
| for (var field in data.additionalPercentiles.entries) |
| field.key: field.value.toAnalyticsString(), |
| for (var field in data.additionalEnumCounts.entries) |
| field.key: json.encode(field.value), |
| }); |
| } |
| } |
| |
| /// Send information about the session. |
| void _sendSessionData(SessionData sessionData) { |
| var endTime = DateTime.now().millisecondsSinceEpoch; |
| var duration = endTime - sessionData.startTime.millisecondsSinceEpoch; |
| analytics.sendEvent('language_server', 'session', parameters: { |
| 'flags': sessionData.commandLineArguments, |
| 'parameters': sessionData.initializeParams, |
| 'clientId': sessionData.clientId, |
| 'clientVersion': sessionData.clientVersion, |
| 'sdkVersion': sessionData.sdkVersion, |
| 'duration': duration.toString(), |
| 'plugins': _pluginData.usageCountData, |
| }); |
| } |
| |
| void _sendSeverityAdjustments() { |
| if (_severityAdjustments.isNotEmpty) { |
| analytics |
| .sendEvent('language_server', 'severityAdjustments', parameters: { |
| 'adjustmentCounts': json.encode(_severityAdjustments), |
| }); |
| } |
| } |
| } |
| |
| extension on Either2<int, String> { |
| String get asString { |
| return map((value) => value.toString(), (value) => value); |
| } |
| } |