Merge pull request #10748 from Kilo-Org/visual-gruyere

feat(jetbrains): expand telemetry and environment logging
This commit is contained in:
Kirill Kalishev
2026-06-01 11:01:44 -04:00
committed by GitHub
34 changed files with 693 additions and 21 deletions
+3
View File
@@ -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.
@@ -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<String, String>) {
val http = connection.apiClient
val port = connection.port
cs.launch {
runCatching {
service<KiloBackendTelemetry>().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<KiloBackendTelemetry>().setEnabled(http, port, enabled)
}.onFailure { log.info("Skipping telemetry setEnabled: ${it.message}") }
}
}
private fun captureBackend(event: String, props: Map<String, String>) {
val http = connection.apiClient
val port = connection.port
cs.launch {
runCatching {
service<KiloBackendTelemetry>().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")
@@ -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}") }
@@ -0,0 +1,7 @@
package ai.kilocode.backend.dev
import ai.kilocode.log.KiloLog
object KiloDevMode {
fun enabled(): Boolean = KiloLog.sandbox()
}
@@ -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<KiloBackendTelemetry>().capture(app.http, app.port, capture.event, capture.properties)
}
private fun dto(state: KiloAppState): KiloAppStateDto =
appStateDto(state)
}
@@ -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<String, String>) {
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, String>): String = JsonObject(
mapOf(
"event" to JsonPrimitive(event),
"properties" to JsonObject(base() + properties.mapValues { JsonPrimitive(it.value) }),
),
).toString()
private fun base(): Map<String, JsonPrimitive> =
KiloLog.payload(log).mapValues { JsonPrimitive(it.value) }
}
@@ -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())
@@ -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()
}
}
}
@@ -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<KiloWorkspaceService>()
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)
}
}
@@ -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) }
}
}
@@ -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()
}
@@ -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(
@@ -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()
}
@@ -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<KiloAppService>().reinstallAsync()
}
@@ -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)
}
}
@@ -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<KiloAppService>().restartAsync()
}
@@ -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 ->
@@ -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<String, String> = 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<String, String> = 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<String, String>) {
Telemetry.send(event, props)
}
private fun buildInitialProgress(
selections: MigrationUiSelections,
detection: ai.kilocode.rpc.dto.LegacyMigrationDetectionDto,
@@ -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)
}
@@ -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) {
@@ -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<String, String>) -> 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<String, String> = emptyMap()) {
telemetry(event, props)
}
private fun sessionProps(id: String? = sid): Map<String, String> = 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<String, String> = 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<String, String> = 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) }
}
}
@@ -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<String, String>) -> 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<String, String> = 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.
@@ -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<String, String>) {
Telemetry.send(event, props)
}
}
private val HTML_MARKERS = listOf("<!doctype html", "<html", "<head", "<body")
@@ -0,0 +1,74 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.client.telemetry
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.TelemetryCaptureDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import fleet.rpc.client.durable
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
object Telemetry {
fun send(event: String, properties: Map<String, String> = 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<String, String> = 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()
}
}
}
}
@@ -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()
@@ -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
@@ -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]
@@ -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<Pair<String, Map<String, String>>>()
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<Pair<String, Map<String, String>>>()
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<Pair<String, Map<String, String>>>) = HistoryController(
sessions,
workspace,
scope,
telemetry = { event, props -> events.add(event to props) },
)
private fun controllerWithGit(url: String?) = HistoryController(
sessions,
workspace,
@@ -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`() {
@@ -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<String?, ProfileDto?>()
val orgSelections = mutableListOf<String?>()
val telemetry = mutableListOf<TelemetryCaptureDto>()
/** When set, [completeLogin] will await this deferred before returning. */
var completeGate: CompletableDeferred<Unit>? = 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)
}
}
@@ -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
}
@@ -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<String, String> = 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
}
@@ -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<Unit> {
* 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)
}
@@ -0,0 +1,9 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class TelemetryCaptureDto(
val event: String,
val properties: Map<String, String> = emptyMap(),
)