diff --git a/src/io/flutter/actions/FlutterSdkAction.java b/src/io/flutter/actions/FlutterSdkAction.java index 4707b790c1..5babc1bc55 100644 --- a/src/io/flutter/actions/FlutterSdkAction.java +++ b/src/io/flutter/actions/FlutterSdkAction.java @@ -13,6 +13,9 @@ import io.flutter.FlutterBundle; import io.flutter.FlutterMessages; import io.flutter.FlutterUtils; +import io.flutter.analytics.Analytics; +import io.flutter.analytics.AnalyticsConstants; +import io.flutter.analytics.AnalyticsData; import io.flutter.bazel.Workspace; import io.flutter.pub.PubRoot; import io.flutter.pub.PubRoots; @@ -32,12 +35,16 @@ public abstract class FlutterSdkAction extends DumbAwareAction { public void actionPerformed(@NotNull AnActionEvent event) { final Project project = DumbAwareAction.getEventProject(event); + AnalyticsData analyticsData = AnalyticsData.forAction(this, event); + if (enableActionInBazelContext()) { // See if the Bazel workspace exists for this project. final Workspace workspace = FlutterModuleUtils.getFlutterBazelWorkspace(project); if (workspace != null) { FileDocumentManager.getInstance().saveAllDocuments(); startCommandInBazelContext(project, workspace, event); + analyticsData.add(AnalyticsConstants.IN_BAZEL_CONTEXT, true); + Analytics.report(analyticsData); return; } } @@ -45,6 +52,8 @@ public void actionPerformed(@NotNull AnActionEvent event) { final FlutterSdk sdk = project != null ? FlutterSdk.getFlutterSdk(project) : null; if (sdk == null) { showMissingSdkDialog(project); + analyticsData.add(AnalyticsConstants.MISSING_SDK, true); + Analytics.report(analyticsData); return; } @@ -60,6 +69,8 @@ public void actionPerformed(@NotNull AnActionEvent event) { startCommand(project, sdk, sub, context); } } + + Analytics.report(analyticsData); } public abstract void startCommand(@NotNull Project project, diff --git a/src/io/flutter/actions/ReloadFlutterApp.java b/src/io/flutter/actions/ReloadFlutterApp.java index 6e2ed80376..c3b3f47ff0 100644 --- a/src/io/flutter/actions/ReloadFlutterApp.java +++ b/src/io/flutter/actions/ReloadFlutterApp.java @@ -12,6 +12,9 @@ import icons.FlutterIcons; import io.flutter.FlutterBundle; import io.flutter.FlutterConstants; +import io.flutter.analytics.Analytics; +import io.flutter.analytics.AnalyticsConstants; +import io.flutter.analytics.AnalyticsData; import io.flutter.run.FlutterReloadManager; import io.flutter.run.daemon.FlutterApp; import org.jetbrains.annotations.NotNull; @@ -42,9 +45,12 @@ public void actionPerformed(@NotNull AnActionEvent e) { return; } + var analyticsData = AnalyticsData.forAction(this, e); + // If the shift key is held down, perform a restart. We check to see if we're being invoked from the // 'GoToAction' dialog. If so, the modifiers are for the command that opened the go-to action dialog. final boolean shouldRestart = (e.getModifiers() & InputEvent.SHIFT_MASK) != 0 && !"GoToAction".equals(e.getPlace()); + analyticsData.add(AnalyticsConstants.REQUIRES_RESTART, shouldRestart); var reloadManager = FlutterReloadManager.getInstance(project); if (reloadManager == null) return; @@ -56,6 +62,8 @@ public void actionPerformed(@NotNull AnActionEvent e) { // Else perform a hot reload. reloadManager.saveAllAndReload(getApp(), FlutterConstants.RELOAD_REASON_MANUAL); } + + Analytics.report(analyticsData); } // Override to disable the hot reload action when running flutter web apps. diff --git a/src/io/flutter/actions/RestartFlutterApp.java b/src/io/flutter/actions/RestartFlutterApp.java index fd68e0e6da..1d258925bf 100644 --- a/src/io/flutter/actions/RestartFlutterApp.java +++ b/src/io/flutter/actions/RestartFlutterApp.java @@ -16,6 +16,9 @@ import io.flutter.FlutterBundle; import io.flutter.FlutterConstants; import io.flutter.FlutterMessages; +import io.flutter.analytics.Analytics; +import io.flutter.analytics.AnalyticsConstants; +import io.flutter.analytics.AnalyticsData; import io.flutter.bazel.WorkspaceCache; import io.flutter.run.FlutterReloadManager; import io.flutter.run.daemon.FlutterApp; @@ -50,6 +53,8 @@ public void actionPerformed(@NotNull AnActionEvent e) { reloadManager.saveAllAndRestart(getApp(), FlutterConstants.RELOAD_REASON_MANUAL); } + var analyticsData = AnalyticsData.forAction(this, e); + if (WorkspaceCache.getInstance(project).isBazel() && FlutterSettings.getInstance().isShowBazelHotRestartWarning() && !FlutterSettings.getInstance().isEnableBazelHotRestart()) { @@ -61,8 +66,12 @@ public void actionPerformed(@NotNull AnActionEvent e) { NotificationType.INFORMATION); Notifications.Bus.notify(notification, project); + analyticsData.add(AnalyticsConstants.GOOGLE3, true); + // We only want to show this notification once. FlutterSettings.getInstance().setShowBazelHotRestartWarning(false); } + + Analytics.report(analyticsData); } } diff --git a/src/io/flutter/analytics/Analytics.kt b/src/io/flutter/analytics/Analytics.kt new file mode 100644 index 0000000000..cb0103fdc4 --- /dev/null +++ b/src/io/flutter/analytics/Analytics.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2025 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. + */ + +package io.flutter.analytics + +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import io.flutter.actions.FlutterAppAction + +object Analytics { + private val reporter = NoOpReporter + + @JvmStatic + fun report(data: AnalyticsData) = data.reportTo(reporter) +} + +abstract class AnalyticsReporter { + internal abstract fun process(data: AnalyticsData) +} + +internal object PrintingReporter : AnalyticsReporter() { + override fun process(data: AnalyticsData) = println(data.data) +} + +internal object NoOpReporter : AnalyticsReporter() { + override fun process(data: AnalyticsData) = Unit +} + +abstract class AnalyticsData(type: String) { + val data = mutableMapOf() + + init { + add(AnalyticsConstants.TYPE, type) + } + + companion object { + @JvmStatic + fun forAction(action: AnAction, event: AnActionEvent): ActionData = ActionData( + event.actionManager.getId(action) + // `FlutterAppAction`s aren't registered so ask them directly. + ?: (action as? FlutterAppAction)?.id, + event.place + ) + } + + fun add(key: DataValue, value: T) = key.addTo(this, value) + + internal operator fun set(key: String, value: Boolean) { + data[key] = value + } + + internal operator fun set(key: String, value: Int) { + data[key] = value + } + + internal operator fun set(key: String, value: String) { + data[key] = value + } + + open fun reportTo(reporter: AnalyticsReporter) = reporter.process(this) +} + +/** + * Data describing an IntelliJ [com.intellij.openapi.actionSystem.AnAction] for analytics reporting. + * + * @param id The unique identifier of the action, typically defined in `plugin.xml`. + * @param place The UI location where the action was invoked (e.g., "MainMenu", "Toolbar"). + * @see IntelliJ Action System + */ +class ActionData(private val id: String?, private val place: String) : AnalyticsData("action") { + + init { + id?.let { add(AnalyticsConstants.ID, it) } + add(AnalyticsConstants.PLACE, place) + } + + override fun reportTo(reporter: AnalyticsReporter) { + // We only report if we have an id for the event. + if (id == null) return + super.reportTo(reporter) + } +} + +/** + * Defines standard keys for analytics data properties. + * + * The properties are exposed as `@JvmField`s to be easily accessible as static + * fields from Java. + */ +object AnalyticsConstants { + /** + * Indicates if the project is a Google3 project. + */ + @JvmField + val GOOGLE3 = BooleanValue("google3") + + /** + * The unique identifier for an action or event. + */ + @JvmField + val ID = StringValue("id") + + /** + * Indicates if the project is in a Bazel context. + */ + @JvmField + val IN_BAZEL_CONTEXT = BooleanValue("inBazelContext") + + /** + * Indicates if the Flutter SDK is missing. + */ + @JvmField + val MISSING_SDK = BooleanValue("missingSdk") + + /** + * The UI location where an action was invoked, as provided by + * [com.intellij.openapi.actionSystem.PlaceProvider.getPlace] (for example, "MainMenu", + * "MainToolbar", "EditorPopup", "GoToAction", etc). + */ + @JvmField + val PLACE = StringValue("place") + + /** + * Indicates if a restart is required for a hot reload request. + */ + @JvmField + val REQUIRES_RESTART = BooleanValue("requiresRestart") + + /** + * The type of the analytics event (e.g., "action", ...). + */ + @JvmField + val TYPE = StringValue("type") +} + + +sealed class DataValue(val name: String) { + abstract fun addTo(data: AnalyticsData, value: T); +} + +class StringValue(name: String) : DataValue(name) { + override fun addTo(data: AnalyticsData, value: String) { + data[name] = value + } +} + +class IntValue(name: String) : DataValue(name) { + override fun addTo(data: AnalyticsData, value: Int) { + data[name] = value + } +} + +class BooleanValue(name: String) : DataValue(name) { + override fun addTo(data: AnalyticsData, value: Boolean) { + data[name] = value + } +}