[analytics] propagate ENV to deeplink sub-tool calls (#9836)
* [analytics] propagate ENV to deeplink sub-tool calls
* de-dup
* fail safe
* step back ua dep
* rollback
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 da0a4c6..52bb571 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 @@
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<void> 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 24a86c4..9bf1351 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: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 @@
// [_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 = <String, String>{
+ ...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 2f10d99..b395832 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 @@
/// 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 = <String, String>{
+ '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 @@
Future<ProcessResult> runProcess(
String executable, {
required List<String> arguments,
+ String? ide,
+ bool suppressAnalytics = false,
}) {
+ final environment = <String, String>{
+ ...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 @@
Future<String> _runFlutterCommand(
List<String> 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 @@
Future<Map<String, Object?>> getAndroidBuildVariants({
required String rootPath,
+ String? ide,
+ bool suppressAnalytics = false,
}) {
return _runFlutterCommand(
<String>['analyze', '--android', '--list-build-variants', rootPath],
outputMatcher: _androidBuildVariantJsonRegex,
+ ide: ide,
+ suppressAnalytics: suppressAnalytics,
).then<Map<String, Object?>>(
_handleJsonOutput,
onError: _handleRunFlutterError,
@@ -139,6 +190,8 @@
Future<Map<String, Object?>> getAndroidAppLinkSettings({
required String rootPath,
required String buildVariant,
+ String? ide,
+ bool suppressAnalytics = false,
}) {
return _runFlutterCommand(
<String>[
@@ -149,6 +202,8 @@
rootPath,
],
outputMatcher: _outputFilePathRegex,
+ ide: ide,
+ suppressAnalytics: suppressAnalytics,
).then<Map<String, Object?>>(
_handleReadJsonFile,
onError: _handleRunFlutterError,
@@ -157,10 +212,14 @@
Future<Map<String, Object?>> getIosBuildOptions({
required String rootPath,
+ String? ide,
+ bool suppressAnalytics = false,
}) {
return _runFlutterCommand(
<String>['analyze', '--ios', '--list-build-options', rootPath],
outputMatcher: _iosBuildOptionsJsonRegex,
+ ide: ide,
+ suppressAnalytics: suppressAnalytics,
).then<Map<String, Object?>>(
_handleJsonOutput,
onError: _handleRunFlutterError,
@@ -171,6 +230,8 @@
required String rootPath,
required String configuration,
required String target,
+ String? ide,
+ bool suppressAnalytics = false,
}) {
return _runFlutterCommand(
<String>[
@@ -182,6 +243,8 @@
rootPath,
],
outputMatcher: _outputFilePathRegex,
+ ide: ide,
+ suppressAnalytics: suppressAnalytics,
).then<Map<String, Object?>>(
_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 b5c8641..42d05ec 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 @@
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 @@
final result = await deeplinkManager.getAndroidAppLinkSettings(
rootPath: rootPath,
buildVariant: buildVariant,
+ ide: queryParams.ide,
+ suppressAnalytics: queryParams.suppressAnalytics,
);
return _resultOutputOrError(api, result);
}
@@ -65,7 +70,11 @@
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 @@
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 on Map<String, String> {
+ 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 ce77240..e96d45b 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 @@
);
});
+ test('getBuildVariants propagates parent IDE and analytics opt-out status',
+ () async {
+ const projectRoot = '/abc';
+ manager.expectedCommands.add(
+ TestCommand(
+ executable: manager.mockedFlutterBinary,
+ arguments: <String>[
+ '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 @@
Future<ProcessResult> runProcess(
String executable, {
required List<String> 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<String>()
- .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 @@
const TestCommand({
required this.executable,
required this.arguments,
+ this.ide,
+ this.suppressAnalytics = false,
required this.result,
});
final String executable;
final List<String> 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 dd79a9c..7c8c7fb 100644
--- a/packages/devtools_shared/test/fakes.dart
+++ b/packages/devtools_shared/test/fakes.dart
@@ -11,23 +11,27 @@
String? receivedBuildVariant;
String? receivedConfiguration;
String? receivedTarget;
- late Map<String, String> responseForGetAndroidBuildVariants;
- late Map<String, String> responseForGetAndroidAppLinkSettings;
- late Map<String, String> responseForGetIosBuildOptions;
- late Map<String, String> responseForGetIosUniversalLinkSettings;
+ late Map<String, Object?> responseForGetAndroidBuildVariants;
+ late Map<String, Object?> responseForGetAndroidAppLinkSettings;
+ late Map<String, Object?> responseForGetIosBuildOptions;
+ late Map<String, Object?> responseForGetIosUniversalLinkSettings;
@override
- Future<Map<String, String>> getAndroidBuildVariants({
+ Future<Map<String, Object?>> getAndroidBuildVariants({
required String rootPath,
+ String? ide,
+ bool suppressAnalytics = false,
}) async {
receivedPath = rootPath;
return responseForGetAndroidBuildVariants;
}
@override
- Future<Map<String, String>> getAndroidAppLinkSettings({
+ Future<Map<String, Object?>> getAndroidAppLinkSettings({
required String rootPath,
required String buildVariant,
+ String? ide,
+ bool suppressAnalytics = false,
}) async {
receivedPath = rootPath;
receivedBuildVariant = buildVariant;
@@ -35,18 +39,22 @@
}
@override
- Future<Map<String, String>> getIosBuildOptions({
+ Future<Map<String, Object?>> getIosBuildOptions({
required String rootPath,
+ String? ide,
+ bool suppressAnalytics = false,
}) async {
receivedPath = rootPath;
return responseForGetIosBuildOptions;
}
@override
- Future<Map<String, String>> getIosUniversalLinkSettings({
+ Future<Map<String, Object?>> getIosUniversalLinkSettings({
required String rootPath,
required String configuration,
required String target,
+ String? ide,
+ bool suppressAnalytics = false,
}) async {
receivedPath = rootPath;
receivedConfiguration = configuration;