diff --git a/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart b/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart index da0a4c615bf..52bb5710d0a 100644 --- a/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart +++ b/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart @@ -34,6 +34,15 @@ Future get analyticsController async { AnalyticsController? _analyticsController; +/// A synchronous check to see if analytics are enabled. +/// +/// Returns `false` if analytics are disabled or not yet initialized. +bool get isAnalyticsEnabled => + _analyticsController?.analyticsEnabled.value ?? false; + +/// Whether the analytics controller has been initialized. +bool get isAnalyticsControllerInitialized => _analyticsController != null; + typedef AsyncAnalyticsCallback = FutureOr Function(); class AnalyticsController { diff --git a/packages/devtools_app/lib/src/shared/server/server.dart b/packages/devtools_app/lib/src/shared/server/server.dart index 24a86c4f0cc..9bf135157d6 100644 --- a/packages/devtools_app/lib/src/shared/server/server.dart +++ b/packages/devtools_app/lib/src/shared/server/server.dart @@ -13,8 +13,10 @@ import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; +import '../analytics/analytics_controller.dart'; import '../development_helpers.dart'; import '../globals.dart'; +import '../primitives/query_parameters.dart'; import '../primitives/storage.dart'; import '../primitives/utils.dart'; @@ -73,7 +75,21 @@ Uri buildDevToolsServerRequestUri(String url) { // [_debugDevToolsServerFlag] environment variable declaration was not set // using `--dart-define`. const baseUri = _debugDevToolsServerEnvironmentVariable; - return Uri.parse(path.join(baseUri, url)); + final uri = Uri.parse(path.join(baseUri, url)); + + final queryParams = DevToolsQueryParams.load(); + // Forward the parent IDE name and the client-side analytics opt-out status + // to the server, so they can be propagated to any spawned subprocesses. + // Fail-safe: default to suppressing analytics if the controller is not yet + // initialized. + final newParams = { + ...uri.queryParameters, + if (queryParams.ide != null) 'ide': queryParams.ide!, + if (!isAnalyticsControllerInitialized || !isAnalyticsEnabled) + 'suppress_analytics': 'true', + }; + + return uri.replace(queryParameters: newParams); } /// Helper to catch any server request which could fail. diff --git a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart index 2f10d997857..b3958329ace 100644 --- a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart +++ b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart @@ -32,6 +32,28 @@ class DeeplinkManager { /// APIs. static const kOutputJsonField = 'json'; + // TODO(https://github.com/flutter/devtools/issues/9702): Use the `DashTool` + // and `DashEnvVar` enums and `getEnvironment()` helper directly from + // `package:unified_analytics` once the pinned Flutter candidate SDK in this + // repository is bumped to a stable Dart SDK version >= 3.10.0 (resolving the + // dev SDK version solving conflict on CI). + /// Mappings from case-insensitive IDE query parameter values to their + /// corresponding DashTool canonical label strings used by `package:unified_analytics`. + /// + /// Contains multiple spelling and format variations (with/without hyphens + /// or suffixes) passed by different IDE integrations to ensure O(1) lookup. + static const _ideToDashToolMap = { + 'vs-code': 'vscode-plugins', + 'vscode': 'vscode-plugins', + 'vscodeplugins': 'vscode-plugins', + 'intellij-idea': 'intellij-plugins', + 'intellij': 'intellij-plugins', + 'intellijplugins': 'intellij-plugins', + 'android-studio': 'android-studio-plugins', + 'androidstudio': 'android-studio-plugins', + 'androidstudioplugins': 'android-studio-plugins', + }; + /// A regex to retrieve the file path from the stdout of iOS or Android /// analyzers. /// @@ -44,13 +66,31 @@ class DeeplinkManager { Future runProcess( String executable, { required List arguments, + String? ide, + bool suppressAnalytics = false, }) { + final environment = { + ...Platform.environment, + 'DASH__SUPPRESS_ANALYTICS': suppressAnalytics.toString(), + 'DASH__TOOL': ide != null ? _mapIdeToDashToolLabel(ide) : 'devtools', + }; + return Process.run( executable, arguments, + environment: environment, ); } + String _mapIdeToDashToolLabel(String ide) { + final lowerIde = ide.toLowerCase(); + final mappedTool = _ideToDashToolMap[lowerIde]; + if (mappedTool != null) { + return mappedTool; + } + return 'devtools'; + } + @visibleForTesting String getFlutterBinary() { // FLUTTER_ROOT can be set by Dart-Code VSCode extension or dart shell @@ -81,9 +121,16 @@ class DeeplinkManager { Future _runFlutterCommand( List arguments, { required RegExp outputMatcher, + String? ide, + bool suppressAnalytics = false, }) async { final flutterPath = getFlutterBinary(); - final result = await runProcess(flutterPath, arguments: arguments); + final result = await runProcess( + flutterPath, + arguments: arguments, + ide: ide, + suppressAnalytics: suppressAnalytics, + ); if (result.exitCode != 0) { throw _FlutterProcessError( 'Flutter command exit with non-zero error code ${result.exitCode}\n${result.stderr}', @@ -126,10 +173,14 @@ class DeeplinkManager { Future> getAndroidBuildVariants({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) { return _runFlutterCommand( ['analyze', '--android', '--list-build-variants', rootPath], outputMatcher: _androidBuildVariantJsonRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleJsonOutput, onError: _handleRunFlutterError, @@ -139,6 +190,8 @@ class DeeplinkManager { Future> getAndroidAppLinkSettings({ required String rootPath, required String buildVariant, + String? ide, + bool suppressAnalytics = false, }) { return _runFlutterCommand( [ @@ -149,6 +202,8 @@ class DeeplinkManager { rootPath, ], outputMatcher: _outputFilePathRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleReadJsonFile, onError: _handleRunFlutterError, @@ -157,10 +212,14 @@ class DeeplinkManager { Future> getIosBuildOptions({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) { return _runFlutterCommand( ['analyze', '--ios', '--list-build-options', rootPath], outputMatcher: _iosBuildOptionsJsonRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleJsonOutput, onError: _handleRunFlutterError, @@ -171,6 +230,8 @@ class DeeplinkManager { required String rootPath, required String configuration, required String target, + String? ide, + bool suppressAnalytics = false, }) { return _runFlutterCommand( [ @@ -182,6 +243,8 @@ class DeeplinkManager { rootPath, ], outputMatcher: _outputFilePathRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleReadJsonFile, onError: _handleRunFlutterError, diff --git a/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart b/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart index b5c86417bfe..42d05ecc9e4 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart @@ -20,8 +20,11 @@ extension _DeeplinkApiHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final rootPath = queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!; - final result = - await deeplinkManager.getAndroidBuildVariants(rootPath: rootPath); + final result = await deeplinkManager.getAndroidBuildVariants( + rootPath: rootPath, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, + ); return _resultOutputOrError(api, result); } @@ -47,6 +50,8 @@ extension _DeeplinkApiHandler on Never { final result = await deeplinkManager.getAndroidAppLinkSettings( rootPath: rootPath, buildVariant: buildVariant, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, ); return _resultOutputOrError(api, result); } @@ -65,7 +70,11 @@ extension _DeeplinkApiHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final rootPath = queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!; - final result = await deeplinkManager.getIosBuildOptions(rootPath: rootPath); + final result = await deeplinkManager.getIosBuildOptions( + rootPath: rootPath, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, + ); return _resultOutputOrError(api, result); } @@ -90,6 +99,8 @@ extension _DeeplinkApiHandler on Never { rootPath: queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!, configuration: queryParams[DeeplinkApi.xcodeConfigurationPropertyName]!, target: queryParams[DeeplinkApi.xcodeTargetPropertyName]!, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, ); return _resultOutputOrError(api, result); } @@ -107,3 +118,8 @@ extension _DeeplinkApiHandler on Never { ); } } + +extension on Map { + String? get ide => this['ide']; + bool get suppressAnalytics => this['suppress_analytics'] == 'true'; +} diff --git a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart index ce77240debb..e96d45b9500 100644 --- a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart +++ b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart @@ -60,6 +60,43 @@ Running Gradle task 'printBuildVariants'... 10.4s ); }); + test('getBuildVariants propagates parent IDE and analytics opt-out status', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--list-build-variants', + projectRoot, + ], + ide: 'VS-Code', + suppressAnalytics: true, + result: ProcessResult( + 0, + 0, + r''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug"] + ''', + '', + ), + ), + ); + final response = await manager.getAndroidBuildVariants( + rootPath: projectRoot, + ide: 'VS-Code', + suppressAnalytics: true, + ); + expect(response[DeeplinkManager.kErrorField], isNull); + expect( + response[DeeplinkManager.kOutputJsonField], + '["debug"]', + ); + }); + test( 'getBuildVariants return internal server error if command failed', () async { @@ -217,15 +254,19 @@ class StubbedDeeplinkManager extends DeeplinkManager { Future runProcess( String executable, { required List arguments, + String? ide, + bool suppressAnalytics = false, }) async { if (expectedCommands.isNotEmpty) { final expectedCommand = expectedCommands.removeAt(0); - expect(expectedCommand.executable, executable); + expect(executable, expectedCommand.executable); expect( const ListEquality() - .equals(expectedCommand.arguments, arguments), + .equals(arguments, expectedCommand.arguments), isTrue, ); + expect(ide, expectedCommand.ide); + expect(suppressAnalytics, expectedCommand.suppressAnalytics); return expectedCommand.result; } throw 'Received unexpected command: $executable ${arguments.join(' ')}'; @@ -236,10 +277,14 @@ class TestCommand { const TestCommand({ required this.executable, required this.arguments, + this.ide, + this.suppressAnalytics = false, required this.result, }); final String executable; final List arguments; + final String? ide; + final bool suppressAnalytics; final ProcessResult result; @override diff --git a/packages/devtools_shared/test/fakes.dart b/packages/devtools_shared/test/fakes.dart index dd79a9c0358..7c8c7fbf853 100644 --- a/packages/devtools_shared/test/fakes.dart +++ b/packages/devtools_shared/test/fakes.dart @@ -11,23 +11,27 @@ class FakeDeeplinkManager extends DeeplinkManager { String? receivedBuildVariant; String? receivedConfiguration; String? receivedTarget; - late Map responseForGetAndroidBuildVariants; - late Map responseForGetAndroidAppLinkSettings; - late Map responseForGetIosBuildOptions; - late Map responseForGetIosUniversalLinkSettings; + late Map responseForGetAndroidBuildVariants; + late Map responseForGetAndroidAppLinkSettings; + late Map responseForGetIosBuildOptions; + late Map responseForGetIosUniversalLinkSettings; @override - Future> getAndroidBuildVariants({ + Future> getAndroidBuildVariants({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; return responseForGetAndroidBuildVariants; } @override - Future> getAndroidAppLinkSettings({ + Future> getAndroidAppLinkSettings({ required String rootPath, required String buildVariant, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; receivedBuildVariant = buildVariant; @@ -35,18 +39,22 @@ class FakeDeeplinkManager extends DeeplinkManager { } @override - Future> getIosBuildOptions({ + Future> getIosBuildOptions({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; return responseForGetIosBuildOptions; } @override - Future> getIosUniversalLinkSettings({ + Future> getIosUniversalLinkSettings({ required String rootPath, required String configuration, required String target, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; receivedConfiguration = configuration;