mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
Merge pull request #10748 from Kilo-Org/visual-gruyere
feat(jetbrains): expand telemetry and environment logging
This commit is contained in:
@@ -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.
|
||||
|
||||
+56
-1
@@ -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")
|
||||
|
||||
+11
-5
@@ -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()
|
||||
}
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+74
@@ -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) }
|
||||
}
|
||||
+12
@@ -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())
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
@@ -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 ->
|
||||
|
||||
+35
@@ -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,
|
||||
|
||||
+3
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
+112
-3
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+42
-1
@@ -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.
|
||||
|
||||
+16
-1
@@ -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")
|
||||
|
||||
+74
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -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()
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+6
@@ -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]
|
||||
|
||||
+34
@@ -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,
|
||||
|
||||
+2
@@ -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`() {
|
||||
|
||||
+7
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user