[fus] IJPL-251495 New CLI install usage collector

(cherry picked from commit c13e3c73397748f1449a3e7a0774f7d2c470ab5b)
IJ-CR-218650
(cherry picked from commit e1fda984eebc3e73f9e09187fd59316b47e356d5)

GitOrigin-RevId: 48c19ea6cd123134b4744b35a8097650ef75b42d
This commit is contained in:
Yuriy Artamonov
2026-08-20 18:41:59 +00:00
committed by intellij-monorepo-bot
parent d06e28fa8f
commit c92ee9ab0e
6 changed files with 283 additions and 51 deletions
@@ -7,7 +7,7 @@ import com.intellij.internal.statistic.eventLog.events.EventFields
import com.intellij.internal.statistic.eventLog.events.EventId1
import com.intellij.internal.statistic.service.fus.collectors.ApplicationUsagesCollector
import com.intellij.openapi.progress.checkCanceled
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.EnvironmentScanner
import com.intellij.openapi.util.EnvironmentScanner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -5,15 +5,7 @@ import com.intellij.ide.IdeBundle
import com.intellij.ide.plugins.DependencyCollector
import com.intellij.ide.plugins.DependencyInformation
import com.intellij.openapi.project.Project
import com.intellij.util.EnvironmentUtil
import com.intellij.util.system.OS
import org.jetbrains.annotations.ApiStatus
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.isExecutable
import kotlin.io.path.isRegularFile
import com.intellij.openapi.util.EnvironmentScanner
internal class EnvironmentDependencyCollector : DependencyCollector {
private val ALLOWED_EXECUTABLES: List<String> = listOf(
@@ -35,43 +27,4 @@ internal class EnvironmentDependencyCollector : DependencyCollector {
.filter { EnvironmentScanner.hasToolInLocalPath(pathNames, it) }
.map { DependencyInformation(it, IdeBundle.message("plugins.configurable.suggested.features.executable", it)) }
}
}
@ApiStatus.Internal
object EnvironmentScanner {
fun getPathNames(): List<Path> {
val fs = FileSystems.getDefault()
@Suppress("IO_FILE_USAGE")
val pathDelimiter = java.io.File.pathSeparatorChar
val pathNames = EnvironmentUtil.getEnvironmentMap()["PATH"]?.split(pathDelimiter)
?.mapNotNull {
try {
fs.getPath(it)
}
catch (_: InvalidPathException) {
null
}
}
?.filter(Files::exists)
return pathNames ?: emptyList()
}
fun hasToolInLocalPath(pathNames: List<Path>, executableWithoutExt: String): Boolean {
val baseNames = if (OS.CURRENT == OS.Windows) {
sequenceOf(".bat", ".cmd", ".com", ".exe")
.map { exeSuffix -> executableWithoutExt + exeSuffix }
}
else {
sequenceOf(executableWithoutExt)
}
return pathNames.asSequence()
.flatMap { pathEntry ->
baseNames.map { basename -> pathEntry.resolve(basename) }
}
.filter(Path::isRegularFile)
.any(Path::isExecutable)
}
}
}
@@ -0,0 +1,53 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.util
import com.intellij.util.EnvironmentUtil
import com.intellij.util.system.LowLevelLocalMachineAccess
import com.intellij.util.system.OS
import org.jetbrains.annotations.ApiStatus
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
import kotlin.io.path.isExecutable
import kotlin.io.path.isRegularFile
@ApiStatus.Internal
object EnvironmentScanner {
fun getPathNames(): List<Path> {
val fs = FileSystems.getDefault()
@Suppress("IO_FILE_USAGE")
val pathDelimiter = java.io.File.pathSeparatorChar
val pathNames = EnvironmentUtil.getEnvironmentMap()["PATH"]?.split(pathDelimiter)
?.mapNotNull {
try {
fs.getPath(it)
}
catch (_: InvalidPathException) {
null
}
}
?.filter(Files::exists)
return pathNames ?: emptyList()
}
@OptIn(LowLevelLocalMachineAccess::class)
fun hasToolInLocalPath(pathNames: List<Path>, executableWithoutExt: String): Boolean {
val baseNames = if (OS.CURRENT == OS.Windows) {
sequenceOf(".bat", ".cmd", ".com", ".exe")
.map { exeSuffix -> executableWithoutExt + exeSuffix }
}
else {
sequenceOf(executableWithoutExt)
}
return pathNames.asSequence()
.flatMap { pathEntry ->
baseNames.map { basename -> pathEntry.resolve(basename) }
}
.filter(Path::isRegularFile)
.any(Path::isExecutable)
}
}
@@ -1,7 +1,7 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.ide.plugins.advertiser
import com.intellij.openapi.updateSettings.impl.pluginsAdvertisement.EnvironmentScanner
import com.intellij.openapi.util.EnvironmentScanner
import com.intellij.util.EnvironmentUtil
import com.intellij.util.system.LowLevelLocalMachineAccess
import com.intellij.util.system.OS
@@ -10,6 +10,7 @@
<applicationService
serviceInterface="org.jetbrains.plugins.terminal.hyperlinks.menu.BackendHyperlinkInfoService"
serviceImplementation="com.intellij.terminal.backend.hyperlinks.BackendHyperlinkInfoServiceImpl"/>
<statistics.applicationUsagesCollector implementation="com.intellij.terminal.backend.CliUtilInstallCollector"/>
</extensions>
<extensions defaultExtensionNs="org.jetbrains.plugins.terminal">
@@ -0,0 +1,225 @@
package com.intellij.terminal.backend
import com.intellij.internal.statistic.beans.MetricEvent
import com.intellij.internal.statistic.eventLog.EventLogGroup
import com.intellij.internal.statistic.eventLog.events.EventFields
import com.intellij.internal.statistic.eventLog.events.EventId1
import com.intellij.internal.statistic.service.fus.collectors.ApplicationUsagesCollector
import com.intellij.openapi.util.EnvironmentScanner
import com.intellij.util.SystemProperties
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.InvalidPathException
import java.nio.file.Path
internal class CliUtilInstallCollector : ApplicationUsagesCollector() {
private val GROUP: EventLogGroup = EventLogGroup("cli.tools", 1)
@Suppress("SpellCheckingInspection")
private val CLI_EXECUTABLES: List<String> = listOf(
"aliyun",
"amplify",
"ansible",
"argocd",
"aws",
"az",
"bpftrace",
"buildah",
"bun",
"cargo",
"cdk",
"cfn",
"chaos-mesh",
"chef",
"cilium",
"circleci",
"code",
"codium",
"colima",
"conftest",
"consul",
"crane",
"crictl",
"ctr",
"datadog-agent",
"devcontainer-cli",
"devspace",
"docker",
"doctl",
"doppler",
"drone",
"eb",
"eksctl",
"envoy",
"etcdctl",
"fleet",
"flux",
"fly",
"gcloud",
"gem",
"git",
"gitpod",
"glab",
"gradle",
"grafana",
"hadolint",
"harbor",
"hcloud",
"helm",
"helmfile",
"hubble",
"ibmcloud",
"istioctl",
"jenkins-cli",
"jf",
"jq",
"k3d",
"k3s",
"k9s",
"kibana",
"kind",
"kubeconform",
"kubectl",
"kubectx",
"kubefwd",
"kubens",
"kubeseal",
"kubetail",
"kustomize",
"lima",
"localstack",
"lxc",
"lxd",
"maven",
"meshery",
"microk8s",
"minikube",
"mirrord",
"ncp",
"nerdctl",
"nerdctl", //Docker-compatible CLI for containerd.
"newrelic",
"ngrok",
"nix",
"nomad",
"npm",
"openstack",
"otel",
"pack", //CLI tool maintained by the CNB project to support the use of buildpacks.
"packer",
"pip",
"pnpm",
"podman",
"poetry",
"pre-commit",
"promtool",
"pulumi",
"puppet",
"quarkus",
"rancher",
"rke",
"sam",
"sdkman",
"semgrep",
"serverless",
"sf",
"shellcheck",
"skaffold",
"sops",
"splunk",
"st2",
"stern",
"tanzu",
"telepresence",
"terraform",
"terraform-docs",
"terragrunt",
"terramate",
"tflint",
"tfsec",
"tfswitch",
"tilt",
"tofu",
"travis",
"trivy",
"uv",
"vagrant",
"vault",
"velero",
"werf",
"yamllint",
"yarn",
"yc",
"yq",
"claude",
"codex",
"cursor",
"junie",
"opencode",
"copilot",
)
private val TOOL_DISCOVERED: EventId1<String> = GROUP.registerEvent(
"tool.discovered",
EventFields.String("tool", CLI_EXECUTABLES)
)
private val KUBECONFIG_ID: String = "kubeconfig"
private val CLAUDE_ID: String = ".claude"
private val CODEX_ID: String = ".codex"
private val COPILOT_ID: String = ".copilot"
private val CURSOR_ID: String = ".cursor"
private val CONFIG_EXISTS: EventId1<String> = GROUP.registerEvent(
"config.exists",
EventFields.String("config", listOf(KUBECONFIG_ID, CLAUDE_ID))
)
override fun getGroup(): EventLogGroup = GROUP
override fun getMetrics(): Set<MetricEvent> {
val pathNames = EnvironmentScanner.getPathNames()
val metrics = CLI_EXECUTABLES
.filter { EnvironmentScanner.hasToolInLocalPath(pathNames, it) }
.map { TOOL_DISCOVERED.metric(it) }
.toMutableList()
if (userPathExists(Path.of(".kube", "config"))) {
metrics.add(CONFIG_EXISTS.metric(KUBECONFIG_ID))
}
if (userPathExists(Path.of(".claude"))) {
metrics.add(CONFIG_EXISTS.metric(CLAUDE_ID))
}
if (userPathExists(Path.of(".codex"))) {
metrics.add(CONFIG_EXISTS.metric(CODEX_ID))
}
if (userPathExists(Path.of(".copilot"))) {
metrics.add(CONFIG_EXISTS.metric(COPILOT_ID))
}
if (userPathExists(Path.of(".cursor"))) {
metrics.add(CONFIG_EXISTS.metric(CURSOR_ID))
}
return metrics.toSet()
}
private fun userPathExists(subPath: Path): Boolean {
val fs = FileSystems.getDefault()
val configPath = try {
fs.getPath(SystemProperties.getUserHome()).resolve(subPath)
}
catch (_: InvalidPathException) {
null
}
return configPath != null && try {
Files.exists(configPath)
}
catch (_: Exception) {
false
}
}
}