diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index e96a2572711..ea07fc45eee 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -20,6 +20,8 @@ When looking for IntelliJ Platform API usage, implementation examples, extension points, services, actions, inspections, PSI/VFS/editor behavior, or plugin patterns, prefer real IntelliJ source code over Gradle caches, downloaded jars, generated parser artifacts, or decompiled classes. +Do not use IntelliJ Platform APIs marked as internal in the IntelliJ source repository. Find a public API alternative or keep the integration behind supported extension points. Experimental APIs are acceptable when needed, but warn the user that the integration relies on an experimental IntelliJ API. + Use this priority order: 1. Check whether `$INTELLIJ_REPO` is set and points to a readable IntelliJ Community checkout. @@ -226,6 +228,7 @@ Rules: ### Primary UI Rules - Use IntelliJ platform components instead of raw Swing where an equivalent exists (see [Platform Components](#platform-components-and-utilities) table below). +- When implementing a new user-facing action, consider adding metrics so usage can be tracked. - Do not set default Swing properties explicitly. Avoid `isOpaque = false` unless the component default differs or there is a documented rendering reason. - Avoid hardcoded dimensions, colors, and font sizes — use the platform style APIs described in [Theme-Derived Colors](#theme-derived-colors), [Theme-Derived Fonts](#theme-derived-fonts), and [Borders, Insets, and Spacing](#borders-insets-and-spacing). - Put user-visible strings in `*.properties` files. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 5a0c14126ea..240d562cdc9 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -4,6 +4,7 @@ import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendCliManager import ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService import ai.kilocode.backend.migration.LegacyMigrationDetection +import ai.kilocode.backend.telemetry.KiloBackendTelemetry import ai.kilocode.log.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi @@ -21,6 +22,7 @@ import ai.kilocode.rpc.dto.DeviceAuthDto import ai.kilocode.rpc.dto.HealthDto import com.intellij.openapi.Disposable import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -119,7 +121,6 @@ class KiloBackendAppService private constructor( val chat = KiloBackendChatManager(cs, log) val models = KiloBackendModelStateManager(log) val workspaces = KiloBackendWorkspaceManager(cs, sessions, log) - @Volatile var profile: KiloProfile200Response? = null private set @@ -265,12 +266,14 @@ class KiloBackendAppService private constructor( loader?.cancel() eventWatcher?.cancel() loader = cs.launch { + val start = System.currentTimeMillis() log.info("Application starting — loading config, profile, notifications") val progress = AtomicReference(LoadProgress()) _appState.value = KiloAppState.Loading(progress.get()) val migration = detectMigration() if (migration != null) { + captureLoad("Backend Migration Required", start, mapOf("migrationRequired" to "true")) stopRuntime() profile = null config = null @@ -343,6 +346,13 @@ class KiloBackendAppService private constructor( sessions.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) chat.start(connection.apiClient!!, connection.port, connection.events) workspaces.start(connection.api!!, connection.apiClient!!, connection.port, connection.events) + setTelemetry(true) + captureBackend("Backend Connected", mapOf("portKnown" to "true")) + captureLoad("Backend Load Completed", start, mapOf( + "profileStatus" to if (prof != null) "loaded" else "not_logged_in", + "warningCount" to warns.size.toString(), + "migrationRequired" to "false", + )) setAppReady( AppData( profile = prof, @@ -359,6 +369,11 @@ class KiloBackendAppService private constructor( detail = "Timed out loading app data after ${loadTimeoutMs}ms", ) log.warn("Application start timed out after ${loadTimeoutMs}ms") + captureLoad("Backend Load Failed", start, mapOf( + "errorCount" to (errors.size + 1).toString(), + "resources" to (errors.map { it.resource } + err.resource).distinct().joinToString(","), + "reason" to "timeout", + )) setAppError( message = "Failed to load required data", errors = errors.toList() + err, @@ -367,6 +382,11 @@ class KiloBackendAppService private constructor( throw e } catch (e: Exception) { log.warn("Application start failed: ${e.message}") + captureLoad("Backend Load Failed", start, mapOf( + "errorCount" to errors.size.toString(), + "resources" to errors.map { it.resource }.distinct().joinToString(","), + "reason" to e::class.java.name, + )) setAppError( message = "Failed to load required data", errors = errors.toList(), @@ -376,6 +396,41 @@ class KiloBackendAppService private constructor( } } + private fun captureLoad(event: String, start: Long, props: Map) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().capture( + http, + port, + event, + props + mapOf("durationMs" to (System.currentTimeMillis() - start).toString()), + ) + }.onFailure { log.info("Skipping backend load telemetry: ${it.message}") } + } + } + + private fun setTelemetry(enabled: Boolean) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().setEnabled(http, port, enabled) + }.onFailure { log.info("Skipping telemetry setEnabled: ${it.message}") } + } + } + + private fun captureBackend(event: String, props: Map) { + val http = connection.apiClient + val port = connection.port + cs.launch { + runCatching { + service().capture(http, port, event, props) + }.onFailure { log.info("Skipping backend telemetry: ${it.message}") } + } + } + private suspend fun detectMigration(): LegacyMigrationDetection? = withContext(Dispatchers.IO) { val http = connection.apiClient ?: run { log.info("Migration check: skipped because CLI HTTP client is not connected") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index ffeb58fed7d..564d342590e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -1,10 +1,10 @@ package ai.kilocode.backend.cli +import ai.kilocode.KiloPlugin +import ai.kilocode.backend.dev.KiloDevMode import ai.kilocode.log.KiloLog -import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager -import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.util.SystemInfo import com.intellij.util.system.CpuArch import kotlinx.coroutines.Dispatchers @@ -130,6 +130,7 @@ class KiloBackendCliManager( put("KILO_ENABLE_QUESTION_TOOL", "true") put("KILO_PLATFORM", "jetbrains") put("KILO_APP_NAME", "kilo-code") + put("KILO_TELEMETRY_LEVEL", if (KiloDevMode.enabled()) "off" else "all") put("KILO_DISABLE_CLAUDE_CODE", "true") put("KILOCODE_FEATURE", "jetbrains-plugin") putIfAbsent("KILO_CONFIG_CONTENT", DEFAULT_CONFIG) @@ -178,7 +179,12 @@ class KiloBackendCliManager( log.info("Starting CLI: ${cmd.joinToString(" ")}") log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code") - val proc = builder.start() + val proc = try { + builder.start() + } catch (e: Exception) { + log.warn("CLI process failed to start: ${e.message}", e) + throw e + } log.info("CLI process started (pid=${proc.pid()})") process = proc install(proc) @@ -212,6 +218,7 @@ class KiloBackendCliManager( val details = synchronized(stderr) { stderr.toString().trim() } process = null uninstall() + log.warn("CLI process exited with code $code before announcing a port: $details") CliServer.State.Error( message = "CLI process exited with code $code before announcing a port", details = details.ifEmpty { null }, @@ -288,8 +295,7 @@ class KiloBackendCliManager( }.onFailure { log.info("Could not read ApplicationInfo: ${it.message}") } runCatching { - val version = PluginManagerCore - .getPlugin(PluginId.getId("ai.kilocode"))?.version + val version = KiloPlugin.version() if (version != null) put("KILO_APP_VERSION", version) }.onFailure { log.info("Could not read plugin version: ${it.message}") } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt new file mode 100644 index 00000000000..1118cb96dd8 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/dev/KiloDevMode.kt @@ -0,0 +1,7 @@ +package ai.kilocode.backend.dev + +import ai.kilocode.log.KiloLog + +object KiloDevMode { + fun enabled(): Boolean = KiloLog.sandbox() +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt index d2971645924..668db20b290 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloAppRpcApiImpl.kt @@ -4,6 +4,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendAppService +import ai.kilocode.backend.telemetry.KiloBackendTelemetry import ai.kilocode.backend.app.ConfigWarning import ai.kilocode.backend.app.LoadError import ai.kilocode.backend.app.LoadProgress @@ -30,6 +31,7 @@ import ai.kilocode.rpc.dto.ProfileBalanceDto import ai.kilocode.rpc.dto.ProfileDto import ai.kilocode.rpc.dto.ProfileOrganizationDto import ai.kilocode.rpc.dto.ProfileStatusDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.openapi.components.service import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -94,6 +96,10 @@ class KiloAppRpcApiImpl : KiloAppRpcApi { override suspend fun setOrganization(organizationId: String?): ProfileDto? = app.setOrganization(organizationId)?.let(::profileDto) + override suspend fun captureTelemetry(capture: TelemetryCaptureDto) { + service().capture(app.http, app.port, capture.event, capture.properties) + } + private fun dto(state: KiloAppState): KiloAppStateDto = appStateDto(state) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt new file mode 100644 index 00000000000..9f0ee195da1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetry.kt @@ -0,0 +1,74 @@ +package ai.kilocode.backend.telemetry + +import ai.kilocode.backend.dev.KiloDevMode +import ai.kilocode.log.KiloLog +import com.intellij.openapi.components.Service +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +@Service(Service.Level.APP) +class KiloBackendTelemetry( + private val log: KiloLog = KiloLog.create(KiloBackendTelemetry::class.java), +) { + companion object { + private const val TIMEOUT_MS = 5_000L + } + + suspend fun capture(http: OkHttpClient?, port: Int, event: String, properties: Map) { + val body = payload(event, properties) + if (KiloDevMode.enabled()) { + log.info(body) + return + } + if (http == null || port <= 0) return + post(http, port, "telemetry/capture", body) + } + + suspend fun setEnabled(http: OkHttpClient?, port: Int, enabled: Boolean) { + val body = JsonObject(mapOf("enabled" to JsonPrimitive(enabled))).toString() + if (KiloDevMode.enabled()) { + log.info(body) + return + } + if (http == null || port <= 0) return + post(http, port, "telemetry/setEnabled", body) + } + + private suspend fun post(http: OkHttpClient, port: Int, path: String, body: String) { + withContext(Dispatchers.IO) { + try { + val client = http.newBuilder() + .callTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() + val req = Request.Builder() + .url("http://127.0.0.1:$port/$path") + .header("Accept", "application/json") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + client.newCall(req).execute().use { res -> + if (!res.isSuccessful) log.warn("telemetry $path failed: HTTP ${res.code}") + } + } catch (e: Exception) { + log.warn("telemetry $path failed: ${e.message}", e) + } + } + } + + private fun payload(event: String, properties: Map): String = JsonObject( + mapOf( + "event" to JsonPrimitive(event), + "properties" to JsonObject(base() + properties.mapValues { JsonPrimitive(it.value) }), + ), + ).toString() + + private fun base(): Map = + KiloLog.payload(log).mapValues { JsonPrimitive(it.value) } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt index 77517bfea4f..1a6ed2b5701 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloBackendCliManagerEnvTest.kt @@ -19,12 +19,14 @@ class KiloBackendCliManagerEnvTest { tmp = Files.createTempDirectory("kilo-cli-env-test").toFile() System.clearProperty("kilo.dev.storage.isolated") System.clearProperty("kilo.dev.worktree.root") + System.clearProperty("idea.plugin.in.sandbox.mode") } @AfterTest fun tearDown() { System.clearProperty("kilo.dev.storage.isolated") System.clearProperty("kilo.dev.worktree.root") + System.clearProperty("idea.plugin.in.sandbox.mode") tmp.deleteRecursively() } @@ -36,11 +38,21 @@ class KiloBackendCliManagerEnvTest { assertEquals("true", env["KILO_ENABLE_QUESTION_TOOL"]) assertEquals("jetbrains", env["KILO_PLATFORM"]) assertEquals("kilo-code", env["KILO_APP_NAME"]) + assertEquals("all", env["KILO_TELEMETRY_LEVEL"]) assertEquals("true", env["KILO_DISABLE_CLAUDE_CODE"]) assertEquals("jetbrains-plugin", env["KILOCODE_FEATURE"]) assertEquals("pwd123", env["KILO_SERVER_PASSWORD"]) } + @Test + fun `dev mode disables CLI telemetry`() { + System.setProperty("idea.plugin.in.sandbox.mode", "true") + + val env = manager.buildEnv("pwd123", emptyMap()) + + assertEquals("off", env["KILO_TELEMETRY_LEVEL"]) + } + @Test fun `isolation disabled - default CLI config asks for edit and bash permissions`() { val env = manager.buildEnv("pwd123", emptyMap()) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt new file mode 100644 index 00000000000..1a9a5be5a07 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/telemetry/KiloBackendTelemetryTest.kt @@ -0,0 +1,76 @@ +package ai.kilocode.backend.telemetry + +import ai.kilocode.backend.cli.KiloBackendHttpClients +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import java.util.concurrent.TimeUnit + +class KiloBackendTelemetryTest { + @Test + fun `capture posts to telemetry endpoint with auth`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setBody("{}")) + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().capture(http, server.port, "Test Event", mapOf("source" to "test")) + + val req = server.takeRequest() + assertEquals("/telemetry/capture", req.path) + assertTrue(req.getHeader("Authorization")?.startsWith("Basic ") == true) + val body = req.body.readUtf8() + assertTrue(body.contains("\"event\":\"Test Event\"")) + assertTrue(!body.contains("JetBrains")) + assertTrue(body.contains("\"platform\":\"jetbrains\"")) + assertTrue(!body.contains("appName")) + assertTrue(body.contains("source")) + } finally { + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } + + @Test + fun `set enabled posts to telemetry endpoint`() = runBlocking { + val server = MockWebServer() + server.enqueue(MockResponse().setBody("{}")) + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().setEnabled(http, server.port, true) + + val req = server.takeRequest() + assertEquals("/telemetry/setEnabled", req.path) + assertTrue(req.body.readUtf8().contains("enabled")) + } finally { + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } + + @Test + fun `capture failure does not throw`() = runBlocking { + KiloBackendTelemetry().capture(null, 0, "Test Event", emptyMap()) + } + + @Test + fun `dev mode does not post capture`() = runBlocking { + System.setProperty("idea.plugin.in.sandbox.mode", "true") + val server = MockWebServer() + server.start() + val http = KiloBackendHttpClients.api("secret") + try { + KiloBackendTelemetry().capture(http, server.port, "Test Event", emptyMap()) + + assertEquals(null, server.takeRequest(100, TimeUnit.MILLISECONDS)) + } finally { + System.clearProperty("idea.plugin.in.sandbox.mode") + KiloBackendHttpClients.shutdown(http) + server.shutdown() + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index d449a5a1da4..b3e083f0fb0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -3,6 +3,7 @@ package ai.kilocode.client import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.SessionSidePanelManager +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.log.KiloLog import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service @@ -32,6 +33,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { } override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val start = System.currentTimeMillis() try { val workspaces = service() val cs = CoroutineScope(SupervisorJob()) @@ -43,8 +45,13 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { withContext(Dispatchers.Main) { setup(project, toolWindow, workspace) } + Telemetry.send("Tool Window Opened", mapOf( + "projectResolved" to dir.isNotBlank().toString(), + "durationMs" to (System.currentTimeMillis() - start).toString(), + )) } } catch (e: Exception) { + Telemetry.send("Tool Window Setup Failed", mapOf("stage" to "create", "errorClass" to e::class.java.name)) LOG.error("Failed to create Kilo tool window content", e) } } @@ -71,6 +78,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware { ) toolWindow.setTitleActions(actions) } catch (e: Exception) { + Telemetry.send("Tool Window Setup Failed", mapOf("stage" to "setup", "errorClass" to e::class.java.name)) LOG.error("Failed to set up Kilo tool window content", e) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt index 3db258f8cce..0cee2523d55 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt @@ -41,7 +41,11 @@ class DeleteSessionAction : AnAction() { else KiloBundle.message("history.delete.confirm.message.multiple", items.size) - if (!confirm(e.project, msg)) return + controller.requestDelete(items.size) + if (!confirm(e.project, msg)) { + controller.cancelDelete(items.size) + return + } items.forEach { controller.delete(it) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt index 92d662e7ae5..9a82e36a1ed 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -13,6 +14,7 @@ class HistoryAction : AnAction( AllIcons.Vcs.History, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("History Opened", mapOf("surface" to "tool_window")) e.getData(SessionManager.KEY)?.showHistory() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt index b7f6ba5d12f..d3f12fb4089 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/KiloSettingsAction.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.actions +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.ActionGroup import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnAction @@ -22,6 +23,7 @@ class KiloSettingsAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { val component = e.inputEvent?.component ?: return val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return + Telemetry.send("Settings Opened", mapOf("surface" to "tool_window")) JBPopupFactory.getInstance() .createActionGroupPopup( diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt index dbe8aefcd6c..385f45e45a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/NewSessionAction.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionManager +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent @@ -13,6 +14,7 @@ class NewSessionAction : AnAction( AllIcons.General.Add, ), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("New Session Clicked", mapOf("surface" to "tool_window")) e.getData(SessionManager.KEY)?.newSession() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt index c5bd9574cd6..e6d982347ac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ReinstallKiloAction.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -8,6 +9,7 @@ import com.intellij.openapi.project.DumbAware class ReinstallKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("CLI Reinstall Clicked", mapOf("surface" to "settings")) service().reinstallAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt index aa40c15fb41..dd2a617b219 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt @@ -39,9 +39,22 @@ class RenameSessionAction : AnAction() { val item = selection.selectedLocal.singleOrNull() ?: return val current = title(item) - val newTitle = input(e.project, current)?.trim() ?: return + controller.requestRename() + val raw = input(e.project, current) + if (raw == null) { + controller.cancelRename("cancelled") + return + } + val newTitle = raw.trim() - if (newTitle.isBlank() || newTitle == current) return + if (newTitle.isBlank()) { + controller.cancelRename("blank") + return + } + if (newTitle == current) { + controller.cancelRename("unchanged") + return + } controller.rename(item, newTitle) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt index 1df776223e4..93d356c78d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RestartKiloAction.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service @@ -8,6 +9,7 @@ import com.intellij.openapi.project.DumbAware class RestartKiloAction : AnAction(), DumbAware { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("CLI Restart Clicked", mapOf("surface" to "settings")) service().restartAsync() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt index ebf954c9fa1..d7c82f3cb16 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/ShowProfileAction.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.telemetry.Telemetry import com.intellij.icons.AllIcons import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent @@ -24,6 +25,7 @@ class ShowProfileAction : DumbAwareAction( ) { override fun actionPerformed(e: AnActionEvent) { + Telemetry.send("Profile Settings Opened", mapOf("surface" to "tool_window")) ShowSettingsUtil.getInstance().showSettingsDialog( e.project, Predicate { cfg: Configurable -> diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt index 82ef9cb1fc4..0ebe5e89320 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/migration/KiloMigrationService.kt @@ -4,6 +4,7 @@ package ai.kilocode.client.migration import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.autocomplete.KiloAutocompleteSettingsService +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloMigrationRpcApi import ai.kilocode.rpc.dto.KiloAppStateDto @@ -105,6 +106,7 @@ class KiloMigrationService internal constructor( return } LOG.info("Migration wizard: user started migration ${selectionSummary(selections)}") + telemetry("Migration Started", selectionProps(selections)) lastSelections.set(selections) val dto = MigrationSelectionBuilder.toDto(selections) @@ -125,6 +127,7 @@ class KiloMigrationService internal constructor( val flow = try { call { migrate(dto) } } catch (e: Exception) { + telemetry("Migration Failed", mapOf("itemCount" to initialProgress.size.toString(), "errorCount" to "1", "stage" to "start")) LOG.warn("migration start failed", e) finishWithError(e.message ?: "Migration failed") return@launch @@ -142,6 +145,8 @@ class KiloMigrationService internal constructor( */ override fun skip() { LOG.info("Migration wizard: user chose skip") + val current = _state.value as? MigrationUiState.Needed + if (current != null) telemetry("Migration Skipped", detectionProps(current.detection)) cs.launch { try { call { skip() } @@ -165,6 +170,7 @@ class KiloMigrationService internal constructor( val status = if (hasErrors) LegacyMigrationStatusDto.completed_with_errors else LegacyMigrationStatusDto.completed val selections = lastSelections.get() LOG.info("Migration wizard: user finished migration status=$status results=${current.results.size} errors=${current.results.count { it.status == MigrationItemStatusDto.error }}") + telemetry("Migration Finished", mapOf("status" to status.name, "cleanupRequested" to (selections?.keepLegacySettingsFile == false).toString())) cs.launch { try { call { finalize(status) } @@ -225,6 +231,11 @@ class KiloMigrationService internal constructor( val phase = if (hasErrors) MigrationUiPhase.error else MigrationUiPhase.done val progress = if (hasErrors) current.progress else finishSilentProgress(current.progress) LOG.info("Migration wizard: migration complete phase=$phase items=${items.size} errors=${items.count { it.status == MigrationItemStatusDto.error }}") + if (hasErrors) { + telemetry("Migration Failed", mapOf("itemCount" to items.size.toString(), "errorCount" to items.count { it.status == MigrationItemStatusDto.error }.toString(), "stage" to "complete")) + } else { + telemetry("Migration Completed", mapOf("itemCount" to items.size.toString(), "sessionImportedCount" to current.sessionSummary.imported.size.toString())) + } _state.value = current.copy( running = false, phase = phase, @@ -234,6 +245,7 @@ class KiloMigrationService internal constructor( } is LegacyMigrationEventDto.Error -> { LOG.warn("Migration wizard: migration error message=${event.message}") + telemetry("Migration Failed", mapOf("itemCount" to current.progress.size.toString(), "errorCount" to "1", "stage" to "event")) finishWithError(event.message) } } @@ -245,6 +257,7 @@ class KiloMigrationService internal constructor( val current = _state.value if (current is MigrationUiState.Needed && current.detection == migration && current.phase != MigrationUiPhase.selecting) return LOG.info("Migration wizard: showing because backend requires migration ${detectionSummary(migration)}") + telemetry("Migration Shown", detectionProps(migration)) _state.value = MigrationUiState.Needed(migration) return } @@ -298,6 +311,28 @@ class KiloMigrationService internal constructor( private fun detectionSummary(detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto): String = "providers=${detection.providers.size} mcp=${detection.mcpServers.size} modes=${detection.customModes.size} sessions=${detection.sessions.size} model=${detection.defaultModel != null} settings=${detection.settings != null}" + private fun detectionProps(detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto): Map = mapOf( + "settings" to (detection.settings != null).toString(), + "providers" to detection.providers.size.toString(), + "mcpServers" to detection.mcpServers.size.toString(), + "customModes" to detection.customModes.size.toString(), + "sessions" to detection.sessions.size.toString(), + "defaultModel" to (detection.defaultModel != null).toString(), + ) + + private fun selectionProps(selections: MigrationUiSelections): Map = mapOf( + "providers" to selections.providers.size.toString(), + "mcpServers" to selections.mcpServers.size.toString(), + "customModes" to selections.customModes.size.toString(), + "sessions" to selections.sessions.size.toString(), + "defaultModel" to selections.defaultModel.toString(), + "keepLegacySettingsFile" to selections.keepLegacySettingsFile.toString(), + ) + + private fun telemetry(event: String, props: Map) { + Telemetry.send(event, props) + } + private fun buildInitialProgress( selections: MigrationUiSelections, detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index 6d0cf70b7f0..2a1a7e1f10d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.history.HistoryController import ai.kilocode.client.session.history.HistoryPanel +import ai.kilocode.client.telemetry.Telemetry import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.DataProvider import com.intellij.openapi.application.ApplicationManager @@ -62,6 +63,8 @@ class SessionSidePanelManager( existing } else create(ref) } + if (current === ui) return + Telemetry.send("Session Opened", mapOf("source" to ref.type.name.lowercase(), "sessionId" to ref.id)) show(ui) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 133af6862f3..c9a0ae28459 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -32,6 +32,7 @@ import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.settings.profile.UserProfileConfigurable +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.layout.Stack import ai.kilocode.log.ChatLogSummary import com.intellij.util.ui.JBUI @@ -285,7 +286,13 @@ class SessionUi( } prompt.setAutoApprove(controller.autoApprove) prompt.model.favorites = { app.favorites.value } - prompt.model.onFavoriteToggle = { item -> app.toggleModelFavorite(item.provider, item.id) } + prompt.model.onFavoriteToggle = { item -> + Telemetry.send( + "Model Favorite Toggled", + mapOf("provider" to item.provider, "modelId" to item.id), + ) + app.toggleModelFavorite(item.provider, item.id) + } controller.addListener(this) { event -> when (event) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 9ca8d56807f..bf1a3513555 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -20,6 +20,7 @@ import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -83,6 +84,7 @@ class SessionController( private val afterUpdate: (Boolean) -> Unit = {}, private val loaded: (Boolean) -> Unit = {}, private val openProfileAction: () -> Unit = {}, + private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, ) : Disposable { private data class OrganizationTarget(val org: String?) @@ -189,8 +191,15 @@ class SessionController( fun prompt(text: String) { assertEdt() val start = sid ?: ref?.key ?: "pending" + val exists = sid != null val dto = promptDto(text) + val props = promptProps() LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } + capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf( + "source" to "user", + "hasExistingSession" to exists.toString(), + "textLength" to bucket(text), + ) + props) showSession() cs.launch { try { @@ -207,12 +216,15 @@ class SessionController( if (disposed) return@launch val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") + capture("Task Created", sessionProps(session.id) + mapOf("source" to "jetbrains")) subscribeEvents() session.id } sessions.prompt(id, directory, dto) + capture("Conversation Message", sessionProps(id) + mapOf("source" to "user", "hasExistingSession" to exists.toString()) + props) LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" } } catch (e: Exception) { + capture("Session Error", sessionProps(sid ?: ref?.key ?: start) + mapOf("context" to "prompt", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: start)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { if (disposed) return@edt @@ -229,11 +241,14 @@ class SessionController( assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=abort" } val id = sid ?: return + capture("Session Stop Clicked", sessionProps(id)) cs.launch { try { sessions.abort(id, directory) + capture("Session Stopped", sessionProps(id)) LOG.debug { "${ChatLogSummary.sid(id)} kind=abort ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "abort", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(id)} kind=abort dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } @@ -242,6 +257,7 @@ class SessionController( fun setAutoApprove(value: Boolean) { assertEdt() KiloPluginSettings.setAutoApprove(value) + capture("Auto Approve Toggled", mapOf("enabled" to value.toString())) if (!value) { drainJob?.cancel() drainJob = null @@ -268,8 +284,10 @@ class SessionController( cs.launch { try { sessions.compact(id, directory, sel) + capture("Context Condensed", sessionProps(id) + mapOf("provider" to sel.providerID, "modelId" to sel.modelID)) LOG.debug { "${ChatLogSummary.sid(id)} kind=compact ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "compact", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(id)} kind=compact dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { updateModel { @@ -285,6 +303,7 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}" } + capture("Connection Retry Clicked", connectionProps()) setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting) setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting) // App retry policy is backend-owned and may escalate from lightweight refresh to restart. @@ -320,6 +339,7 @@ class SessionController( model.agent = name syncModelSelection() } + capture("Mode Switched", sessionProps() + mapOf("agent" to name)) } fun selectModel(provider: String, id: String) { @@ -334,6 +354,7 @@ class SessionController( app.selectModel(agent, provider, id) selectResolvedModel(key) model.modelOverride = model.defaultModel != model.model + capture("Model Selected", sessionProps() + mapOf("agent" to agent, "provider" to provider, "modelId" to id, "isOverride" to "true")) } fun clearModelOverride() { @@ -344,6 +365,7 @@ class SessionController( val auto = configModel(agent) ?: providerModel(agent) selectResolvedModel(auto) model.modelOverride = false + capture("Model Override Cleared", sessionProps() + mapOf("agent" to agent)) } fun selectVariant(value: String) { @@ -353,6 +375,7 @@ class SessionController( LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config variant=$key/$value" } app.selectVariant(key, value) model.variant = value + capture("Reasoning Variant Selected", sessionProps() + mapOf("model" to key, "variant" to value)) } // ------ permission / question resolution ------ @@ -360,13 +383,23 @@ class SessionController( fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { assertEdt() LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } + val current = model.state as? SessionState.AwaitingPermission updatePermission(requestId, PermissionRequestState.RESPONDING) cs.launch { try { if (rules != null) sessions.savePermissionRules(requestId, directory, rules) sessions.replyPermission(requestId, directory, reply) + capture("Approval Answered", sessionProps() + mapOf( + "requestId" to requestId, + "tool" to (current?.permission?.name ?: "unknown"), + "reply" to reply.reply, + "hasRules" to (rules != null).toString(), + "hasDiffs" to (current?.permission?.meta?.fileDiffs?.isNotEmpty() == true).toString(), + "diffCount" to (current?.permission?.meta?.fileDiffs?.size ?: 0).toString(), + )) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" } } catch (e: Exception) { + capture("Session Error", sessionProps() + mapOf("context" to "permission", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { updatePermission( @@ -404,6 +437,7 @@ class SessionController( model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) } sessions.replyPermission(id, directory, PermissionReplyDto("once")) + capture("Permission Auto Approved", sessionProps() + mapOf("tool" to restore().name, "source" to "single")) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) @@ -445,6 +479,7 @@ class SessionController( for (request in permissions) { if (!autoApprove) return count sessions.replyPermission(request.id, directory, PermissionReplyDto("once")) + capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain")) count++ } return count @@ -472,9 +507,15 @@ class SessionController( ) { Followup(directory, System.currentTimeMillis()) } else null + val follow = followup != null cs.launch { try { sessions.replyQuestion(requestId, directory, answers) + capture("Question Answered", sessionProps() + mapOf( + "requestId" to requestId, + "answerCount" to answers.answers.size.toString(), + "hasFollowupNewSession" to follow.toString(), + )) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { edt { followup = null } @@ -490,6 +531,7 @@ class SessionController( cs.launch { try { sessions.rejectQuestion(requestId, directory) + capture("Question Rejected", sessionProps() + mapOf("requestId" to requestId)) LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) @@ -879,12 +921,16 @@ class SessionController( || current is SessionState.Busy || current is SessionState.Retry || current is SessionState.Offline - if (clobberOk) model.setState(SessionState.Idle) + if (clobberOk) { + if (event.reason == "completed") capture("Task Completed", sessionProps(event.sessionID)) + model.setState(SessionState.Idle) + } } is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { + capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) error(event, true) } @@ -922,7 +968,10 @@ class SessionController( idle() } - is ChatEventDto.SessionCompacted -> model.markCompacted() + is ChatEventDto.SessionCompacted -> { + capture("Context Condensed", sessionProps(event.sessionID)) + model.markCompacted() + } is ChatEventDto.SessionDiffChanged -> model.setDiff(event.diff) is ChatEventDto.TodoUpdated -> model.setTodos(event.todos) } @@ -969,6 +1018,10 @@ class SessionController( if (isPaidModelAuthRequired(event.error)) { loginRetry = retryPrompt() if (reveal) showSession() + capture("Account Overlay Shown", sessionProps(event.sessionID) + mapOf( + "surface" to "session", + "reason" to "paid_model_auth", + )) model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } @@ -1279,7 +1332,9 @@ class SessionController( cs.launch { try { app.setOrganization(org) + capture("Organization Switched", mapOf("target" to if (org == null) "personal" else "organization")) } catch (e: Exception) { + capture("Account Connect Failed", mapOf("stage" to "organization", "errorClass" to e::class.java.name)) LOG.warn("account switch failed org=$org message=${e.message}", e) edt { if (disposed) return@edt @@ -1292,13 +1347,67 @@ class SessionController( fun openProfile() { assertEdt() + capture("Profile Settings Opened", mapOf("surface" to "session_overlay")) openProfileAction() } + private fun capture(event: String, props: Map = emptyMap()) { + telemetry(event, props) + } + + private fun sessionProps(id: String? = sid): Map = buildMap { + id?.let { put("sessionId", it) } + if (ApplicationManager.getApplication().isDispatchThread) { + model.agent?.let { put("agent", it) } + model.model?.let { put("model", it) } + } + } + + private fun promptProps(): Map = buildMap { + model.agent?.let { put("agent", it) } + model.model?.let { key -> + put("model", key) + parseModel(key)?.let { sel -> + put("provider", sel.first) + put("modelId", sel.second) + } + } + model.variant?.takeIf { it in model.variants }?.let { put("variant", it) } + } + + private fun bucket(text: String): String = when (text.length) { + 0 -> "empty" + in 1..80 -> "short" + in 81..500 -> "medium" + else -> "long" + } + + private fun connectionProps(): Map = buildMap { + put("appStatus", model.app.status.name) + put("workspaceStatus", model.workspace.status.name) + model.app.error?.let { put("appError", bucketError(it)) } + model.workspace.error?.let { put("workspaceError", bucketError(it)) } + put("warningCount", model.app.warnings.size.toString()) + } + + private fun bucketError(text: String): String = when { + text.isBlank() -> "empty" + text.contains("timed out", ignoreCase = true) -> "timeout" + text.contains("not connected", ignoreCase = true) -> "not_connected" + text.contains("connection", ignoreCase = true) -> "connection" + text.contains("http", ignoreCase = true) -> "http" + else -> "other" + } + fun dismissLoginRequired() { assertEdt() + val active = model.state is SessionState.LoginRequired loginRetry = null - if (model.state is SessionState.LoginRequired) { + if (active) { + capture("Account Overlay Dismissed", sessionProps() + mapOf( + "surface" to "session", + "reason" to "paid_model_auth", + )) updateModel { model.setState(SessionState.Idle) } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt index d790daf3901..dabdf4067d4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.Workspace import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.SessionDto import com.intellij.openapi.application.ApplicationManager @@ -21,6 +22,7 @@ class HistoryController( open: (SessionRef) -> Unit = {}, private val deleted: (String) -> Unit = {}, private val gitUrlProvider: () -> String? = { resolveGitRemoteUrl(workspace.directory) }, + private val telemetry: (String, Map) -> Unit = { event, props -> Telemetry.send(event, props) }, ) { companion object { const val CLOUD_LIMIT = 50 @@ -70,6 +72,7 @@ class HistoryController( val items = result.sessions.map(::localItem) edt { local.replace(items) } } catch (e: Exception) { + capture("History Load Failed", mapOf("source" to "local", "errorClass" to e::class.java.name)) edt { local.fail(e.message ?: KiloBundle.message("history.error.local")) } } } @@ -81,14 +84,35 @@ class HistoryController( fun loadMoreCloud() { if (cloud.cursor == null || cloud.loading) return + capture("History Cloud Load More Clicked") loadCloud(reset = false) } fun applyRepoOnly(value: Boolean) { updateRepoOnly(value) + capture("History Cloud Repo Filter Toggled", mapOf( + "enabled" to value.toString(), + "hasGitUrl" to (gitUrl != null).toString(), + )) edt { reloadCloud() } } + fun requestDelete(count: Int) { + capture("History Session Delete Clicked", mapOf("count" to count.toString())) + } + + fun cancelDelete(count: Int) { + capture("History Session Delete Cancelled", mapOf("count" to count.toString())) + } + + fun requestRename() { + capture("History Session Rename Clicked") + } + + fun cancelRename(reason: String) { + capture("History Session Rename Cancelled", mapOf("reason" to reason)) + } + fun delete(item: LocalHistoryItem) { edt { if (item.id in deleting) return@edt @@ -103,6 +127,7 @@ class HistoryController( edt { deleting.remove(item.id) local.remove(item.id) + capture("History Session Deleted", mapOf("sessionId" to item.id)) deleted(item.id) } } catch (e: Exception) { @@ -120,7 +145,10 @@ class HistoryController( cs.launch { try { val updated = sessions.renameSession(item.id, dir, title) - edt { local.update(LocalHistoryItem(updated)) } + edt { + local.update(LocalHistoryItem(updated)) + capture("History Session Renamed", mapOf("sessionId" to item.id)) + } } catch (e: Exception) { edt { local.fail(e.message ?: KiloBundle.message("history.error.local.rename")) } } @@ -130,10 +158,12 @@ class HistoryController( fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting fun open(item: LocalHistoryItem) { + capture("History Session Opened", mapOf("source" to "local")) edt { opener(SessionRef.Local(item.session)) } } fun open(item: CloudHistoryItem) { + capture("History Session Opened", mapOf("source" to "cloud")) edt { opener(SessionRef.Cloud(item.session)) } } @@ -146,16 +176,27 @@ class HistoryController( try { val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, filter) val items = result.sessions.map(::cloudItem) + capture("History Cloud Page Loaded", mapOf( + "reset" to reset.toString(), + "count" to items.size.toString(), + "hasNextCursor" to (result.nextCursor != null).toString(), + "repoOnly" to (filter != null).toString(), + )) edt { if (reset) cloud.replace(items, result.nextCursor) else cloud.append(items, result.nextCursor) } } catch (e: Exception) { + capture("History Load Failed", mapOf("source" to "cloud", "errorClass" to e::class.java.name)) edt { cloud.fail(e.message ?: KiloBundle.message("history.error.cloud")) } } } } + private fun capture(event: String, props: Map = emptyMap()) { + telemetry(event, props) + } + /** * Resolves [gitUrl] on first cloud load. Subsequent calls return the cached value. * Also enables [repoOnly] by default when a URL is found the first time. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt index 82337f20b08..110a695264c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/settings/profile/ProfileUi.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.settings.profile import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.ProfileDto @@ -53,7 +54,10 @@ internal class ProfileUi( browse = browse, ) private val account = LoggedInProfileUi( - dashboard = { browse(DASHBOARD_URL) }, + dashboard = { + telemetry("Dashboard Opened", mapOf("surface" to "settings")) + browse(DASHBOARD_URL) + }, logout = ::logout, organization = ::organization, refresh = ::refreshProfile, @@ -180,6 +184,7 @@ internal class ProfileUi( private fun start() { val id = ++attempt login = LoginState.Initiating + telemetry("Account Connect Clicked", mapOf("surface" to "settings")) sync() cs.launch { try { @@ -191,6 +196,7 @@ internal class ProfileUi( browse(next.verificationUrl) } val profile = app.completeLogin() + telemetry("Account Connect Success", mapOf("surface" to "settings", "hasOrganizations" to ((profile?.organizations?.isNotEmpty()) == true).toString())) val state = app.state.value withContext(edt) { if (id != attempt) return@withContext @@ -200,6 +206,7 @@ internal class ProfileUi( } catch (e: CancellationException) { throw e } catch (e: Exception) { + telemetry("Account Connect Failed", mapOf("stage" to "complete", "errorClass" to e::class.java.name)) withContext(edt) { if (id != attempt) return@withContext login = LoginState.Error(compactLoginError(e)) @@ -212,14 +219,17 @@ internal class ProfileUi( private fun cancel() { attempt++ login = LoginState.Idle + telemetry("Account Connect Failed", mapOf("stage" to "cancel", "errorClass" to "cancelled")) sync() } private fun logout() { + telemetry("Account Logout Clicked", mapOf("surface" to "settings")) cs.launch { try { val ok = app.logout() if (!ok) return@launch + telemetry("Account Logout Success", mapOf("surface" to "settings")) withContext(edt) { login = LoginState.Idle applyState() @@ -238,6 +248,7 @@ internal class ProfileUi( cs.launch { try { val profile = app.setOrganization(org) + telemetry("Organization Switched", mapOf("target" to if (org == null) "personal" else "organization")) val state = app.state.value withContext(edt) { update(profile ?: state.profile, state.status) @@ -271,6 +282,10 @@ internal class ProfileUi( } } } + + private fun telemetry(event: String, props: Map) { + Telemetry.send(event, props) + } } private val HTML_MARKERS = listOf(" = emptyMap()) { + KiloTelemetryService.getInstance().send(event, properties) + } +} + +@Service(Service.Level.APP) +class KiloTelemetryService internal constructor( + private val cs: CoroutineScope, + private val rpc: KiloAppRpcApi?, +) { + constructor(cs: CoroutineScope) : this(cs, null) + + companion object { + private val LOG = KiloLog.create(KiloTelemetryService::class.java) + private const val MAX_PENDING = 64 + private const val TIMEOUT_MS = 5_000L + + fun getInstance(): KiloTelemetryService = service() + } + + private val pending = AtomicInteger() + private val warned = AtomicBoolean() + + fun send(event: String, properties: Map = emptyMap()) { + if (pending.incrementAndGet() > MAX_PENDING) { + pending.decrementAndGet() + if (warned.compareAndSet(false, true)) { + LOG.warn("telemetry backpressure: dropping events with more than $MAX_PENDING pending") + } + return + } + cs.launch { + try { + if (KiloLog.sandbox()) { + val payload = KiloLog.payload(LOG) + properties + LOG.info("event=$event ${payload.entries.joinToString(" ") { "${it.key}=${it.value}" }}") + return@launch + } + val dto = TelemetryCaptureDto(event, properties) + val sent = withTimeoutOrNull(TIMEOUT_MS) { + val api = rpc + if (api != null) api.captureTelemetry(dto) + else durable { KiloAppRpcApi.getInstance().captureTelemetry(dto) } + true + } + if (sent != true) LOG.warn("telemetry capture timed out: $event") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LOG.warn("telemetry capture failed: ${e.message}", e) + } finally { + pending.decrementAndGet() + } + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index acf202509de..04aeadf6630 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -35,6 +35,15 @@ class PromptLifecycleTest : SessionControllerTestBase() { } } + fun `test prompt records send intent telemetry`() { + prompted() + + val event = appRpc.telemetry.single { it.event == "Conversation Send Clicked" } + assertEquals("user", event.properties["source"]) + assertEquals("false", event.properties["hasExistingSession"]) + assertEquals("short", event.properties["textLength"]) + } + fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 4b0a377c6d7..4149ad5313f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -27,6 +27,7 @@ import ai.kilocode.rpc.dto.ProviderDto import ai.kilocode.rpc.dto.ProvidersDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer @@ -167,6 +168,7 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { open = open, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, + telemetry = { event, props -> appRpc.telemetry.add(TelemetryCaptureDto(event, props)) }, ) controllers.add(m) roots[m] = root diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index 0151333baab..002d7509c65 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -180,6 +180,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { )) assertTrue(m.model.state is SessionState.LoginRequired) + assertTrue(appRpc.telemetry.any { + it.event == "Account Overlay Shown" && it.properties["reason"] == "paid_model_auth" + }) assertSession( """ [code] [kilo/gpt-5] [login-required] [Go to User Profile settings to sign in, then continue this session.] @@ -346,6 +349,9 @@ class TurnLifecycleTest : SessionControllerTestBase() { edt { m.dismissLoginRequired() } flush() + assertTrue(appRpc.telemetry.any { + it.event == "Account Overlay Dismissed" && it.properties["reason"] == "paid_model_auth" + }) assertSession( """ [code] [kilo/gpt-5] [idle] diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt index cc014f97ee7..3ccfb9de9a7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -104,6 +104,21 @@ class HistoryControllerTest : BasePlatformTestCase() { assertEquals(FakeSessionRpcApi.CloudCall("/test", "next_1", 50, null), rpc.cloudCalls[1]) } + fun `test cloud load records page telemetry`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + rpc.cloudCursor = "next_1" + val events = mutableListOf>>() + val controller = telemetryController(events) + + controller.reloadCloud() + flush() + + val event = events.single { it.first == "History Cloud Page Loaded" } + assertEquals("true", event.second["reset"]) + assertEquals("1", event.second["count"]) + assertEquals("true", event.second["hasNextCursor"]) + } + fun `test local delete calls rpc and removes item`() { rpc.listed += session("ses_1", "Local One") val controller = controller() @@ -117,6 +132,18 @@ class HistoryControllerTest : BasePlatformTestCase() { assertTrue(controller.local.items.isEmpty()) } + fun `test opening history items records source telemetry`() { + val events = mutableListOf>>() + val controller = telemetryController(events) + + controller.open(LocalHistoryItem(session("ses_1", "Local One"))) + controller.open(CloudHistoryItem(cloud("cloud_1", "Cloud One"))) + flush() + + val opened = events.filter { it.first == "History Session Opened" } + assertEquals(listOf("local", "cloud"), opened.map { it.second["source"] }) + } + fun `test activity returns typed items`() { rpc.statuses.value = mapOf( "ses_busy" to SessionStatusDto("busy"), @@ -799,6 +826,13 @@ class HistoryControllerTest : BasePlatformTestCase() { private fun controller() = HistoryController(sessions, workspace, scope) + private fun telemetryController(events: MutableList>>) = HistoryController( + sessions, + workspace, + scope, + telemetry = { event, props -> events.add(event to props) }, + ) + private fun controllerWithGit(url: String?) = HistoryController( sessions, workspace, diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index 16536b70276..7507db6943f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -91,6 +91,8 @@ class ConnectionPanelTest : SessionControllerTestBase() { flush() assertEquals(1, appRpc.retries) + assertEquals("Connection Retry Clicked", appRpc.telemetry.last().event) + assertEquals("ERROR", appRpc.telemetry.last().properties["appStatus"]) } fun `test ready warnings show collapsed banner with retry`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt index 6ebca7de308..8a001addf95 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeAppRpcApi.kt @@ -11,6 +11,7 @@ import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -116,6 +117,7 @@ class FakeAppRpcApi : KiloAppRpcApi { var fakeDeviceAuth = DeviceAuthDto(code = "TEST-1234", verificationUrl = "https://auth.kilo.ai/device") val orgProfiles = mutableMapOf() val orgSelections = mutableListOf() + val telemetry = mutableListOf() /** When set, [completeLogin] will await this deferred before returning. */ var completeGate: CompletableDeferred? = null @@ -186,4 +188,9 @@ class FakeAppRpcApi : KiloAppRpcApi { if (orgProfiles.containsKey(organizationId)) fakeProfile = orgProfiles[organizationId] return fakeProfile } + + override suspend fun captureTelemetry(capture: TelemetryCaptureDto) { + assertNotEdt("captureTelemetry") + telemetry.add(capture) + } } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt new file mode 100644 index 00000000000..5dfd3f5ee62 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/KiloPlugin.kt @@ -0,0 +1,17 @@ +package ai.kilocode + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.extensions.PluginDescriptor +import com.intellij.openapi.extensions.PluginId + +object KiloPlugin { + const val ID = "ai.kilocode.jetbrains" + + val id: PluginId = PluginId.getId(ID) + + fun descriptor(): PluginDescriptor? = PluginManagerCore.getPlugin(id) + + fun version() = descriptor()?.version + + fun isRc() = version()?.contains("-rc.", ignoreCase = true) == true +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt index d76dc9e9df5..12763663cf3 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -1,5 +1,7 @@ package ai.kilocode.log +import ai.kilocode.KiloPlugin +import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager import com.intellij.openapi.diagnostic.Logger import java.io.PrintWriter @@ -22,9 +24,8 @@ import java.util.logging.LogRecord * which writes to the standard IDE log file. * * In sandbox mode (i.e. when running via `./gradlew runIde`, detected via the `idea.plugin.in.sandbox.mode` - * system property), output is additionally written to a `kilo-dev.log` file inside the sandbox log directory. - * This makes it easy to tail frontend and backend logs side-by-side during development without opening the - * IDE's own log viewer. + * system property), output is written only to a `kilo-dev.log` file inside the IDE log directory. RC plugin builds + * write to both IntelliJ's log and `kilo-dev.log`. * * Usage: * ```kotlin @@ -46,13 +47,32 @@ interface KiloLog { fun error(msg: String, t: Throwable? = null) companion object { - private val sandbox = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() - fun create(cls: Class<*>): KiloLog { + if (sandbox()) return FileLog(cls) val intellij = IntellijLog(cls) - if (!sandbox) return intellij + if (!runCatching { KiloPlugin.isRc() }.getOrDefault(false)) return intellij return CompositeLog(intellij, FileLog(cls)) } + + fun sandbox(): Boolean = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() + + fun payload(log: KiloLog? = null): Map = buildMap { + put("platform", "jetbrains") + put("client", "jetbrains") + put("feature", "jetbrains-plugin") + runCatching { + val info = ApplicationInfo.getInstance() + put("editorName", info.fullApplicationName) + put("jetbrainsBuild", info.build.asString()) + }.onFailure { log?.info("Could not read ApplicationInfo for environment payload: ${it.message}") } + runCatching { + val version = KiloPlugin.version() + if (version != null) { + put("pluginVersion", version) + put("appVersion", version) + } + }.onFailure { log?.info("Could not read plugin version for environment payload: ${it.message}") } + } } } @@ -80,9 +100,11 @@ internal class FileLog(cls: Class<*>) : KiloLog { private val root: java.util.logging.Logger by lazy { val logger = java.util.logging.Logger.getLogger("ai.kilocode") + val payload = KiloLog.payload().entries.joinToString(" ") { "${it.key}=${it.value}" } logger.addHandler(handler) logger.useParentHandlers = false logger.level = level + logger.log(Level.INFO, "environment payload: $payload") logger } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt index df83a017ac7..9411fefc880 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloAppRpcApi.kt @@ -8,6 +8,7 @@ import ai.kilocode.rpc.dto.ModelSelectionUpdateDto import ai.kilocode.rpc.dto.ModelStateDto import ai.kilocode.rpc.dto.ModelVariantUpdateDto import ai.kilocode.rpc.dto.ProfileDto +import ai.kilocode.rpc.dto.TelemetryCaptureDto import com.intellij.platform.rpc.RemoteApiProviderService import fleet.rpc.RemoteApi import fleet.rpc.Rpc @@ -85,4 +86,7 @@ interface KiloAppRpcApi : RemoteApi { * Returns the updated profile, or null if not logged in. */ suspend fun setOrganization(organizationId: String?): ProfileDto? + + /** Fire-and-forget behavior telemetry routed through the CLI server. */ + suspend fun captureTelemetry(capture: TelemetryCaptureDto) } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt new file mode 100644 index 00000000000..7bfffc64555 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/TelemetryDto.kt @@ -0,0 +1,9 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class TelemetryCaptureDto( + val event: String, + val properties: Map = emptyMap(), +)