[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;