From d574890d84d302c9632766456616c589afe07c95 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Oct 2026 22:44:37 +0300 Subject: [PATCH 01/17] Update Toolbox API to 1.15.88839 The plugin targets Toolbox API 1.13.87111 instead of the required 1.15.88839 release. This commit updates the shared API version so the plugin dependencies and packaged manifest consistently target 1.15.88839. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a28804..17c0ffc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -toolbox-plugin-api = "1.13.87111" +toolbox-plugin-api = "1.15.88839" kotlin = "2.3.10" coroutines = "1.10.2" serialization = "1.9.0" From 89b7a545a6106e09f9a0f497eac3a9ad83081473 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Oct 2026 23:23:42 +0300 Subject: [PATCH 02/17] Add a validated connection log buffer size setting The plugin has no setting for a connection log buffer limit, so users cannot configure how many diagnostic entries a buffer should retain. This commit adds a General settings field backed by the connectionLogBufferSize key. The settings store defaults to 1000 entries and accepts whole numbers from 0 through 10000, with 0 representing disabled buffering. Invalid stored values fall back to the default, while the settings page rejects invalid input before saving. The README describes the setting, and tests cover persistence, range validation, default handling, and reloading the field. --- README.md | 6 + .../toolbox/settings/ReadOnlyCoderSettings.kt | 10 ++ .../coder/toolbox/store/CoderSettingsStore.kt | 12 ++ .../com/coder/toolbox/store/StoreKeys.kt | 2 + .../coder/toolbox/views/CoderSettingsPage.kt | 33 ++++++ .../resources/localization/defaultMessages.po | 12 ++ .../toolbox/store/CoderSettingsStoreTest.kt | 39 +++++++ .../toolbox/views/CoderSettingsPageTest.kt | 106 ++++++++++++++++++ 8 files changed, 220 insertions(+) create mode 100644 src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt diff --git a/README.md b/README.md index 26fecc0..e732fc4 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,12 @@ explicit entry per resolved workspace/agent using `Host coder-jetbrains-toolbox----.--`, and its proxy command targets `/.`. +### Connection logging settings + +- `Connection log buffer size` (`connectionLogBufferSize`) sets the maximum number of log entries below Toolbox's + log level to keep in memory for connection diagnostics. It defaults to `1000` and accepts whole numbers from `0` + to `10000`; `0` disables buffering. Invalid stored values fall back to `1000`. + ### CLI related settings - `Binary source` specifies the source URL or relative path from which the Coder CLI should be downloaded. If a relative diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index c6928e0..be644ed 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -4,6 +4,9 @@ import java.net.URL import java.nio.file.Path import java.util.Locale.getDefault +internal const val DEFAULT_CONNECTION_LOG_BUFFER_SIZE = 1_000 +internal val CONNECTION_LOG_BUFFER_SIZE_RANGE = 0..10_000 + /** * Read-only interface for accessing Coder settings */ @@ -58,6 +61,13 @@ interface ReadOnlyCoderSettings { */ val httpClientLogLevel: HttpLoggingVerbosity + /** + * Maximum number of entries below Toolbox's log level to retain for connection diagnostics. + * Defaults to 1000, accepts 0 through 10000, and uses 0 to disable buffering. + * Reserved for future log capture and replay on terminal failures and support bundle collection. + */ + val connectionLogBufferSize: Int + /** * Default CLI binary name based on OS and architecture */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 83e0832..84080e0 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,5 +1,7 @@ package com.coder.toolbox.store +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE +import com.coder.toolbox.settings.DEFAULT_CONNECTION_LOG_BUFFER_SIZE import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings @@ -48,6 +50,9 @@ class CoderSettingsStore( get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) override val httpClientLogLevel: HttpLoggingVerbosity get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) + override val connectionLogBufferSize: Int + get() = store[CONNECTION_LOG_BUFFER_SIZE]?.toIntOrNull() + ?.takeIf { it in CONNECTION_LOG_BUFFER_SIZE_RANGE } ?: DEFAULT_CONNECTION_LOG_BUFFER_SIZE override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) override val dataDirectory: String? get() = store[DATA_DIRECTORY] @@ -213,6 +218,13 @@ class CoderSettingsStore( store[HTTP_CLIENT_LOG_LEVEL] = level.toString() } + fun updateConnectionLogBufferSize(size: Int) { + require(size in CONNECTION_LOG_BUFFER_SIZE_RANGE) { + "Connection log buffer size must be between 0 and 10000" + } + store[CONNECTION_LOG_BUFFER_SIZE] = size.toString() + } + fun updateHeaderCommand(cmd: String) { store[HEADER_COMMAND] = cmd } diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index df7d9b9..a1cc09a 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -23,6 +23,8 @@ internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" +internal const val CONNECTION_LOG_BUFFER_SIZE = "connectionLogBufferSize" + internal const val DATA_DIRECTORY = "dataDirectory" internal const val ENABLE_DOWNLOADS = "enableDownloads" diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 1fddc9c..69379f3 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS @@ -14,6 +15,8 @@ import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue import com.jetbrains.toolbox.api.ui.components.FieldModifier +import com.jetbrains.toolbox.api.ui.components.LabelField +import com.jetbrains.toolbox.api.ui.components.LabelStyleType import com.jetbrains.toolbox.api.ui.components.SectionField import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType @@ -88,6 +91,30 @@ class CoderSettingsPage( settings.headerCommand ?: "", TextType.General ) + + private val connectionLogBufferSizeField = TextField( + context.i18n.ptrl("Connection log buffer size"), + settings.connectionLogBufferSize.toString(), + TextType.Integer, + validator = { value -> + if (value.toIntOrNull()?.let { it in CONNECTION_LOG_BUFFER_SIZE_RANGE } == true) { + ValidationResult.Valid + } else { + ValidationResult.Invalid( + context.i18n.ptrl("Connection log buffer size must be a whole number between 0 and 10000") + ) + } + } + ) + + private val connectionLogBufferDescription = LabelField( + context.i18n.ptrl( + "Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " + + "Defaults to 1000; accepts 0 to 10000. Set to 0 to disable. " + + "Log capture and replay are not yet implemented." + ), + LabelStyleType.Secondary + ) private val useKeyringField = CheckboxField( settings.useKeyring, context.i18n.ptrl("Store CLI session in OS keyring when supported (CLI >= 2.29.0)") @@ -198,6 +225,8 @@ class CoderSettingsPage( useAppNameField, disableAutostartField, httpLoggingField, + connectionLogBufferSizeField, + connectionLogBufferDescription, ) ), SectionField( @@ -252,6 +281,7 @@ class CoderSettingsPage( updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) + updateConnectionLogBufferSize(connectionLogBufferSizeField.contentState.value.toInt()) updateHeaderCommand(headerCommandField.contentState.value) // The active CLI keeps its credential backend until the next sign-in. if (shouldShowKeyringField) updateUseKeyring(useKeyringField.checkedState.value) @@ -293,6 +323,9 @@ class CoderSettingsPage( override fun beforeShow() { // update the value of all fields + connectionLogBufferSizeField.contentState.update { + settings.connectionLogBufferSize.toString() + } binarySourceField.contentState.update { settings.binarySource ?: "" } diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 3b26422..ab5f313 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -67,6 +67,18 @@ msgstr "" msgid "Coder Settings" msgstr "" +msgid "Connection log buffer size" +msgstr "" + +msgid "Connection log buffer size must be a whole number between 0 and 10000" +msgstr "" + +msgid "" +"Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " +"Defaults to 1000; accepts 0 to 10000. Set to 0 to disable. " +"Log capture and replay are not yet implemented." +msgstr "" + msgid "Binary source" msgstr "" diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt index 8dab016..d983a74 100644 --- a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -12,6 +12,7 @@ import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertFailsWith class CoderSettingsStoreTest { private var originalOsName: String? = null @@ -46,6 +47,44 @@ class CoderSettingsStoreTest { assertTrue(store.useKeyring) } + @Test + fun `connection log buffer defaults to 1000`() { + assertEquals(1000, store.readOnly().connectionLogBufferSize) + } + + @Test + fun `connection log buffer persists valid sizes under the documented key`() { + val backingStore = pluginTestSettingsStore() + val settings = CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) + + listOf(0, 1, 2500, 10000).forEach { size -> + settings.updateConnectionLogBufferSize(size) + + assertEquals(size.toString(), backingStore["connectionLogBufferSize"]) + val reloaded = CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) + assertEquals(size, reloaded.readOnly().connectionLogBufferSize) + } + } + + @Test + fun `invalid stored connection log buffer sizes fall back to the default`() { + listOf("", "invalid", "1.5", "-1", "10001", "2147483648").forEach { value -> + val settings = storeWith("connectionLogBufferSize" to value) + + assertEquals(1000, settings.readOnly().connectionLogBufferSize, value) + } + } + + @Test + fun `invalid connection log buffer updates preserve the saved size`() { + store.updateConnectionLogBufferSize(2500) + + listOf(Int.MIN_VALUE, -1, 10001, Int.MAX_VALUE).forEach { size -> + assertFailsWith { store.updateConnectionLogBufferSize(size) } + assertEquals(2500, store.readOnly().connectionLogBufferSize) + } + } + @Test fun `Default CLI and signature for Windows AMD64`() = assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.exe.asc") diff --git a/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt b/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt new file mode 100644 index 0000000..5dedef5 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/views/CoderSettingsPageTest.kt @@ -0,0 +1,106 @@ +package com.coder.toolbox.views + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.ui.components.FieldModifier +import com.jetbrains.toolbox.api.ui.components.SectionField +import com.jetbrains.toolbox.api.ui.components.TextField +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CoderSettingsPageTest { + @TempDir + lateinit var tempDir: Path + + private val settings = CoderSettingsStore( + pluginTestSettingsStore(), Environment(), mockk(relaxed = true) + ) + private val context = mockk(relaxed = true) + private val bufferLabel = mockk() + + init { + every { context.settingsStore } returns settings + every { context.i18n.ptrl("Connection log buffer size") } returns bufferLabel + } + + @Test + fun `connection log buffer field defaults to 1000 and accepts the inclusive range`() { + val page = createPage() + val field = bufferField(page) + assertEquals("1000", field.contentState.value) + + listOf("0", "1", "2500", "10000").forEach { value -> + field.contentState.value = value + assertTrue(page.actionButtons.value.single().validate(), value) + } + } + + @Test + fun `save validation rejects invalid connection log buffer input`() { + val page = createPage() + val field = bufferField(page) + + listOf("", "invalid", "1.5", "-1", "10001", "2147483648").forEach { value -> + field.contentState.value = value + assertFalse(page.actionButtons.value.single().validate(), value) + assertTrue(field.modifiers.value.any { it is FieldModifier.LocalizableError }) + assertEquals(1000, settings.connectionLogBufferSize) + } + + field.contentState.value = "1000" + assertTrue(page.actionButtons.value.single().validate()) + assertTrue(field.modifiers.value.none { it is FieldModifier.LocalizableError }) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `save persists the buffer size and reopening reloads the current value`() = runTest { + val testScope = this + every { context.cs } returns testScope + val page = createPage() + val field = bufferField(page) + val save = page.actionButtons.value.single() + + listOf("0", "10000").forEach { value -> + field.contentState.value = value + assertTrue(save.validate()) + save.run() + advanceUntilIdle() + assertEquals(value.toInt(), settings.readOnly().connectionLogBufferSize) + } + + settings.updateConnectionLogBufferSize(2500) + page.beforeShow() + try { + assertEquals("2500", field.contentState.value) + } finally { + page.afterHide() + advanceUntilIdle() + } + } + + private fun createPage(): CoderSettingsPage { + settings.updateSshConfigPath(tempDir.resolve("config").toString()) + return CoderSettingsPage(context, Channel(Channel.CONFLATED)) {} + } + + private fun bufferField(page: CoderSettingsPage): TextField = page.fields.value + .filterIsInstance() + .flatMap { it.contentState.value } + .filterIsInstance() + .single { it.labelState.value === bufferLabel } +} From 2f01805abf20dd3c5d9788ed42bb02113e2784a3 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 21 Oct 2026 00:24:19 +0300 Subject: [PATCH 03/17] Expose plugin settings changes through Kotlin flows The plugin settings store exposes current values without a way for other components to observe changes. This commit adds typed observation that emits the current effective value and subsequent distinct values. Subscribers can cancel collection independently, and slow collectors receive the latest state. The settings store serializes writes and observer reads, publishing changes after successful storage updates. TLS settings use value equality to suppress duplicate notifications. Existing consumers retain their current behavior. Tests cover initial values, duplicate suppression, failed writes, cancellation, multiple subscribers, and concurrent updates. --- .../toolbox/settings/ReadOnlyCoderSettings.kt | 12 + .../coder/toolbox/store/CoderSettingsStore.kt | 73 +++-- .../store/CoderSettingsObservationTest.kt | 267 ++++++++++++++++++ 3 files changed, 326 insertions(+), 26 deletions(-) create mode 100644 src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index be644ed..ea243e7 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.settings +import kotlinx.coroutines.flow.Flow import java.net.URL import java.nio.file.Path import java.util.Locale.getDefault @@ -12,6 +13,17 @@ internal val CONNECTION_LOG_BUFFER_SIZE_RANGE = 0..10_000 */ interface ReadOnlyCoderSettings { + /** + * Observes the effective value returned by [select], starting with its current value. + * Subsequent values are emitted only when they differ according to [Any.equals]. + * Select immutable values, or immutable data classes when observing several settings together. + * The selector must be a quick, side-effect-free read of these settings. + * + * Changes made through this settings instance are observed. Slow collectors may skip intermediate + * updates and receive the latest value. Cancelling collection unsubscribes the observer. + */ + fun observe(select: (ReadOnlyCoderSettings) -> T): Flow + /** * The last used deployment URL. */ diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 84080e0..c7449c1 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -17,6 +17,10 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.withPath import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.diagnostics.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import java.net.URL import java.nio.file.Files import java.nio.file.Path @@ -29,8 +33,11 @@ class CoderSettingsStore( private val logger: Logger ) : ReadOnlyCoderSettings { + private val settingsLock = Any() + private val revision = MutableStateFlow(0L) + // Internal TLS settings implementation - private class TLSSettings( + private data class TLSSettings( override val certPath: String?, override val keyPath: String?, override val caPath: String?, @@ -177,112 +184,126 @@ class CoderSettingsStore( // a readonly cast fun readOnly(): ReadOnlyCoderSettings = this + override fun observe(select: (ReadOnlyCoderSettings) -> T): Flow = revision + .map { synchronized(settingsLock) { select(this) } } + .distinctUntilChanged() + + private fun writeSetting(key: String, value: String) { + synchronized(settingsLock) { + store[key] = value + // A failed write leaves the revision unchanged. Selectors share this lock so they + // cannot read an update before the backing store's write has returned. + revision.value += 1 + } + } + // Write operations fun updateLastUsedUrl(url: URL) { - store[LAST_USED_URL] = url.toString() + writeSetting(LAST_USED_URL, url.toString()) } fun updateUseAppNameAsTitle(appNameAsTitle: Boolean) { - store[APP_NAME_AS_TITLE] = appNameAsTitle.toString() + writeSetting(APP_NAME_AS_TITLE, appNameAsTitle.toString()) } fun updateBinarySource(source: String) { - store[BINARY_SOURCE] = source + writeSetting(BINARY_SOURCE, source) } fun updateBinaryDestination(dest: String) { - store[BINARY_DESTINATION] = dest + writeSetting(BINARY_DESTINATION, dest) } fun updateDataDirectory(dir: String) { - store[DATA_DIRECTORY] = dir + writeSetting(DATA_DIRECTORY, dir) } fun updateEnableDownloads(shouldEnableDownloads: Boolean) { - store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() + writeSetting(ENABLE_DOWNLOADS, shouldEnableDownloads.toString()) } fun updateDisableSignatureVerification(shouldDisableSignatureVerification: Boolean) { - store[DISABLE_SIGNATURE_VALIDATION] = shouldDisableSignatureVerification.toString() + writeSetting(DISABLE_SIGNATURE_VALIDATION, shouldDisableSignatureVerification.toString()) } fun updateSignatureFallbackStrategy(fallback: Boolean) { - store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { + val strategy = when (fallback) { true -> SignatureFallbackStrategy.ALLOW.toString() else -> SignatureFallbackStrategy.FORBIDDEN.toString() } + writeSetting(FALLBACK_ON_CODER_FOR_SIGNATURES, strategy) } fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { if (level == null) return - store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + writeSetting(HTTP_CLIENT_LOG_LEVEL, level.toString()) } fun updateConnectionLogBufferSize(size: Int) { require(size in CONNECTION_LOG_BUFFER_SIZE_RANGE) { "Connection log buffer size must be between 0 and 10000" } - store[CONNECTION_LOG_BUFFER_SIZE] = size.toString() + writeSetting(CONNECTION_LOG_BUFFER_SIZE, size.toString()) } fun updateHeaderCommand(cmd: String) { - store[HEADER_COMMAND] = cmd + writeSetting(HEADER_COMMAND, cmd) } fun updateUseKeyring(useKeyring: Boolean) { - store[USE_KEYRING] = useKeyring.toString() + writeSetting(USE_KEYRING, useKeyring.toString()) } fun updateCertPath(path: String) { - store[TLS_CERT_PATH] = path + writeSetting(TLS_CERT_PATH, path) } fun updateKeyPath(path: String) { - store[TLS_KEY_PATH] = path + writeSetting(TLS_KEY_PATH, path) } fun updateCAPath(path: String) { - store[TLS_CA_PATH] = path + writeSetting(TLS_CA_PATH, path) } fun updateAltHostname(hostname: String) { - store[TLS_ALTERNATE_HOSTNAME] = hostname + writeSetting(TLS_ALTERNATE_HOSTNAME, hostname) } fun updateDisableAutostart(shouldDisableAutostart: Boolean) { - store[DISABLE_AUTOSTART] = shouldDisableAutostart.toString() + writeSetting(DISABLE_AUTOSTART, shouldDisableAutostart.toString()) } fun updateSshConnectionTimeoutInSeconds(sshConnectionTimeoutInSeconds: Int) { - store[SSH_CONNECTION_TIMEOUT_IN_SECONDS] = sshConnectionTimeoutInSeconds.toString() + writeSetting(SSH_CONNECTION_TIMEOUT_IN_SECONDS, sshConnectionTimeoutInSeconds.toString()) } fun updateEnableSshWildcardConfig(enable: Boolean) { - store[ENABLE_SSH_WILDCARD_CONFIG] = enable.toString() + writeSetting(ENABLE_SSH_WILDCARD_CONFIG, enable.toString()) } fun updateSshConfigPath(path: String) { - store[SSH_CONFIG_PATH] = path + writeSetting(SSH_CONFIG_PATH, path) } fun updateSshLogDir(path: String) { - store[SSH_LOG_DIR] = path + writeSetting(SSH_LOG_DIR, path) } fun updateNetworkInfoDir(path: String) { - store[NETWORK_INFO_DIR] = path + writeSetting(NETWORK_INFO_DIR, path) } fun updateSshConfigOptions(options: String) { - store[SSH_CONFIG_OPTIONS] = options + writeSetting(SSH_CONFIG_OPTIONS, options) } fun updateAutoConnect(workspaceId: String, autoConnect: Boolean) { - store["$SSH_AUTO_CONNECT_PREFIX$workspaceId"] = autoConnect.toString() + writeSetting("$SSH_AUTO_CONNECT_PREFIX$workspaceId", autoConnect.toString()) } fun updatePreferAuthViaOAuth2(preferAuthViaOAuth2: Boolean) { - store[PREFER_OAUTH2_IF_AVAILABLE] = preferAuthViaOAuth2.toString() + writeSetting(PREFER_OAUTH2_IF_AVAILABLE, preferAuthViaOAuth2.toString()) } private fun getDefaultGlobalDataDir(): Path { diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt new file mode 100644 index 0000000..063478b --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsObservationTest.kt @@ -0,0 +1,267 @@ +package com.coder.toolbox.store + +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.ReadOnlyTLSSettings +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.PluginSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoderSettingsObservationTest { + @Test + fun `observation starts with the effective persisted buffer size`() = runTest { + listOf("2500" to 2500, "0" to 0, "10000" to 10000, "invalid" to 1000, "10001" to 1000) + .forEach { (raw, expected) -> + val settings = settings(pluginTestSettingsStore(CONNECTION_LOG_BUFFER_SIZE to raw)).readOnly() + + assertEquals(expected, settings.observe { it.connectionLogBufferSize }.first()) + } + assertEquals(1000, settings().readOnly().observe { it.connectionLogBufferSize }.first()) + } + + @Test + fun `observation reads the current value when collection starts`() = runTest { + val settings = settings() + val sizes = settings.readOnly().observe { it.connectionLogBufferSize } + + settings.updateConnectionLogBufferSize(2500) + + assertEquals(2500, sizes.first()) + } + + @Test + fun `buffer updates are delivered after persistence`() = runTest { + val backingStore = pluginTestSettingsStore() + val settings = settings(backingStore) + val observed = mutableListOf>() + settings.readOnly().observe { it.connectionLogBufferSize } + .onEach { observed += it to backingStore[CONNECTION_LOG_BUFFER_SIZE] } + .launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + runCurrent() + settings.updateConnectionLogBufferSize(10000) + runCurrent() + + assertEquals(listOf(1000 to null, 0 to "0", 10000 to "10000"), observed) + } + + @Test + fun `unchanged effective values and unrelated settings do not emit`() = runTest { + val settings = settings(pluginTestSettingsStore(CONNECTION_LOG_BUFFER_SIZE to "invalid")) + val observed = mutableListOf() + settings.observe { it.connectionLogBufferSize }.onEach { observed += it }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(1000) + runCurrent() + settings.updateHeaderCommand("new header command") + runCurrent() + settings.updateConnectionLogBufferSize(1000) + runCurrent() + + assertEquals(listOf(1000), observed) + } + + @Test + fun `rejected buffer sizes do not invalidate observers`() = runTest { + val settings = settings() + var reads = 0 + val observed = mutableListOf() + settings.observe { + reads++ + it.connectionLogBufferSize + } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + assertFailsWith { settings.updateConnectionLogBufferSize(-1) } + assertFailsWith { settings.updateConnectionLogBufferSize(10001) } + runCurrent() + + assertEquals(1, reads) + assertEquals(listOf(1000), observed) + } + + @Test + fun `failed persistence does not invalidate observers and a later successful write does`() = runTest { + val persisted = pluginTestSettingsStore() + var failWrites = true + val backingStore = object : PluginSettingsStore by persisted { + override fun put(key: String, value: String): String? { + if (failWrites) throw IOException("Cannot persist settings") + return persisted.put(key, value) + } + } + val settings = settings(backingStore) + var reads = 0 + val observed = mutableListOf() + settings.observe { + reads++ + it.connectionLogBufferSize + } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + assertFailsWith { settings.updateConnectionLogBufferSize(2500) } + runCurrent() + assertEquals(1, reads) + assertEquals(1000, settings.connectionLogBufferSize) + + failWrites = false + settings.updateConnectionLogBufferSize(2500) + runCurrent() + + assertEquals(listOf(1000, 2500), observed) + } + + @Test + fun `subscribers receive the current value and can unsubscribe independently`() = runTest { + val settings = settings() + val sizes = settings.readOnly().observe { it.connectionLogBufferSize } + val first = mutableListOf() + val second = mutableListOf() + val subscription = sizes.onEach { first += it }.launchIn(backgroundScope) + runCurrent() + settings.updateConnectionLogBufferSize(2500) + runCurrent() + + sizes.onEach { second += it }.launchIn(backgroundScope) + runCurrent() + settings.updateConnectionLogBufferSize(0) + runCurrent() + subscription.cancelAndJoin() + settings.updateConnectionLogBufferSize(10000) + runCurrent() + + assertEquals(listOf(1000, 2500, 0), first) + assertEquals(listOf(2500, 0, 10000), second) + } + + @Test + fun `slow subscribers receive the latest size without blocking writes`() = runTest { + val settings = settings() + val release = CompletableDeferred() + val observed = mutableListOf() + settings.observe { it.connectionLogBufferSize }.onEach { + observed += it + if (it == 1000) release.await() + }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + settings.updateConnectionLogBufferSize(10000) + settings.updateConnectionLogBufferSize(2500) + runCurrent() + assertEquals(listOf(1000), observed) + + release.complete(Unit) + runCurrent() + + assertEquals(listOf(1000, 2500), observed) + } + + @Test + fun `TLS selections compare their values rather than their object identities`() = runTest { + val settings = settings() + val observed = mutableListOf() + settings.readOnly().observe { it.tls }.onEach { observed += it }.launchIn(backgroundScope) + runCurrent() + + settings.updateConnectionLogBufferSize(0) + runCurrent() + settings.updateCertPath("cert.pem") + runCurrent() + settings.updateCertPath("cert.pem") + runCurrent() + settings.updateKeyPath("key.pem") + runCurrent() + + assertEquals( + listOf(null to null, "cert.pem" to null, "cert.pem" to "key.pem"), + observed.map { it.certPath to it.keyPath } + ) + } + + @Test + fun `selectors can observe several settings as one immutable value`() = runTest { + val settings = settings() + val observed = mutableListOf>() + settings.readOnly().observe { it.connectionLogBufferSize to it.useKeyring } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + settings.updateUseKeyring(false) + runCurrent() + settings.updateConnectionLogBufferSize(2500) + runCurrent() + settings.updateUseKeyring(false) + runCurrent() + + assertEquals(listOf(1000 to true, 1000 to false, 2500 to false), observed) + } + + @Test + fun `workspace setting changes notify only affected selections`() = runTest { + val settings = settings() + val observed = mutableListOf() + settings.readOnly().observe { it.shouldAutoConnect("workspace") } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + settings.updateAutoConnect("other-workspace", true) + runCurrent() + settings.updateAutoConnect("workspace", true) + runCurrent() + + assertEquals(listOf(false, true), observed) + } + + @Test + fun `concurrent writes leave observers consistent with persisted settings`() = runTest { + val backingStore = pluginTestSettingsStore() + val settings = settings(backingStore) + val observed = mutableListOf>() + settings.observe { it.connectionLogBufferSize to it.connectionLogBufferSize } + .onEach { observed += it } + .launchIn(backgroundScope) + runCurrent() + + withContext(Dispatchers.Default) { + coroutineScope { + repeat(100) { size -> launch { settings.updateConnectionLogBufferSize(size) } } + } + } + runCurrent() + + assertTrue(observed.all { (first, second) -> first == second }) + val persisted = backingStore[CONNECTION_LOG_BUFFER_SIZE]?.toInt() + assertEquals(persisted to persisted, observed.last()) + } + + private fun settings(backingStore: PluginSettingsStore = pluginTestSettingsStore()): CoderSettingsStore = + CoderSettingsStore(backingStore, Environment(), mockk(relaxed = true)) +} From 9e060b910bd1c6985950c4e12d07e56fb653e057 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 21 Oct 2026 22:59:36 +0300 Subject: [PATCH 04/17] Add a flight recorder for filtered plugin logs Currently, Toolbox discards plugin logs below the active log level, which removes detail needed to diagnose connection failures. This commit adds a private flight recorder to CoderLogger for messages supplied as strings. The recorder uses Toolbox's filtering decision to retain each message with its original level, timestamp, and exception details in a bounded ring buffer. It observes the plugin's buffer capacity setting, preserves the newest records when shrinking, and clears the buffer when recording is disabled. --- .../com/coder/toolbox/CoderToolboxContext.kt | 4 +- .../coder/toolbox/CoderToolboxExtension.kt | 12 ++- .../coder/toolbox/diagnostics/CoderLogger.kt | 78 ++++++++++++----- .../toolbox/diagnostics/FlightRecorder.kt | 86 +++++++++++++++++++ .../toolbox/settings/ReadOnlyCoderSettings.kt | 2 +- .../coder/toolbox/views/CoderSettingsPage.kt | 3 +- .../resources/localization/defaultMessages.po | 3 +- .../toolbox/CoderRemoteEnvironmentTest.kt | 2 +- .../coder/toolbox/CoderRemoteProviderTest.kt | 1 + .../com/coder/toolbox/cli/EnsureCLITest.kt | 3 +- .../toolbox/diagnostics/CoderLoggerTest.kt | 2 +- .../CoderSupportBundleCollectorTest.kt | 11 +-- .../toolbox/feed/IdeFeedManagerOfflineTest.kt | 4 +- .../coder/toolbox/feed/IdeFeedManagerTest.kt | 4 +- .../toolbox/session/SessionIdRegistryTest.kt | 3 +- .../toolbox/util/CoderProtocolHandlerTest.kt | 1 + .../util/ConnectionMonitoringServiceTest.kt | 1 + .../com/coder/toolbox/views/ActionTest.kt | 2 + 18 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 2aeda7a..3567b25 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -34,7 +34,9 @@ data class CoderToolboxContext( val secrets: CoderSecretsStore, val proxySettings: ToolboxProxySettings, ) { - val logger: CoderLogger = CoderLogger(underlyingLogger, ui, cs, i18n) + val logger: CoderLogger = + underlyingLogger as? CoderLogger ?: CoderLogger(underlyingLogger, ui, cs, i18n, settingsStore.readOnly()) + val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this) /** diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 638fba2..fdf7369 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -1,5 +1,6 @@ package com.coder.toolbox +import com.coder.toolbox.diagnostics.CoderLogger import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore @@ -28,9 +29,12 @@ class CoderToolboxExtension : RemoteDevExtension { // All services must be passed in here and threaded as necessary. override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { val ui = serviceLocator.getService() - val logger = serviceLocator.getService(Logger::class.java) val cs = serviceLocator.getService() val i18n = serviceLocator.getService() + val delegate = serviceLocator.getService() + val settingsStore = + CoderSettingsStore(serviceLocator.getService(), Environment(), delegate) + val logger = CoderLogger(delegate, ui, cs, i18n, settingsStore.readOnly()) return CoderRemoteProvider( CoderToolboxContext( ui, @@ -41,12 +45,12 @@ class CoderToolboxExtension : RemoteDevExtension { serviceLocator.getService(), serviceLocator.getService(), cs, - serviceLocator.getService(), + logger, i18n, - CoderSettingsStore(serviceLocator.getService(), Environment(), logger), + settingsStore, CoderSecretsStore(serviceLocator.getService()), serviceLocator.getService(), ) ) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt index b219954..2ebeb05 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt @@ -3,6 +3,8 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory import com.jetbrains.toolbox.api.ui.ToolboxUi @@ -30,84 +32,115 @@ class CoderLogger( private val ui: ToolboxUi, private val cs: CoroutineScope, private val i18n: LocalizableStringFactory, + settings: ReadOnlyCoderSettings, ) : Logger by delegate { + private val flightRecorder = FlightRecorder(settings, cs, delegate) + override inline fun error(message: String) { + record(LogLevel.ERROR, message) delegate.error(message) } override inline fun error(exception: Throwable, message: String) { + record(LogLevel.ERROR, message, exception) delegate.error(exception, message) } inline fun error(sessionId: SessionId?, exception: Throwable, message: String) { - delegate.error(exception, withSessionId(sessionId, message)) + error(exception, withSessionId(sessionId, message)) } inline fun error(sessionIds: Set, exception: Throwable, message: String) { - sessionIds.onceOrForEach { delegate.error(exception, withSessionId(it, message)) } + sessionIds.onceOrForEach { error(exception, withSessionId(it, message)) } } override inline fun warn(message: String) { + record(LogLevel.WARN, message) delegate.warn(message) } override inline fun warn(exception: Throwable, message: String) { + record(LogLevel.WARN, message, exception) delegate.warn(exception, message) } inline fun warn(sessionId: SessionId?, message: String) { - delegate.warn(withSessionId(sessionId, message)) + warn(withSessionId(sessionId, message)) } inline fun warn(sessionId: SessionId?, exception: Throwable, message: String) { - delegate.warn(exception, withSessionId(sessionId, message)) + warn(exception, withSessionId(sessionId, message)) } inline fun warn(sessionIds: Set, exception: Throwable, message: String) { - sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, message)) } + sessionIds.onceOrForEach { warn(exception, withSessionId(it, message)) } } override inline fun debug(message: String) { + record(LogLevel.DEBUG, message) delegate.debug(message) } + override inline fun debug(exception: Throwable, message: String) { + record(LogLevel.DEBUG, message, exception) + delegate.debug(exception, message) + } + inline fun debug(sessionId: SessionId?, message: String) { - delegate.debug(withSessionId(sessionId, message)) + debug(withSessionId(sessionId, message)) } inline fun debug(sessionIds: Set, message: String) { - sessionIds.onceOrForEach { delegate.debug(withSessionId(it, message)) } + sessionIds.onceOrForEach { debug(withSessionId(it, message)) } } override inline fun info(message: String) { + record(LogLevel.INFO, message) delegate.info(message) } + override inline fun info(exception: Throwable, message: String) { + record(LogLevel.INFO, message, exception) + delegate.info(exception, message) + } + inline fun info(sessionId: SessionId?, message: String) { - delegate.info(withSessionId(sessionId, message)) + info(withSessionId(sessionId, message)) } inline fun info(sessionIds: Set, message: String) { - sessionIds.onceOrForEach { delegate.info(withSessionId(it, message)) } + sessionIds.onceOrForEach { info(withSessionId(it, message)) } + } + + override inline fun trace(message: String) { + record(LogLevel.TRACE, message) + delegate.trace(message) + } + + @Deprecated("Do not trace with exceptions", level = DeprecationLevel.ERROR) + @Suppress("DEPRECATION_ERROR") + override inline fun trace(exception: Throwable, message: String) { + record(LogLevel.TRACE, message, exception) + delegate.trace(exception, message) } inline fun logAndShowError(title: String, error: String) { - delegate.error(error) + error(error) showInfoPopup(title, error) } inline fun logAndShowError(sessionId: SessionId?, title: String, error: String) { - delegate.error(withSessionId(sessionId, error)) + error(withSessionId(sessionId, error)) showInfoPopup(title, error) } inline fun logAndShowError(title: String, error: String, exception: Throwable) { - delegate.error(exception, error) + error(exception, error) showInfoPopup(title, error) } inline fun logAndShowError(sessionId: SessionId?, title: String, error: String, exception: Throwable) { - delegate.error(exception, withSessionId(sessionId, error)) + error(exception, withSessionId(sessionId, error)) showInfoPopup(title, error) } @@ -117,17 +150,17 @@ class CoderLogger( error: String, exception: Throwable, ) { - sessionIds.onceOrForEach { delegate.error(exception, withSessionId(it, error)) } + sessionIds.onceOrForEach { error(exception, withSessionId(it, error)) } showInfoPopup(title, error) } inline fun logAndShowWarning(title: String, warning: String) { - delegate.warn(warning) + warn(warning) showInfoPopup(title, warning) } inline fun logAndShowWarning(sessionId: SessionId?, title: String, warning: String) { - delegate.warn(withSessionId(sessionId, warning)) + warn(withSessionId(sessionId, warning)) showInfoPopup(title, warning) } @@ -137,20 +170,25 @@ class CoderLogger( warning: String, exception: Throwable, ) { - sessionIds.onceOrForEach { delegate.warn(exception, withSessionId(it, warning)) } + sessionIds.onceOrForEach { warn(exception, withSessionId(it, warning)) } showInfoPopup(title, warning) } inline fun logAndShowWarning(title: String, warning: String, exception: Throwable) { - delegate.warn(exception, warning) + warn(exception, warning) showInfoPopup(title, warning) } inline fun logAndShowInfo(title: String, info: String) { - delegate.info(info) + info(info) showInfoPopup(title, info) } + @PublishedApi + internal fun record(level: LogLevel, message: String, exception: Throwable? = null) { + flightRecorder.record(level, message, exception) + } + /** * Displays an informational popup on a child of the plugin coroutine scope rather than on * the caller's coroutine, without waiting for it. @@ -175,7 +213,7 @@ class CoderLogger( } catch (_: CancellationException) { // Expected when the plugin scope shuts down while the popup is open. } catch (ex: Exception) { - delegate.error(ex, "Failed to display popup with title '$title'") + error(ex, "Failed to display popup with title '$title'") } } } diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt new file mode 100644 index 0000000..e2c77ab --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -0,0 +1,86 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.Instant + +internal data class FlightRecorderEntry(val level: LogLevel, val timestamp: Instant, val message: String) + +/** Captures recent messages filtered by Toolbox without retaining message suppliers or exceptions. */ +internal class FlightRecorder( + readOnlySettings: ReadOnlyCoderSettings, + scope: CoroutineScope, + private val logger: Logger, +) { + private val lock = Any() + private val clock: Clock = Clock.systemUTC() + private var entries = arrayOfNulls(readOnlySettings.connectionLogBufferSize) + private var oldestRecordIndex = 0 + private var count = 0 + + init { + scope.launch(CoroutineName("Connection log buffer capacity"), start = CoroutineStart.UNDISPATCHED) { + try { + readOnlySettings.observe { it.connectionLogBufferSize }.collect(::resize) + } finally { + resize(0) + } + } + } + + private val capacity: Int get() = synchronized(lock) { entries.size } + + fun record(level: LogLevel, message: String, exception: Throwable? = null) { + if (capacity == 0 || logger.isEnabled(level)) return + record(level, clock.instant(), message, exception) + } + + private fun record(level: LogLevel, timestamp: Instant, message: String, exception: Throwable?) { + val text = if (exception == null) message else "$message\n${exception.stackTraceToString()}" + val entry = FlightRecorderEntry(level, timestamp, text) + synchronized(lock) { + if (entries.isEmpty()) return + val newestRecordIndex = oldestRecordIndex + count + entries[if (newestRecordIndex >= entries.size) newestRecordIndex - entries.size else newestRecordIndex] = + entry + if (count < entries.size) { + count++ + } else { + oldestRecordIndex = if (oldestRecordIndex == entries.lastIndex) 0 else oldestRecordIndex + 1 + } + } + } + + /** Retains the newest entries, in insertion order, when the capacity changes. Zero clears stored entries. */ + private fun resize(capacity: Int) { + require(capacity in CONNECTION_LOG_BUFFER_SIZE_RANGE) + synchronized(lock) { + if (capacity == entries.size) return + val retained = minOf(count, capacity) + val resized = arrayOfNulls(capacity) + if (retained > 0) { + val start = (oldestRecordIndex + count - retained) % entries.size + val firstPart = minOf(retained, entries.size - start) + entries.copyInto(resized, destinationOffset = 0, startIndex = start, endIndex = start + firstPart) + if (firstPart < retained) { + entries.copyInto( + resized, + destinationOffset = firstPart, + startIndex = 0, + endIndex = retained - firstPart + ) + } + } + entries = resized + oldestRecordIndex = 0 + count = retained + } + } +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index ea243e7..3bb5739 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -76,7 +76,7 @@ interface ReadOnlyCoderSettings { /** * Maximum number of entries below Toolbox's log level to retain for connection diagnostics. * Defaults to 1000, accepts 0 through 10000, and uses 0 to disable buffering. - * Reserved for future log capture and replay on terminal failures and support bundle collection. + * Captured entries retain their original log level, timestamp, and message. */ val connectionLogBufferSize: Int diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 69379f3..b82bfd4 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -110,8 +110,7 @@ class CoderSettingsPage( private val connectionLogBufferDescription = LabelField( context.i18n.ptrl( "Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " + - "Defaults to 1000; accepts 0 to 10000. Set to 0 to disable. " + - "Log capture and replay are not yet implemented." + "Defaults to 1000; accepts 0 to 10000. Set to 0 to disable." ), LabelStyleType.Secondary ) diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index ab5f313..98c4790 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -75,8 +75,7 @@ msgstr "" msgid "" "Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " -"Defaults to 1000; accepts 0 to 10000. Set to 0 to disable. " -"Log capture and replay are not yet implemented." +"Defaults to 1000; accepts 0 to 10000. Set to 0 to disable." msgstr "" msgid "Binary source" diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt index 9d78fbd..9cce4ff 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteEnvironmentTest.kt @@ -804,7 +804,7 @@ class CoderRemoteEnvironmentTest { every { i18n.pnotr(any()) } answers { localizedStrings.getOrPut(firstArg()) { mockk(relaxed = true) } } - val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n) + val coderLogger = CoderLogger(logger, mockk(relaxed = true), scope, i18n, mockk(relaxed = true)) every { context.cs } returns scope every { context.logger } returns coderLogger every { context.settingsStore } returns settingsStore diff --git a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt index c1fad6e..5eb6843 100644 --- a/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/CoderRemoteProviderTest.kt @@ -67,6 +67,7 @@ class CoderRemoteProviderTest { mockk(relaxed = true), CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) val settingsStore = mockk(relaxed = true) every { mockContext.settingsStore } returns settingsStore diff --git a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt index c6da80e..036768e 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt @@ -30,6 +30,7 @@ import com.sun.net.httpserver.HttpServer import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import java.net.HttpURLConnection @@ -68,7 +69,7 @@ internal class EnsureCLITest { mockk(), mockk(), mockk(), - mockk(), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), mockk(relaxed = true), CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt index 9f8b33e..51d8e8c 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerTest.kt @@ -17,7 +17,7 @@ class CoderLoggerTest { private val delegate = mockk(relaxed = true) private val ui = mockk(relaxed = true) private val i18n = mockk(relaxed = true) - private val logger = CoderLogger(delegate, ui, CoroutineScope(Dispatchers.Unconfined), i18n) + private val logger = CoderLogger(delegate, ui, CoroutineScope(Dispatchers.Unconfined), i18n, mockk(relaxed = true)) private val sessionId = SessionId.generate() private val prefix = "client_session_id=$sessionId" diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt index 5a17460..dcf6efe 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt @@ -28,7 +28,8 @@ class CoderSupportBundleCollectorTest { val destination = Files.writeString(root.resolve("diagnostics"), "existing file") val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) val sshLog = Files.writeString(sshLogs.resolve("coder-ssh-session-1.log"), "SSH session") - val logger = mockk(relaxed = true) + val delegate = mockk(relaxed = true) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) var bundleCollected = false val collector = CoderSupportBundleCollector(logger, sshLogs) { bundleCollected = true @@ -39,8 +40,8 @@ class CoderSupportBundleCollectorTest { assertFalse(bundleCollected) assertEquals("existing file", Files.readString(destination)) assertEquals("SSH session", Files.readString(sshLog)) - verify(exactly = 1) { logger.warn(any(), "Could not collect Coder workspace diagnostics") } - confirmVerified(logger) + verify(exactly = 1) { delegate.warn(any(), "Could not collect Coder workspace diagnostics") } + confirmVerified(delegate) } @Test @@ -68,7 +69,7 @@ class CoderSupportBundleCollectorTest { val sshLogs = Files.createDirectories(root.resolve("ssh-logs")) Files.writeString(sshLogs.resolve("coder-ssh-session-1.log"), "SSH session") val delegate = mockk(relaxed = true) - val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) val sessionId = SessionId.generate() val collector = CoderSupportBundleCollector(logger, sshLogs, currentSessionId = { sessionId }) { Files.writeString(it, "partial") @@ -104,7 +105,7 @@ class CoderSupportBundleCollectorTest { fun `SSH log failure preserves the support bundle`() = runTest { val invalidDirectory = Files.writeString(root.resolve("ssh-logs"), "not a directory") val delegate = mockk(relaxed = true) - val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk()) + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), mockk(relaxed = true)) val sessionId = SessionId.generate() val collector = CoderSupportBundleCollector(logger, invalidDirectory, currentSessionId = { sessionId }) { Files.writeString(it, "bundle") diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt index 36e8cb4..484631d 100644 --- a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerOfflineTest.kt @@ -11,6 +11,7 @@ import com.squareup.moshi.Types import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -93,8 +94,9 @@ class IdeFeedManagerOfflineTest { logger = CoderLogger( mockk(relaxed = true), mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) settingsStore = mockk(relaxed = true) diff --git a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt index 46244e9..ebf1ad8 100644 --- a/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/feed/IdeFeedManagerTest.kt @@ -9,6 +9,7 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -86,8 +87,9 @@ class IdeFeedManagerTest { logger = CoderLogger( mockk(relaxed = true), mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) every { context.logger } returns logger diff --git a/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt b/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt index 2c8655b..86434b3 100644 --- a/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt +++ b/src/test/kotlin/com/coder/toolbox/session/SessionIdRegistryTest.kt @@ -25,8 +25,9 @@ class SessionIdRegistryTest { private val coderLogger = CoderLogger( logger, mockk(relaxed = true), - mockk(relaxed = true), + CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) private val context = mockk(relaxed = true) diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 7b768d2..e8f5642 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -78,6 +78,7 @@ class CoderProtocolHandlerTest { mockk(relaxed = true), CoroutineScope(dispatcher), mockk(relaxed = true), + mockk(relaxed = true), ) environment = mockk { every { id } returns "env-1" diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt index 3f57b1c..ad6b16c 100644 --- a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -33,6 +33,7 @@ class ConnectionMonitoringServiceTest { mockk(relaxed = true), CoroutineScope(Dispatchers.Unconfined), mockk(relaxed = true), + mockk(relaxed = true), ) init { diff --git a/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt b/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt index d0e5ec5..8f31c31 100644 --- a/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt +++ b/src/test/kotlin/com/coder/toolbox/views/ActionTest.kt @@ -27,6 +27,7 @@ class ActionTest { mockk(relaxed = true), testScope, mockk(relaxed = true), + mockk(relaxed = true), ) every { context.cs } returns testScope every { context.logger } returns coderLogger @@ -55,6 +56,7 @@ class ActionTest { mockk(relaxed = true), testScope, mockk(relaxed = true), + mockk(relaxed = true), ) every { context.cs } returns testScope every { context.logger } returns coderLogger From 4236f77564cb9b5da288367dedc586a493f8e722 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 21 Oct 2026 23:05:11 +0300 Subject: [PATCH 05/17] Capture lazy log messages in the flight recorder Currently, CoderLogger delegates lambda logging overloads directly to Toolbox, bypassing the flight recorder. The plugin does not use these overloads yet, but introducing a call later can silently lose filtered messages. This commit overrides every lambda logging overload in CoderLogger, including exception variants, so future calls participate in recording. FlightRecorder evaluates captured messages once and reuses their text, while leaving the original suppliers unevaluated when capture is unnecessary. --- .../coder/toolbox/diagnostics/CoderLogger.kt | 46 +++++++++++++++++++ .../toolbox/diagnostics/FlightRecorder.kt | 9 ++++ 2 files changed, 55 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt index 2ebeb05..a0ed4fa 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt @@ -41,11 +41,19 @@ class CoderLogger( delegate.error(message) } + override inline fun error(noinline message: () -> String) { + delegate.error(record(LogLevel.ERROR, null, message)) + } + override inline fun error(exception: Throwable, message: String) { record(LogLevel.ERROR, message, exception) delegate.error(exception, message) } + override inline fun error(exception: Throwable, noinline message: () -> String) { + delegate.error(exception, record(LogLevel.ERROR, exception, message)) + } + inline fun error(sessionId: SessionId?, exception: Throwable, message: String) { error(exception, withSessionId(sessionId, message)) } @@ -59,11 +67,19 @@ class CoderLogger( delegate.warn(message) } + override inline fun warn(noinline message: () -> String) { + delegate.warn(record(LogLevel.WARN, null, message)) + } + override inline fun warn(exception: Throwable, message: String) { record(LogLevel.WARN, message, exception) delegate.warn(exception, message) } + override inline fun warn(exception: Throwable, noinline message: () -> String) { + delegate.warn(exception, record(LogLevel.WARN, exception, message)) + } + inline fun warn(sessionId: SessionId?, message: String) { warn(withSessionId(sessionId, message)) } @@ -81,11 +97,19 @@ class CoderLogger( delegate.debug(message) } + override inline fun debug(noinline message: () -> String) { + delegate.debug(record(LogLevel.DEBUG, null, message)) + } + override inline fun debug(exception: Throwable, message: String) { record(LogLevel.DEBUG, message, exception) delegate.debug(exception, message) } + override inline fun debug(exception: Throwable, noinline message: () -> String) { + delegate.debug(exception, record(LogLevel.DEBUG, exception, message)) + } + inline fun debug(sessionId: SessionId?, message: String) { debug(withSessionId(sessionId, message)) } @@ -99,11 +123,19 @@ class CoderLogger( delegate.info(message) } + override inline fun info(noinline message: () -> String) { + delegate.info(record(LogLevel.INFO, null, message)) + } + override inline fun info(exception: Throwable, message: String) { record(LogLevel.INFO, message, exception) delegate.info(exception, message) } + override inline fun info(exception: Throwable, noinline message: () -> String) { + delegate.info(exception, record(LogLevel.INFO, exception, message)) + } + inline fun info(sessionId: SessionId?, message: String) { info(withSessionId(sessionId, message)) } @@ -117,6 +149,10 @@ class CoderLogger( delegate.trace(message) } + override inline fun trace(noinline message: () -> String) { + delegate.trace(record(LogLevel.TRACE, null, message)) + } + @Deprecated("Do not trace with exceptions", level = DeprecationLevel.ERROR) @Suppress("DEPRECATION_ERROR") override inline fun trace(exception: Throwable, message: String) { @@ -124,6 +160,12 @@ class CoderLogger( delegate.trace(exception, message) } + @Deprecated("Do not trace with exceptions", level = DeprecationLevel.ERROR) + @Suppress("DEPRECATION_ERROR") + override inline fun trace(exception: Throwable, noinline message: () -> String) { + delegate.trace(exception, record(LogLevel.TRACE, exception, message)) + } + inline fun logAndShowError(title: String, error: String) { error(error) showInfoPopup(title, error) @@ -189,6 +231,10 @@ class CoderLogger( flightRecorder.record(level, message, exception) } + @PublishedApi + internal fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String = + flightRecorder.record(level, exception, message) + /** * Displays an informational popup on a child of the plugin coroutine scope rather than on * the caller's coroutine, without waiting for it. diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt index e2c77ab..e9683da 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -42,6 +42,15 @@ internal class FlightRecorder( record(level, clock.instant(), message, exception) } + fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String { + if (capacity == 0 || logger.isEnabled(level)) return message + val timestamp = clock.instant() + val resolved = message() + record(level, timestamp, resolved, exception) + // Toolbox may evaluate this later if its level changes. Never evaluate the original supplier twice. + return { resolved } + } + private fun record(level: LogLevel, timestamp: Instant, message: String, exception: Throwable?) { val text = if (exception == null) message else "$message\n${exception.stackTraceToString()}" val entry = FlightRecorderEntry(level, timestamp, text) From 48ddebfff907469f85c344d00e72a688459ad72d Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 21 Oct 2026 23:09:01 +0300 Subject: [PATCH 06/17] Skip flight recorder capture when logging is off Currently, Toolbox disables every log level when logging is off, so the flight recorder treats those messages as filtered and retains them. This commit makes FlightRecorder check Toolbox's effective log level before capturing either string or lambda messages. When the level is off, the recorder leaves buffered entries untouched and does not evaluate message suppliers. --- .../kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt index e9683da..5a64ae2 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -38,12 +38,12 @@ internal class FlightRecorder( private val capacity: Int get() = synchronized(lock) { entries.size } fun record(level: LogLevel, message: String, exception: Throwable? = null) { - if (capacity == 0 || logger.isEnabled(level)) return + if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return record(level, clock.instant(), message, exception) } fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String { - if (capacity == 0 || logger.isEnabled(level)) return message + if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return message val timestamp = clock.instant() val resolved = message() record(level, timestamp, resolved, exception) From b447c2e62e45ca7eb8d57e67c6cd81f743e03e25 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 01:08:50 +0300 Subject: [PATCH 07/17] Replay buffered logs through the Coder logger The flight recorder retains suppressed log entries but has no way to replay them into the Toolbox log, so the captured context cannot be written on demand. CoderLogger now exposes a flush operation, and FlightRecorder replays entries from oldest to newest in batches of up to 100. The replay preserves original UTC timestamps and severity in each message, uses a level the Toolbox logger currently persists, and removes entries after their batch is logged. Logging at OFF leaves buffered entries untouched and produces no replay output. The CoderLogger test suite exercises replay formatting, batching, log levels, buffer resizing, and lazy messages through controllable mocked dependencies. --- .../coder/toolbox/diagnostics/CoderLogger.kt | 5 + .../toolbox/diagnostics/FlightRecorder.kt | 57 ++ .../CoderLoggerFlightRecorderTest.kt | 794 ++++++++++++++++++ 3 files changed, 856 insertions(+) create mode 100644 src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt index a0ed4fa..ba4fc6b 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderLogger.kt @@ -235,6 +235,11 @@ class CoderLogger( internal fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String = flightRecorder.record(level, exception, message) + /** Replays and removes buffered logs, oldest first, with an optional session prefix. */ + fun flush(reason: String, sessionId: SessionId? = null) { + flightRecorder.flush(reason, sessionId) + } + /** * Displays an informational popup on a child of the plugin coroutine scope rather than on * the caller's coroutine, without waiting for it. diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt index 5a64ae2..df8e68b 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -1,5 +1,6 @@ package com.coder.toolbox.diagnostics +import com.coder.toolbox.session.SessionId import com.coder.toolbox.settings.CONNECTION_LOG_BUFFER_SIZE_RANGE import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.jetbrains.toolbox.api.core.diagnostics.LogLevel @@ -10,6 +11,11 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.launch import java.time.Clock import java.time.Instant +import java.time.format.DateTimeFormatterBuilder + +private const val REPLAY_CHUNK_SIZE = 100 +private const val BUFFERED_PREFIX = "[buffered] " +private val BUFFERED_TIMESTAMP_FORMATTER = DateTimeFormatterBuilder().appendInstant(3).toFormatter() internal data class FlightRecorderEntry(val level: LogLevel, val timestamp: Instant, val message: String) @@ -51,6 +57,57 @@ internal class FlightRecorder( return { resolved } } + fun flush(reason: String, sessionId: SessionId? = null) { + synchronized(lock) { + if (count == 0 || logger.effectiveLevel.value == LogLevel.OFF) return + if (!replay("${BUFFERED_PREFIX}replaying $count buffered entries ($reason)", sessionId)) return + + while (count > 0) { + if (logger.effectiveLevel.value == LogLevel.OFF) return + val chunkSize = minOf(count, REPLAY_CHUNK_SIZE) + val message = buildString { + repeat(chunkSize) { index -> + if (index > 0) append('\n') + val entry = requireNotNull(entries[(oldestRecordIndex + index) % entries.size]) + appendBufferedEntry(entry) + } + } + if (!replay(message, sessionId)) return + + repeat(chunkSize) { + entries[oldestRecordIndex] = null + oldestRecordIndex = if (oldestRecordIndex == entries.lastIndex) 0 else oldestRecordIndex + 1 + } + count -= chunkSize + } + + replay("${BUFFERED_PREFIX}end of buffered logs ($reason)", sessionId) + } + } + + private fun StringBuilder.appendBufferedEntry(entry: FlightRecorderEntry) { + append(BUFFERED_PREFIX) + append(BUFFERED_TIMESTAMP_FORMATTER.format(entry.timestamp)) + append(' ').append(entry.level.name).append(' ') + entry.message.lineSequence().forEachIndexed { index, line -> + if (index > 0) append('\n').append(BUFFERED_PREFIX) + append(line) + } + } + + private fun replay(message: String, sessionId: SessionId?): Boolean { + val level = logger.effectiveLevel.value + if (level == LogLevel.OFF) return false + val text = withSessionId(sessionId, message) + when (level) { + LogLevel.TRACE, LogLevel.DEBUG, LogLevel.INFO -> logger.info(text) + LogLevel.WARN -> logger.warn(text) + LogLevel.ERROR -> logger.error(text) + LogLevel.OFF -> return false + } + return true + } + private fun record(level: LogLevel, timestamp: Instant, message: String, exception: Throwable?) { val text = if (exception == null) message else "$message\n${exception.stackTraceToString()}" val entry = FlightRecorderEntry(level, timestamp, text) diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt new file mode 100644 index 0000000..eb1ca36 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -0,0 +1,794 @@ +package com.coder.toolbox.diagnostics + +import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import java.time.Instant +import java.time.temporal.ChronoUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CoderLoggerFlightRecorderTest { + private val logLevel = MutableStateFlow(LogLevel.INFO) + private val bufferSize = MutableStateFlow(1000) + private val calls = mutableListOf() + private val delegate = mockk(relaxed = true) { + every { effectiveLevel } returns logLevel + every { isEnabled(any()) } answers { enabled(firstArg()) } + every { trace(any()) } answers { emit(LogLevel.TRACE, firstArg()) } + every { debug(any()) } answers { emit(LogLevel.DEBUG, firstArg()) } + every { info(any()) } answers { emit(LogLevel.INFO, firstArg()) } + every { warn(any()) } answers { emit(LogLevel.WARN, firstArg()) } + every { error(any()) } answers { emit(LogLevel.ERROR, firstArg()) } + every { trace(any<() -> String>()) } answers { emitLazy(LogLevel.TRACE, firstArg()) } + every { debug(any<() -> String>()) } answers { emitLazy(LogLevel.DEBUG, firstArg()) } + every { info(any<() -> String>()) } answers { emitLazy(LogLevel.INFO, firstArg()) } + every { warn(any<() -> String>()) } answers { emitLazy(LogLevel.WARN, firstArg()) } + every { error(any<() -> String>()) } answers { emitLazy(LogLevel.ERROR, firstArg()) } + } + private val settings = mockk { + every { connectionLogBufferSize } answers { bufferSize.value } + every { observe(any<(ReadOnlyCoderSettings) -> Int>()) } returns bufferSize + } + private val ui = mockk(relaxed = true) + private val i18n = mockk(relaxed = true) + + @Test + fun `flush replays 250 entries oldest first in five calls and drains the buffer`() = runTest { + // given + bufferSize.value = 300 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(250) { logger.debug("entry-$it") } + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(5, calls.size) + assertEquals("[buffered] replaying 250 buffered entries (support_bundle)", calls.first().message) + assertEquals("[buffered] end of buffered logs (support_bundle)", calls.last().message) + val chunks = calls.subList(1, 4) + assertEquals(listOf(100, 100, 50), chunks.map { it.message.lines().size }) + assertEquals( + List(250) { "entry-$it" }, + chunks.flatMap { it.message.lines() }.map { it.substringAfter(" DEBUG ") }, + ) + assertTrue(calls.all { it.level == LogLevel.INFO }) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `capacity limits recording to the newest entries and the emptied buffer can be reused`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(5) { logger.debug("entry-$it") } + + // when + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 3 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-2", "entry-3", "entry-4"), replayedDebugMessages()) + + // when + calls.clear() + logger.debug("after flush") + logger.flush("support_bundle") + + // then + assertEquals(listOf("after flush"), replayedDebugMessages()) + } + + @Test + fun `shrinking before a flush drops older entries and enforces the new capacity`() = runTest { + // given + bufferSize.value = 5 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(7) { logger.debug("entry-$it") } + + // when + bufferSize.value = 2 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 2 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-5", "entry-6"), replayedDebugMessages()) + + // when + calls.clear() + repeat(4) { logger.debug("after shrinking-$it") } + logger.flush("smaller capacity overflow") + + // then + assertEquals(listOf("after shrinking-2", "after shrinking-3"), replayedDebugMessages()) + } + + @Test + fun `shrinking a partially filled buffer preserves entries that still fit`() = runTest { + // given + bufferSize.value = 5 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("first") + logger.debug("second") + + // when + bufferSize.value = 3 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals(listOf("first", "second"), replayedDebugMessages()) + } + + @Test + fun `growing a wrapped buffer immediately before flushing preserves all retained entries`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(5) { logger.debug("entry-$it") } + + // when + bufferSize.value = 5 + runCurrent() + logger.flush("remote_closed") + + // then + assertEquals("[buffered] replaying 3 buffered entries (remote_closed)", calls.first().message) + assertEquals(listOf("entry-2", "entry-3", "entry-4"), replayedDebugMessages()) + } + + @Test + fun `growing capacity changes the recording limit from X to Y`() = runTest { + // given + bufferSize.value = 3 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + repeat(5) { logger.debug("original-$it") } + logger.flush("original capacity") + + // then + assertEquals(listOf("original-2", "original-3", "original-4"), replayedDebugMessages()) + + // when + calls.clear() + logger.debug("retained before growing") + bufferSize.value = 5 + runCurrent() + repeat(4) { logger.debug("grown-$it") } + logger.flush("larger capacity") + + // then + assertEquals( + listOf("retained before growing", "grown-0", "grown-1", "grown-2", "grown-3"), + replayedDebugMessages(), + ) + + // when + calls.clear() + repeat(7) { logger.debug("overflow-$it") } + logger.flush("larger capacity overflow") + + // then + assertEquals( + listOf("overflow-2", "overflow-3", "overflow-4", "overflow-5", "overflow-6"), + replayedDebugMessages(), + ) + } + + @Test + fun `batching handles entries below at and above each 100 entry boundary`() = runTest { + listOf( + 1 to listOf(1), + 99 to listOf(99), + 100 to listOf(100), + 101 to listOf(100, 1), + 200 to listOf(100, 100), + 201 to listOf(100, 100, 1), + ).forEach { (entryCount, expectedChunkSizes) -> + // given + calls.clear() + bufferSize.value = entryCount + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(entryCount) { logger.debug("entry-$it") } + + // when + logger.flush("support_bundle") + + // then + assertEquals(expectedChunkSizes.size + 2, calls.size, "Calls for $entryCount entries") + assertEquals( + expectedChunkSizes, + calls.drop(1).dropLast(1).map { it.message.lines().size }, + "Chunk sizes for $entryCount entries", + ) + } + } + + @Test + fun `buffered block contains the exact header entry metadata and footer`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("Connecting") + logger.trace("Retrying") + + // when + logger.flush("unreachable /route") + + // then + assertEquals(3, calls.size) + assertEquals("[buffered] replaying 2 buffered entries (unreachable /route)", calls[0].message) + val timestamp = """\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z""" + assertTrue( + Regex("""\[buffered\] $timestamp DEBUG Connecting\n\[buffered\] $timestamp TRACE Retrying""") + .matches(calls[1].message), + calls[1].message, + ) + assertEquals("[buffered] end of buffered logs (unreachable /route)", calls[2].message) + assertEquals(List(3) { LogLevel.INFO }, calls.map { it.level }) + } + + @Test + fun `error logs normally at ERROR while warn info debug and trace replay as error`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE), LogLevel.ERROR) + } + + @Test + fun `error and warn log normally at WARN while info debug and trace replay as warn`() = runTest { + // given + logLevel.value = LogLevel.WARN + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE), LogLevel.WARN) + } + + @Test + fun `error warn and info log normally at INFO while debug and trace replay as info`() = runTest { + // given + logLevel.value = LogLevel.INFO + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.DEBUG, LogLevel.TRACE), LogLevel.INFO) + } + + @Test + fun `error warn info and debug log normally at DEBUG while trace replays as info`() = runTest { + // given + logLevel.value = LogLevel.DEBUG + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(listOf(LogLevel.TRACE), LogLevel.INFO) + } + + @Test + fun `every level logs normally at TRACE and flush emits nothing`() = runTest { + // given + logLevel.value = LogLevel.TRACE + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertReplayedLevels(emptyList(), LogLevel.INFO) + } + + @Test + fun `flush selects the active replay method and preserves original levels within a chunk`() = runTest { + mapOf( + LogLevel.TRACE to LogLevel.INFO, + LogLevel.DEBUG to LogLevel.INFO, + LogLevel.INFO to LogLevel.INFO, + LogLevel.WARN to LogLevel.WARN, + LogLevel.ERROR to LogLevel.ERROR, + ).forEach { (activeLevel, replayLevel) -> + // given + calls.clear() + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.trace("trace") + logger.debug("debug") + logger.info("info") + logger.warn("warning") + + // when + logLevel.value = activeLevel + logger.flush("unreachable /route") + + // then + assertEquals(List(3) { replayLevel }, calls.map { it.level }) + assertEquals( + listOf("TRACE trace", "DEBUG debug", "INFO info", "WARN warning"), + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + } + } + + @Test + fun `replay keeps original UTC timestamps and prefixes continuation lines and session calls`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val sessionId = SessionId.generate() + val before = Instant.now().truncatedTo(ChronoUnit.MILLIS) + logger.trace("first\r\nsecond\rthird") + logger.debug(IllegalStateException("failure"), "failed") + val after = Instant.now().truncatedTo(ChronoUnit.MILLIS) + + // when + logger.flush("certificate_error /route", sessionId) + + // then + val sessionPrefix = "client_session_id=$sessionId " + assertEquals( + "${sessionPrefix}[buffered] replaying 2 buffered entries (certificate_error /route)", + calls.first().message, + ) + assertEquals( + "${sessionPrefix}[buffered] end of buffered logs (certificate_error /route)", + calls.last().message, + ) + val lines = calls[1].message.removePrefix(sessionPrefix).lines() + val firstLine = assertNotNull( + Regex("""\[buffered\] (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) TRACE first""") + .matchEntire(lines.first()), + ) + assertTrue(Instant.parse(firstLine.groupValues[1]) in before..after) + assertEquals("[buffered] second", lines[1]) + assertEquals("[buffered] third", lines[2]) + assertTrue(lines[3].endsWith("Z DEBUG failed")) + assertEquals("[buffered] java.lang.IllegalStateException: failure", lines[4]) + assertTrue(lines.drop(1).all { it.startsWith("[buffered] ") }) + } + + @Test + fun `initial OFF records no strings or lambdas and flush makes no logging calls`() = runTest { + // given + logLevel.value = LogLevel.OFF + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logAtEveryLevel(logger) + logLazyAtEveryLevel(logger) { level -> evaluations++; "${level.name} message" } + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + clearMocks(delegate, answers = false, recordedCalls = true) + logger.flush("support_bundle") + + // then + assertNoLoggingCalls(delegate) + + // when + logLevel.value = LogLevel.INFO + logger.flush("after enabling logging") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `initial zero capacity skips recording and flush makes no logging calls`() = runTest { + // given + bufferSize.value = 0 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logAtEveryLevel(logger) + + // then + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO).map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + var evaluations = 0 + logger.debug { evaluations++; "ignored debug" } + logger.trace { evaluations++; "ignored trace" } + + // then + assertEquals(0, evaluations) + + // when + calls.clear() + clearMocks(delegate, answers = false, recordedCalls = true) + logger.flush("support_bundle") + + // then + assertNoLoggingCalls(delegate) + + // when + bufferSize.value = 3 + runCurrent() + logger.flush("after enabling recording") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `flushing an empty recorder makes no logging calls`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + + // when + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + assertNoLoggingCalls(delegate) + } + + @Test + fun `off skips capture and flushing without discarding existing records`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("before off") + var evaluations = 0 + + // when + logLevel.value = LogLevel.OFF + logger.debug("ignored string") + logger.debug { evaluations++; "ignored lambda" } + logger.flush("remote_closed") + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + logLevel.value = LogLevel.INFO + logger.flush("support_bundle") + + // then + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG before off")) + } + + @Test + fun `zero capacity clears records and recording resumes after increasing capacity`() = runTest { + // given + bufferSize.value = 2 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("discarded") + var evaluations = 0 + + // when + bufferSize.value = 0 + runCurrent() + logger.debug { evaluations++; "disabled" } + logger.flush("support_bundle") + + // then + assertEquals(0, evaluations) + assertTrue(calls.isEmpty()) + + // when + bufferSize.value = 2 + runCurrent() + logger.debug("after enabling") + logger.flush("support_bundle") + + // then + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG after enabling")) + } + + @Test + fun `failed replay leaves the unwritten chunk available for another flush`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + logger.debug("retained") + every { delegate.info(match { it.contains(" DEBUG ") }) } throws + IllegalStateException("log write failed") + + // when / then + assertFailsWith { logger.flush("first attempt") } + + // given + calls.clear() + every { delegate.info(any()) } answers { + calls += LogCall(LogLevel.INFO, firstArg()) + } + + // when + logger.flush("second attempt") + + // then + assertEquals("[buffered] replaying 1 buffered entries (second attempt)", calls.first().message) + assertTrue(calls[1].message.endsWith("DEBUG retained")) + } + + @Test + fun `turning logging off during a flush retains the remaining chunks`() = runTest { + // given + bufferSize.value = 300 + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + repeat(250) { logger.debug("entry-$it") } + every { delegate.info(any()) } answers { + val message = firstArg() + calls += LogCall(LogLevel.INFO, message) + if (message.contains(" DEBUG ")) logLevel.value = LogLevel.OFF + } + + // when + logger.flush("remote_closed") + + // then + assertEquals(2, calls.size) + assertEquals(100, calls[1].message.lines().size) + + // given + calls.clear() + logLevel.value = LogLevel.INFO + every { delegate.info(any()) } answers { + calls += LogCall(LogLevel.INFO, firstArg()) + } + + // when + logger.flush("support_bundle") + + // then + assertEquals(4, calls.size) + assertEquals("[buffered] replaying 150 buffered entries (support_bundle)", calls.first().message) + assertTrue(calls[1].message.lines().first().endsWith("DEBUG entry-100")) + } + + @Test + fun `captured lambda is evaluated once even if Toolbox enables its level before delegation`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logger.debug { + evaluations++ + logLevel.value = LogLevel.DEBUG + "lazy message" + } + + // then + assertEquals(listOf(LogCall(LogLevel.DEBUG, "lazy message")), calls) + + // when + calls.clear() + logLevel.value = LogLevel.INFO + logger.flush("support_bundle") + + // then + assertEquals(1, evaluations) + assertTrue(calls[1].message.endsWith("DEBUG lazy message")) + } + + @Test + fun `enabled lambdas log normally once and do not enter the recorder`() = runTest { + // given + logLevel.value = LogLevel.TRACE + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logLazyAtEveryLevel(logger) { level -> evaluations++; "${level.name} message" } + + // then + assertEquals(5, evaluations) + assertEquals( + listOf(LogLevel.ERROR, LogLevel.WARN, LogLevel.INFO, LogLevel.DEBUG, LogLevel.TRACE) + .map { LogCall(it, "${it.name} message") }, + calls, + ) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertTrue(calls.isEmpty()) + } + + @Test + fun `filtered lambdas are evaluated once and replay with their original levels`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + + // when + logger.warn { evaluations++; "warning" } + logger.info { evaluations++; "information" } + logger.debug { evaluations++; "debugging" } + logger.trace { evaluations++; "tracing" } + + // then + assertEquals(4, evaluations) + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(4, evaluations) + assertEquals(List(3) { LogLevel.ERROR }, calls.map { it.level }) + assertEquals( + listOf("WARN warning", "INFO information", "DEBUG debugging", "TRACE tracing"), + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + } + + @Test + fun `filtered exception lambda replays its message and exception without evaluating again`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + var evaluations = 0 + logger.debug(IllegalStateException("failure")) { evaluations++; "failed request" } + assertTrue(calls.isEmpty()) + + // when + logger.flush("support_bundle") + + // then + assertEquals(1, evaluations) + val lines = calls[1].message.lines() + assertTrue(lines.first().endsWith("Z DEBUG failed request")) + assertEquals("[buffered] java.lang.IllegalStateException: failure", lines[1]) + assertTrue(lines.all { it.startsWith("[buffered] ") }) + } + + private fun assertReplayedLevels(bufferedLevels: List, replayLevel: LogLevel) { + if (bufferedLevels.isEmpty()) { + assertTrue(calls.isEmpty()) + } else { + assertEquals(List(3) { replayLevel }, calls.map { it.level }) + assertEquals( + "[buffered] replaying ${bufferedLevels.size} buffered entries (support_bundle)", + calls.first().message, + ) + assertEquals( + bufferedLevels.map { "${it.name} ${it.name} message" }, + calls[1].message.lines().map { it.substringAfter("Z ") }, + ) + assertEquals("[buffered] end of buffered logs (support_bundle)", calls.last().message) + } + } + + private fun logAtEveryLevel(logger: CoderLogger) { + logger.error("ERROR message") + logger.warn("WARN message") + logger.info("INFO message") + logger.debug("DEBUG message") + logger.trace("TRACE message") + } + + private fun logLazyAtEveryLevel(logger: CoderLogger, message: (LogLevel) -> String) { + logger.error { message(LogLevel.ERROR) } + logger.warn { message(LogLevel.WARN) } + logger.info { message(LogLevel.INFO) } + logger.debug { message(LogLevel.DEBUG) } + logger.trace { message(LogLevel.TRACE) } + } + + private fun replayedDebugMessages(): List = + calls.drop(1).dropLast(1).flatMap { it.message.lines() }.map { it.substringAfter(" DEBUG ") } + + private fun assertNoLoggingCalls(logger: Logger) { + verify(exactly = 0) { + logger.error(any()) + logger.warn(any()) + logger.info(any()) + logger.debug(any()) + logger.trace(any()) + } + } + + private data class LogCall(val level: LogLevel, val message: String) + + private fun enabled(level: LogLevel): Boolean = logLevel.value != LogLevel.OFF && level >= logLevel.value + + private fun emit(level: LogLevel, message: String) { + if (enabled(level)) calls += LogCall(level, message) + } + + private fun emitLazy(level: LogLevel, message: () -> String) { + if (enabled(level)) calls += LogCall(level, message()) + } +} From fd2d4fc896cc4721d53954571de95dacb925d369 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 21:22:21 +0300 Subject: [PATCH 08/17] Flush buffered logs at connection lifecycle events Connection lifecycle messages do not replay the flight recorder, so the Toolbox log can omit the suppressed context leading up to a disconnect or connection-affecting failure. The remote environment and provider now flush after the selected lifecycle messages. The REST client, workspace filter, and action failure handler also flush at their corresponding triggers. Each flush follows its log message so filtered trigger messages appear in the replay, and environment-specific flushes retain the available session ID. Replay tests cover action failure context and filtered status updates through CoderLogger. The REST tests configure the Toolbox logger's effective level and filter explicitly so the new flush calls use realistic logging behavior. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 8 ++ .../com/coder/toolbox/CoderRemoteProvider.kt | 5 ++ .../com/coder/toolbox/sdk/CoderRestClient.kt | 3 + .../com/coder/toolbox/views/CoderPage.kt | 4 +- .../coder/toolbox/views/NewEnvironmentPage.kt | 1 + .../CoderLoggerFlightRecorderTest.kt | 84 +++++++++++++++++++ .../coder/toolbox/sdk/CoderRestClientTest.kt | 8 +- 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index f79a990..eaf0b2c 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -240,6 +240,7 @@ class CoderRemoteEnvironment( Action(context, "Update and restart") { withProgress("Updating and restarting workspace…") { context.logger.debug(currentSessionId(), "Updating and re-starting $id...") + context.logger.flush("workspace_restarting $id", currentSessionId()) val build = client.updateWorkspace(workspace) applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) @@ -252,6 +253,7 @@ class CoderRemoteEnvironment( withProgress("Stopping workspace…") { tryStopSshConnection() context.logger.debug(currentSessionId(), "Stopping $id...") + context.logger.flush("workspace_stopping $id", currentSessionId()) val build = client.stopWorkspace(workspace) applyWorkspaceSnapshot(workspace.copy(latestBuild = build), agent) workspaceRefreshTrigger.trySend(true) @@ -279,6 +281,7 @@ class CoderRemoteEnvironment( if (confirmation == workspace.name) { withProgress("Deleting workspace…") { context.logger.debug(currentSessionId(), "Deleting $id...") + context.logger.flush("workspace_deleting $id", currentSessionId()) deleteWorkspace() } } @@ -301,6 +304,7 @@ class CoderRemoteEnvironment( "The SSH connection to workspace $name could not be dropped in time, " + "going to stop the workspace while the SSH connection is live" context.logger.warn(currentSessionId(), message) + context.logger.flush("ssh_disconnect_timeout $id", currentSessionId()) } } } @@ -386,6 +390,7 @@ class CoderRemoteEnvironment( } removedSessionId?.let { sessionId -> context.logger.info(sessionId, "Removed Toolbox SSH session for $id") + context.logger.flush("ssh_session_removed $id", sessionId) } } @@ -412,6 +417,7 @@ class CoderRemoteEnvironment( "Toolbox is disconnecting SSH from $id $disconnectKind.$stateInference " + "Latest known Coder state: $latestCoderState", ) + context.logger.flush("ssh_disconnected $id", sessionId) context.logger.info(sessionId, "Stopping the network metrics poll job for $id") pollJob?.cancel() pollJob = null @@ -425,6 +431,7 @@ class CoderRemoteEnvironment( SessionIdRegistry.removeSession(workspace.name, it.name) }?.let { context.logger.info(it, "Removed Toolbox SSH session for $id after manual disconnect") + context.logger.flush("ssh_session_removed_after_manual_disconnect $id", it) } } } @@ -499,6 +506,7 @@ class CoderRemoteEnvironment( "agent lifecycle state: ${previousAgent?.lifecycleState} -> ${agent?.lifecycleState}, " + "login before ready: ${previousAgent?.loginBeforeReady} -> ${agent?.loginBeforeReady}" context.logger.info(sessionId, message) + context.logger.flush("environment_status_changed $id", sessionId) } /** diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index e1cd454..8ea7352 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -158,6 +158,7 @@ class CoderRemoteProvider( sessionIds, "Workspaces have changed, reconfiguring CLI: $resolvedEnvironments", ) + context.logger.flush("workspaces_changed") configureSsh(cli, resolvedEnvironments) } @@ -195,6 +196,7 @@ class CoderRemoteProvider( "Your Coder session has expired. Please re-authenticate and try again.", ex ) + context.logger.flush("coder_session_expired") break } context.logger.error(sessionIds, ex, "workspace polling error encountered") @@ -359,6 +361,7 @@ class CoderRemoteProvider( val activeClient = client val activeCli = cli context.logger.info(sessionIds, "Logging out ${activeClient?.me?.username}...") + context.logger.flush("logout") try { pollJob?.cancelAndJoin() activeClient?.close() @@ -376,6 +379,7 @@ class CoderRemoteProvider( "Sign in and try logging out again to remove it.", ex, ) + context.logger.flush("cli_logout_failed") } finally { close() } @@ -422,6 +426,7 @@ class CoderRemoteProvider( accountDropdownField.visibility.update { false } router.clear() context.logger.info(sessionIds, "Coder plugin is now closed") + context.logger.flush("provider_closed") } private fun softClose() { diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index b70fcc9..2c1c3c8 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -417,6 +417,7 @@ open class CoderRestClient( private fun reportInvalidDeploymentData(ex: InvalidCoderIdentifierException) { if (isInvalidDeploymentDataWarningShown) { context.logger.warn(ex, "Ignoring invalid deployment-controlled identifiers") + context.logger.flush("invalid_deployment_data") return } @@ -426,6 +427,7 @@ open class CoderRestClient( INVALID_DEPLOYMENT_DATA_WARNING, ex, ) + context.logger.flush("invalid_deployment_data") } /** @@ -451,6 +453,7 @@ open class CoderRestClient( true } catch (e: Exception) { context.logger.error(e, "Failed to refresh access token") + context.logger.flush("token_refresh_failed") // propagate the exception to the main workspace polling loop throw e } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt index 68e5517..3ec6500 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderPage.kt @@ -92,12 +92,14 @@ class Action( ex.reason } else ex.message } else ex.message + val sessionId = currentSessionId() context.logger.logAndShowError( - currentSessionId(), + sessionId, "Error while running `$description`", textError ?: "", ex, ) + context.logger.flush("action_failed $description", sessionId) } } } diff --git a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt index af881d7..732cb4f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/NewEnvironmentPage.kt @@ -127,6 +127,7 @@ class NewEnvironmentPage( mutableWorkspaceSearchQuery.value = query workspaceRefreshTrigger.send(true) context.logger.info("Workspace filter changed, refreshing workspaces...") + context.logger.flush("workspace_filter_changed") } } // 2. Reflect the query back into the dropdowns diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt index eb1ca36..3010066 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -1,7 +1,13 @@ package com.coder.toolbox.diagnostics +import com.coder.toolbox.CoderRemoteEnvironment +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.sdk.DataGen +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.session.SessionId +import com.coder.toolbox.session.SessionIdRegistry import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.coder.toolbox.views.Action import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.localization.LocalizableStringFactory @@ -11,11 +17,13 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -734,6 +742,82 @@ class CoderLoggerFlightRecorderTest { assertTrue(lines.all { it.startsWith("[buffered] ") }) } + @Test + fun `action failure replays the preceding context with its current session`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns this + val sessionId = SessionId.generate() + val failure = IllegalStateException("stop failed") + val action = Action(context, "Stop workspace") { throw failure } + .withCurrentSessionId { sessionId } + logger.debug(sessionId, "before stopping") + + // when + action.run() + runCurrent() + + // then + verify(exactly = 1) { delegate.error(failure, "client_session_id=$sessionId stop failed") } + val prefix = "client_session_id=$sessionId " + assertEquals(3, calls.size) + assertEquals( + "${prefix}[buffered] replaying 1 buffered entries (action_failed Stop workspace)", + calls.first().message, + ) + assertTrue(calls[1].message.endsWith("DEBUG ${prefix}before stopping")) + assertEquals( + "${prefix}[buffered] end of buffered logs (action_failed Stop workspace)", + calls.last().message, + ) + } + + @Test + fun `environment status update replays context and the filtered trigger through CoderLogger`() = runTest { + // given + logLevel.value = LogLevel.ERROR + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ) + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + val sessionId = SessionIdRegistry.startSession(context, workspace.name, agent.name) + try { + logger.flush("setup") + calls.clear() + logger.debug(sessionId, "before status update") + + // when + environment.update( + workspace.copy(latestBuild = workspace.latestBuild.copy(status = WorkspaceStatus.STOPPING)), + agent, + ) + + // then + val prefix = "client_session_id=$sessionId " + val reason = "environment_status_changed ${environment.id}" + assertEquals(List(3) { LogLevel.ERROR }, calls.map { it.level }) + assertEquals("${prefix}[buffered] replaying 2 buffered entries ($reason)", calls.first().message) + val lines = calls[1].message.lines() + assertTrue(lines[0].endsWith("DEBUG ${prefix}before status update")) + assertTrue(lines[1].contains("INFO ${prefix}Overall status for workspace ${environment.id}")) + assertEquals("${prefix}[buffered] end of buffered logs ($reason)", calls.last().message) + } finally { + environment.dispose() + SessionIdRegistry.removeSession(workspace.name, agent.name) + } + } + private fun assertReplayedLevels(bufferedLevels: List, replayLevel: LogLevel) { if (bufferedLevels.isEmpty()) { assertTrue(calls.isEmpty()) diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index d1ebda5..563af93 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -23,6 +23,7 @@ import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager import com.jetbrains.toolbox.api.localization.LocalizableString @@ -43,9 +44,11 @@ import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpsConfigurator import com.sun.net.httpserver.HttpsServer import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import okio.buffer import okio.source @@ -103,7 +106,10 @@ internal class BaseHttpHandler( class CoderRestClientTest { private val ui = mockk(relaxed = true) - private val logger = mockk(relaxed = true) + private val logger = mockk(relaxed = true) { + every { effectiveLevel } returns MutableStateFlow(LogLevel.INFO) + every { isEnabled(any()) } answers { firstArg() >= LogLevel.INFO } + } private val moshi = Moshi.Builder() .add(InstantConverter()) From 158cfc7f194c3f24692dd4c2ea93f55992672935 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 21:52:02 +0300 Subject: [PATCH 09/17] Flush buffered logs before support bundle collection Support bundle export leaves suppressed log entries in memory, so exported Toolbox logs can miss the context recorded before collection. CoderSupportBundleCollector now flushes the flight recorder through CoderLogger before collecting diagnostics. The replay uses the support bundle reason and current session ID to preserve the connection context in the log. The regression test exercises a real CoderLogger and verifies that buffered entries are replayed before bundle collection and removed afterwards. --- .../CoderSupportBundleCollector.kt | 1 + .../CoderSupportBundleCollectorTest.kt | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt index 66a0e8f..248fed8 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollector.kt @@ -24,6 +24,7 @@ internal class CoderSupportBundleCollector( override suspend fun collectAdditionalDiagnostics(logsRootFolder: Path) = withContext(Dispatchers.IO) { val sessionId = currentSessionId() try { + logger.flush("support_bundle", sessionId) Files.createDirectories(logsRootFolder) collectSupportBundle(logsRootFolder, sessionId) // Without a usable support bundle the SSH logs still go into an archive of their own. diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt index dcf6efe..869e37d 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderSupportBundleCollectorTest.kt @@ -1,11 +1,15 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.session.SessionId +import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.jetbrains.toolbox.api.core.diagnostics.LogLevel import com.jetbrains.toolbox.api.core.diagnostics.Logger import io.mockk.confirmVerified +import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.io.TempDir import java.nio.file.Files @@ -23,6 +27,54 @@ class CoderSupportBundleCollectorTest { @TempDir lateinit var root: Path + @Test + fun `support export replays buffered logs before collecting the bundle and drains the recorder`() = runTest { + // given + val messages = mutableListOf() + val delegate = mockk(relaxed = true) { + every { effectiveLevel } returns MutableStateFlow(LogLevel.INFO) + every { isEnabled(any()) } answers { firstArg() >= LogLevel.INFO } + every { info(any()) } answers { messages += firstArg() } + } + val bufferSize = MutableStateFlow(2) + val settings = mockk { + every { connectionLogBufferSize } returns bufferSize.value + every { observe(any<(ReadOnlyCoderSettings) -> Int>()) } returns bufferSize + } + val logger = CoderLogger(delegate, mockk(), backgroundScope, mockk(), settings) + val sessionId = SessionId.generate() + var messagesAtCollection: List? = null + val collector = CoderSupportBundleCollector( + logger, root.resolve("ssh-logs"), currentSessionId = { sessionId }, + ) { + messagesAtCollection = messages.toList() + writeBundle(it) + } + logger.debug("before export") + logger.trace("connection details") + + // when + collector.collectAdditionalDiagnostics(root.resolve("diagnostics")) + + // then + val prefix = "client_session_id=$sessionId " + assertEquals(3, messages.size) + assertEquals(messages, messagesAtCollection) + assertEquals("${prefix}[buffered] replaying 2 buffered entries (support_bundle)", messages.first()) + val lines = messages[1].lines() + assertTrue(lines[0].endsWith("DEBUG before export")) + assertTrue(lines[1].endsWith("TRACE connection details")) + assertEquals("${prefix}[buffered] end of buffered logs (support_bundle)", messages.last()) + assertTrue(Files.exists(root.resolve("diagnostics/coder-support.zip"))) + + // when + messages.clear() + logger.flush("after_export") + + // then + assertTrue(messages.isEmpty()) + } + @Test fun `unavailable diagnostics directory stops collection and logs one warning`() = runTest { val destination = Files.writeString(root.resolve("diagnostics"), "existing file") From a296a65d3b84e89fbcba6d4adb11cba363aeac45 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 22:24:21 +0300 Subject: [PATCH 10/17] docs: Explain flight recorder capture and replay The README describes the buffer limit but does not explain when recorded logs are replayed or where to find them, leaving connection diagnostics difficult to collect and interpret. The flight recorder documentation now covers configurable capacity, live resizing, log levels, replay triggers and formatting, and Toolbox log locations. The settings instructions identify the action menu in the Coder page header. The collection guidance also explains that per-environment log collection requests a recorder flush and Coder support bundle, while general Toolbox diagnostics collection does not explicitly request either. --- README.md | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e732fc4..17b5415 100644 --- a/README.md +++ b/README.md @@ -348,13 +348,122 @@ error. ## Debugging and Reporting issues -Enabling debug logging is essential for diagnosing issues with the Toolbox plugin, especially when SSH connections to -the remote environment fail — it provides detailed output that includes SSH negotiation and command execution, which is -not visible at the default log level. +The plugin's flight recorder keeps recent messages below Toolbox's log level in memory and replays them at connection +diagnostic checkpoints and when collecting a Coder support bundle. This can preserve the context leading up to an issue +without enabling debug logging beforehand. Enable debug logging when you need continuous detailed output, including +messages from Toolbox itself. If you encounter a problem with Coder's JetBrains Toolbox plugin, follow the steps below to gather more information and help us diagnose and resolve it quickly. +### Flight recorder + +The flight recorder is enabled by default with a capacity of **1000 log entries** from the Coder plugin logger. You can +configure the capacity up to **10000 entries** using **Connection log buffer size**. Messages that Toolbox accepts at +its current log level are logged normally; messages it filters out are captured in a fixed capacity array ring buffer. +When the buffer fills, each new entry replaces the oldest entry. + +Each entry preserves its original log level, UTC timestamp, message text, and exception details when present. The limit +counts entries, not lines or bytes: a multiline message or a message with a stack trace occupies one entry, so memory +usage depends on message size. Capture covers messages sent through the Coder plugin logger; CLI SSH file logs are +collected separately as described in [Coder Support Bundles](#coder-support-bundles). + +There is one shared buffer per running plugin instance, so a replay can contain context from several workspaces. +Messages retain any `client_session_id` recorded with them. Environment-specific replays also use the triggering +environment's current session ID; provider-wide replays do not add a session prefix. The buffer remains in memory until +replayed or discarded. Entries that have not been replayed are lost when the plugin instance ends or Toolbox exits. + +#### Configuring the buffer + +Open the **Coder main page** (the page listing your environments), click the **action menu on the right side of the +page header**, and select **Settings**. Under **General**, change **Connection log buffer size** and click **Save**. + +| Setting | Plugin settings key | Default | Valid values | +|----------------------------|---------------------------|----------------|----------------------------------------------| +| Connection log buffer size | `connectionLogBufferSize` | `1000` entries | Whole numbers from `0` to `10000`, inclusive | + +Changes take effect without restarting Toolbox: + +- Reducing the capacity keeps the newest entries that fit and discards older entries. +- Increasing the capacity preserves all retained entries and allows more entries to be recorded. +- Setting the capacity to `0` clears the buffer and disables recording and replay. Normal Toolbox logging continues. +- The settings page rejects invalid input. An absent, malformed, or out-of-range stored value falls back to `1000`. + +#### Toolbox log levels and replay levels + +The recorder follows Toolbox's effective log level, including `--debug` and `--trace` launch flags. Changes to the +Toolbox log level apply live. Each replay call uses a level Toolbox currently persists, while the original level of +each entry stays in the message text. + +| Toolbox effective level | Logged normally | New entries captured in memory | Replay level | +|-------------------------|--------------------------|--------------------------------|-------------------------------------------------| +| ERROR | ERROR | WARN, INFO, DEBUG, TRACE | ERROR | +| WARN | ERROR, WARN | INFO, DEBUG, TRACE | WARN | +| INFO | ERROR, WARN, INFO | DEBUG, TRACE | INFO | +| DEBUG | ERROR, WARN, INFO, DEBUG | TRACE | INFO | +| TRACE | All levels | None | INFO for entries retained from an earlier level | +| OFF | None | None | No replay | + +With Toolbox logging set to **OFF**, neither recording nor replay runs. Existing buffered entries remain available +after logging is re-enabled, unless the capacity is set to `0` or the plugin instance ends in the meantime. + +The plugin's **HTTP logging level** is independent of this setting. For example, the recorder cannot capture HTTP +headers or bodies when the HTTP logging setting does not generate those messages. See +[HTTP Request Logging](#http-request-logging). + +#### Automatic replay triggers + +The following events flush the recorder. The reason appears in the replay header and footer; `` is the environment +ID, normally `workspace.agent`. + +| Event | Replay reason | +|------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| +| Workspace or agent status is published | `environment_status_changed ` | +| The resolved workspace/environment list changes | `workspaces_changed` | +| The workspace filter changes | `workspace_filter_changed` | +| A workspace restart, stop, or deletion is requested | `workspace_restarting `, `workspace_stopping `, `workspace_deleting ` | +| An SSH disconnect request times out before workspace stop | `ssh_disconnect_timeout ` | +| Toolbox invokes the SSH disconnect callback | `ssh_disconnected ` | +| An environment's SSH session is removed during disposal or after manual disconnect | `ssh_session_removed `, `ssh_session_removed_after_manual_disconnect ` | +| Logout begins, CLI logout fails, or the provider closes | `logout`, `cli_logout_failed`, `provider_closed` | +| The Coder session expires or access token refresh fails | `coder_session_expired`, `token_refresh_failed` | +| Unsafe deployment-controlled workspace, owner, or agent identifiers are reported | `invalid_deployment_data` | +| A workspace/deployment action fails | `action_failed ` | +| Workspace log collection starts Coder support bundle export | `support_bundle` | + +Lifecycle flushes run immediately after their triggering log message, so that message is included if Toolbox filters +it. Support bundle export flushes before collecting the bundle. These checkpoints also include normal state changes +and user actions; a replay block alone does not mean that a connection failed. + +#### Reading replayed logs + +A flush replays entries from oldest to newest and removes each batch after it is logged. An empty buffer produces no +header, entries, or footer. A completed flush makes one call for the header, one call per chunk of up to **100 +entries**, +and one call for the footer. For example, 250 entries produce five Toolbox log calls. + +Example message text, before Toolbox adds its own timestamp and metadata: + +```text +[buffered] replaying 2 buffered entries (support_bundle) +[buffered] 2026-10-22T06:10:01.002Z DEBUG Connecting to demo.agent +[buffered] retry scheduled +[buffered] 2026-10-22T06:10:03.415Z TRACE Waiting for agent +[buffered] end of buffered logs (support_bundle) +``` + +The `retry scheduled` line is a continuation of the first entry. Every continuation line gets a bare `[buffered] ` +prefix. Entry timestamps use ISO 8601 in UTC with millisecond precision and show when the message was originally +logged. Toolbox adds its own timestamp for the replay time, which can use a different time zone. + +Each chunk is one multiline Toolbox record. Only its first line receives Toolbox's usual timestamp, severity, and +other metadata; subsequent lines contain the buffered message text. Original severity labels such as `DEBUG` and +`TRACE` therefore remain visible even when the Toolbox record itself is logged as INFO, WARN, or ERROR. + +Capture and export are best-effort because Toolbox schedules its own log writes and the effective level can change +between recording and writing a message. Successfully replayed entries are not repeated by later flushes. If logging +is switched to OFF during replay, the remaining entries stay buffered until another flush can run. + ### Enable Debug Logging To help with troubleshooting or to gain more insight into the behavior of the plugin and the SSH connection to the @@ -375,10 +484,23 @@ There is no need to restart Toolbox, as it will begin logging at the __DEBUG__ l > [!WARNING] > Toolbox does not persist log level configuration between restarts. -#### Viewing the Logs +### Viewing the Logs + +Normal plugin logs and replayed flight recorder entries are written through Toolbox's logger into the **Toolbox App +log files**. Search for `[buffered]` to find replay blocks and their reasons. The recorder does not create a separate +log file, and the **SSH proxy log directory** and plugin **Data directory** settings do not change where replayed +entries are written. -Once enabled, debug logs will be written to the Toolbox log files. You can access logs directly via Toolbox App Menu > -About > Show log files. +Open **Toolbox App Menu > Settings > About > Show log files** to locate the files. The standard directories are: + +| Operating system | Toolbox App log directory | +|------------------|------------------------------------------| +| Windows | `%LOCALAPPDATA%\JetBrains\Toolbox\logs\` | +| macOS | `~/Library/Logs/JetBrains/Toolbox/` | +| Linux | `~/.local/share/JetBrains/Toolbox/logs/` | + +These locations are documented in +the [JetBrains Toolbox App FAQ](https://www.jetbrains.com/help/toolbox-app/frequently-asked-questions.html). Alternatively, use the workspace environment's _Collect logs_ action to generate a ZIP file with Toolbox diagnostics and a Coder support bundle. See [Coder Support Bundles](#coder-support-bundles) below for the navigation steps and @@ -403,12 +525,18 @@ To collect logs with a Coder support bundle: > [!NOTE] > The general Toolbox **Settings > About > Collect logs and diagnostic data** action does **not** generate a Coder -> support bundle. +> support bundle or explicitly flush the flight recorder. The **Collect logs** action in an individual environment's +> action menu requests both. Previously replayed entries remain available in the Toolbox App log files. The plugin runs `coder support bundle` using the deployment's existing CLI login and targets the selected workspace and its agent, when available. It places `coder-support.zip` inside the environment's diagnostic directory in the Toolbox log archive. Collection does not start a stopped workspace. +Before running the Coder CLI, the plugin flushes the flight recorder with reason `support_bundle` and the current +session ID when available. Replayed entries go into the Toolbox App logs collected with the diagnostics; the recorder +does not write a separate file into `coder-support.zip`. This replay is requested even if Coder bundle generation +subsequently fails. Keep Toolbox logging enabled when collecting logs, since OFF disables replay. + Recent local CLI SSH logs are added in place to `coder-support.zip` under `toolbox-logs/ssh/`. If the bundle could not be generated or is not a valid archive, the logs are written to a separate `coder-ssh.zip` in the diagnostic directory instead, using the same layout. @@ -462,8 +590,8 @@ issues with Coder deployments. This feature allows you to monitor all HTTP reque You can configure HTTP logging verbosity through the Coder Settings page: -1. Navigate to the Coder Workspaces page -2. Click on the deployment action menu (three dots) +1. Open the Coder main page, which lists your environments +2. Click the action menu (three dots) on the right side of the page header 3. Select "Settings" 4. Find the "HTTP logging level" dropdown @@ -513,7 +641,8 @@ When reporting issues, include HTTP logs to help diagnose: ## Coder Settings The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data storage -paths. The options can be configured from the plugin's main Workspaces page > deployment action menu > Settings. +paths. On the Coder main page (the page listing your environments), click the action menu on the right side of the +page header and select **Settings**. ### Workspace list filtering @@ -566,7 +695,9 @@ explicit entry per resolved workspace/agent using - `Connection log buffer size` (`connectionLogBufferSize`) sets the maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. It defaults to `1000` and accepts whole numbers from `0` - to `10000`; `0` disables buffering. Invalid stored values fall back to `1000`. + to `10000`; `0` clears the buffer and disables buffering. Invalid stored values fall back to `1000`. Saving a new + value resizes the buffer without a restart, keeping the newest entries that fit. See + [Flight recorder](#flight-recorder) for capture, automatic replay, log levels, and output details. ### CLI related settings From 5f5c506238a448a3dd689d515d182e4d53165c11 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 22:35:43 +0300 Subject: [PATCH 11/17] docs: Clarify flight recorder release notes and compatibility The release notes omit the flight recorder and list an outdated Toolbox minimum, while the support bundle requirements do not identify both Coder components. The README and changelog now identify Toolbox 3.9 as the minimum supported version and specify Coder server and CLI 2.10.0 or newer for support bundle collection. The release notes also summarize the flight recorder and explain that Toolbox logs and eligible local SSH logs remain available when a Coder support bundle cannot be generated. --- CHANGELOG.md | 7 ++++--- README.md | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c35ec1..c75b3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ ### Added - include a Coder support bundle for the selected workspace and agent only when using the environment's _Collect logs_ - action in Toolbox + action in Toolbox; requires Coder server and CLI 2.10.0 or newer. Toolbox logs and eligible local SSH logs remain + available when a Coder support bundle cannot be generated - include active and recent local CLI SSH logs in workspace log collection. For CLI 2.4.0 and newer the plugin now passes an explicit `--log-dir` to the SSH ProxyCommand (the configured directory, `CODER_SSH_LOG_DIR`, or `ssh-logs/` in the deployment's plugin data directory) and collects and cleans up logs from that same directory +- add a flight recorder that keeps recent plugin messages below Toolbox's log level in memory for connection diagnostics ### Changed @@ -18,8 +20,7 @@ - remove stored credentials on explicit logout, and remove old plaintext session files after successful keyring login - bound credential and header commands to 60 seconds while preserving unbounded workspace starts and complete progress lines - -- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.7.2 +- upgraded the Toolbox plugin API, dropping support for Toolbox versions older than 3.9.0 - display live workspace start output from the Coder CLI and poll build status and provisioner output while updating, restarting, or stopping workspaces diff --git a/README.md b/README.md index 17b5415..273ae91 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ Connects your JetBrains IDE to Coder workspaces To install this plugin using JetBrains Toolbox, follow the steps below. -1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/). Make sure it's the `2.6.0.40632` release or - above. +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/) **3.9 or newer**. Earlier versions are not supported. 2. Launch the Toolbox app and sign in with your JetBrains account (if needed). ### Install Coder plugin via URI @@ -576,9 +575,10 @@ source, and agent diagnostics. Available information depends on your permissions Review the bundle before sharing, following [Coder's support-bundle guidance](https://coder.com/docs/support/support-bundle). -Support bundles require a Coder CLI that supports `coder support bundle` (Coder 2.10 or newer). Collection runs until -the CLI finishes or you cancel log collection. If the CLI is unavailable or unsupported, or authentication or -connectivity fails, the plugin removes any partial bundle and attempts to include +Support bundles require **Coder server and CLI 2.10.0 or newer**, which introduced +[`coder support bundle`](https://coder.com/changelog/coder-v2-10-0). Collection runs until the CLI finishes or you cancel +log collection. If the CLI is unavailable or unsupported, or authentication or connectivity fails, the plugin removes +any partial bundle and attempts to include `coder-support-error.txt` instead. Local SSH logs and Toolbox's other diagnostics remain available. ### HTTP Request Logging From fdbc2b9db4a9f0f3c427e6f63e76e8bbc0611bec Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 22:41:37 +0300 Subject: [PATCH 12/17] Shorten the flight recorder setting description The connection log buffer description wraps across several lines in Toolbox's narrow settings window. The settings page now uses a 46-character description that names the flight recorder and retains the guidance to set its capacity to zero to disable it. The localization catalog uses the same text. --- src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt | 5 +---- src/main/resources/localization/defaultMessages.po | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index b82bfd4..a9ce5c7 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -108,10 +108,7 @@ class CoderSettingsPage( ) private val connectionLogBufferDescription = LabelField( - context.i18n.ptrl( - "Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " + - "Defaults to 1000; accepts 0 to 10000. Set to 0 to disable." - ), + context.i18n.ptrl("Flight recorder capacity. Set to 0 to disable."), LabelStyleType.Secondary ) private val useKeyringField = CheckboxField( diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 98c4790..6d018fa 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -73,9 +73,7 @@ msgstr "" msgid "Connection log buffer size must be a whole number between 0 and 10000" msgstr "" -msgid "" -"Maximum number of log entries below Toolbox's log level to keep in memory for connection diagnostics. " -"Defaults to 1000; accepts 0 to 10000. Set to 0 to disable." +msgid "Flight recorder capacity. Set to 0 to disable." msgstr "" msgid "Binary source" From 598e574ebc2d3969339f8ae6ce710c845d4aaf95 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 22:57:55 +0300 Subject: [PATCH 13/17] chore: Remove redundant action refresh logging CoderRemoteEnvironment logs each action-menu refresh with an unreadable status object, which adds noise to the flight recorder. This commit removes that debug message because the existing status-transition logs already provide readable workspace and agent details. --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index eaf0b2c..faae287 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -162,7 +162,6 @@ class CoderRemoteEnvironment( private fun refreshAvailableActions() { val actions = mutableListOf() - context.logger.debug("Refreshing available actions for workspace $id with status: $environmentStatus") if (environmentStatus.canStop() && agent != null) { actions.add(Action(context, "Open web terminal") { context.logger.debug("Launching web terminal for $id...") From 56c71bca66b41966f36bf62f53bb1ac3bdf48cd3 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 23:09:19 +0300 Subject: [PATCH 14/17] Flush buffered logs when environment reachability is lost CoderRemoteEnvironment flushes the flight recorder after every status update, so routine updates drain diagnostic context even when reachability does not change. This commit limits the status-update flush to transitions from reachable to unreachable using the state published to Toolbox. Status messages continue to be logged for every update, while buffered context remains available through other transitions. Regression tests cover all four reachability transition combinations, and the README describes the narrower trigger. --- README.md | 8 ++- .../coder/toolbox/CoderRemoteEnvironment.kt | 5 +- .../CoderLoggerFlightRecorderTest.kt | 55 ++++++++++++++++++- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 273ae91..dd3bb78 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Connects your JetBrains IDE to Coder workspaces To install this plugin using JetBrains Toolbox, follow the steps below. -1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/) **3.9 or newer**. Earlier versions are not supported. +1. Install [JetBrains Toolbox](https://www.jetbrains.com/toolbox-app/) **3.9 or newer**. Earlier versions are not + supported. 2. Launch the Toolbox app and sign in with your JetBrains account (if needed). ### Install Coder plugin via URI @@ -417,7 +418,7 @@ ID, normally `workspace.agent`. | Event | Replay reason | |------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| -| Workspace or agent status is published | `environment_status_changed ` | +| An environment changes from reachable to unreachable | `environment_status_changed ` | | The resolved workspace/environment list changes | `workspaces_changed` | | The workspace filter changes | `workspace_filter_changed` | | A workspace restart, stop, or deletion is requested | `workspace_restarting `, `workspace_stopping `, `workspace_deleting ` | @@ -576,7 +577,8 @@ Review the bundle before sharing, following [Coder's support-bundle guidance](https://coder.com/docs/support/support-bundle). Support bundles require **Coder server and CLI 2.10.0 or newer**, which introduced -[`coder support bundle`](https://coder.com/changelog/coder-v2-10-0). Collection runs until the CLI finishes or you cancel +[`coder support bundle`](https://coder.com/changelog/coder-v2-10-0). Collection runs until the CLI finishes or you +cancel log collection. If the CLI is unavailable or unsupported, or authentication or connectivity fails, the plugin removes any partial bundle and attempts to include `coder-support-error.txt` instead. Local SSH logs and Toolbox's other diagnostics remain available. diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index faae287..c199d82 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -485,6 +485,7 @@ class CoderRemoteEnvironment( val previousEnvironmentStatus = environmentStatus val previousWorkspace = workspace val previousAgent = agent + val wasReachable = state.value.isReachable val sessionId = currentSessionId() environmentStatus = newState @@ -505,7 +506,9 @@ class CoderRemoteEnvironment( "agent lifecycle state: ${previousAgent?.lifecycleState} -> ${agent?.lifecycleState}, " + "login before ready: ${previousAgent?.loginBeforeReady} -> ${agent?.loginBeforeReady}" context.logger.info(sessionId, message) - context.logger.flush("environment_status_changed $id", sessionId) + if (wasReachable && !state.value.isReachable) { + context.logger.flush("environment_status_changed $id", sessionId) + } } /** diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt index 3010066..ee4ac35 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.CoderRemoteEnvironment import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.DataGen +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.session.SessionId import com.coder.toolbox.session.SessionIdRegistry @@ -27,6 +28,7 @@ import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -775,7 +777,7 @@ class CoderLoggerFlightRecorderTest { } @Test - fun `environment status update replays context and the filtered trigger through CoderLogger`() = runTest { + fun `environment becoming unreachable replays context and the filtered trigger through CoderLogger`() = runTest { // given logLevel.value = LogLevel.ERROR val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) @@ -796,6 +798,7 @@ class CoderLoggerFlightRecorderTest { logger.flush("setup") calls.clear() logger.debug(sessionId, "before status update") + assertTrue(environment.state.value.isReachable) // when environment.update( @@ -804,6 +807,7 @@ class CoderLoggerFlightRecorderTest { ) // then + assertFalse(environment.state.value.isReachable) val prefix = "client_session_id=$sessionId " val reason = "environment_status_changed ${environment.id}" assertEquals(List(3) { LogLevel.ERROR }, calls.map { it.level }) @@ -818,6 +822,55 @@ class CoderLoggerFlightRecorderTest { } } + @Test + fun `status updates without losing reachability preserve buffered context`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ).let { it.copy(latestBuild = it.latestBuild.copy(status = WorkspaceStatus.STOPPED)) } + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + try { + assertFalse(environment.state.value.isReachable) + logger.debug("before status updates") + val updates = listOf( + Triple(WorkspaceStatus.FAILED, agent, false), + Triple(WorkspaceStatus.RUNNING, agent, true), + Triple(WorkspaceStatus.RUNNING, agent.copy(status = WorkspaceAgentStatus.DISCONNECTED), true), + ) + for ((status, updatedAgent, expectedReachability) in updates) { + // when + environment.update( + workspace.copy(latestBuild = workspace.latestBuild.copy(id = UUID.randomUUID(), status = status)), + updatedAgent, + ) + + // then + assertEquals(expectedReachability, environment.state.value.isReachable) + assertTrue(calls.none { it.message.startsWith("[buffered]") }) + } + assertEquals(3, calls.size) + assertTrue(calls.all { it.level == LogLevel.INFO && it.message.startsWith("Overall status for workspace") }) + + // when + calls.clear() + logger.flush("support_bundle") + + // then + assertEquals(listOf("before status updates"), replayedDebugMessages()) + } finally { + environment.dispose() + } + } + private fun assertReplayedLevels(bufferedLevels: List, replayLevel: LogLevel) { if (bufferedLevels.isEmpty()) { assertTrue(calls.isEmpty()) From 14ce6a4fa05fb9a3fb5a72e0cc47d726241ac7a9 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Oct 2026 23:30:55 +0300 Subject: [PATCH 15/17] Avoid flushing buffered logs for workspace list changes CoderRemoteProvider flushes the flight recorder whenever the environment list changes, so initial discovery and routine additions drain diagnostic context. This commit removes that flush while preserving SSH reconfiguration. Removed environments with an SSH session continue to flush during disposal, and the README now reflects the remaining replay triggers. --- README.md | 1 - src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/README.md b/README.md index dd3bb78..5464b2e 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,6 @@ ID, normally `workspace.agent`. | Event | Replay reason | |------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------| | An environment changes from reachable to unreachable | `environment_status_changed ` | -| The resolved workspace/environment list changes | `workspaces_changed` | | The workspace filter changes | `workspace_filter_changed` | | A workspace restart, stop, or deletion is requested | `workspace_restarting `, `workspace_stopping `, `workspace_deleting ` | | An SSH disconnect request times out before workspace stop | `ssh_disconnect_timeout ` | diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index 8ea7352..f710396 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -158,7 +158,6 @@ class CoderRemoteProvider( sessionIds, "Workspaces have changed, reconfiguring CLI: $resolvedEnvironments", ) - context.logger.flush("workspaces_changed") configureSsh(cli, resolvedEnvironments) } From 55be7cbc4079b13fd1192cafca080ebc13da0853 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 23 Oct 2026 21:05:49 +0300 Subject: [PATCH 16/17] Omit process arguments from SSH discovery logs The SSH process search logs full command lines for every visited descendant and for the matching process. These arguments can contain credentials, and the flight recorder can persist them during automatic replay at INFO. This commit removes per-process command logging and limits the match diagnostic to the PID and workspace/agent name. A regression test covers token-bearing matching and unrelated processes and verifies that automatic disconnect replay retains the match diagnostic without exposing command arguments. --- .../toolbox/cli/SshCommandProcessHandle.kt | 3 +- .../CoderLoggerFlightRecorderTest.kt | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt index 1b62b4f..ea00180 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt @@ -26,11 +26,10 @@ class SshCommandProcessHandle(private val ctx: CoderToolboxContext) { while (stack.isNotEmpty()) { val processHandle = stack.removeLast() val cmdLine = processHandle.info().commandLine().getOrNull() - ctx.logger.debug(sessionId, "SSH command PID: ${processHandle.pid()} Command: $cmdLine") if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) { ctx.logger.debug( sessionId, - "SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}" + "SSH command with PID: ${processHandle.pid()} matches ${ws.name}.${agent.name}" ) return processHandle.pid() } else { diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt index ee4ac35..f1da14e 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.diagnostics import com.coder.toolbox.CoderRemoteEnvironment import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.SshCommandProcessHandle import com.coder.toolbox.sdk.DataGen import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus import com.coder.toolbox.sdk.v2.models.WorkspaceStatus @@ -16,6 +17,8 @@ import com.jetbrains.toolbox.api.ui.ToolboxUi import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel @@ -24,7 +27,9 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.Optional import java.util.UUID +import java.util.stream.Stream import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -822,6 +827,79 @@ class CoderLoggerFlightRecorderTest { } } + @Test + fun `automatic disconnect replay omits arguments from visited and matched processes`() = runTest { + // given + val logger = CoderLogger(delegate, ui, backgroundScope, i18n, settings) + val context = mockk(relaxed = true) + every { context.logger } returns logger + every { context.cs } returns backgroundScope + every { context.i18n } returns i18n + val workspace = DataGen.workspace( + name = "workspace-${UUID.randomUUID()}", + agents = mapOf("agent" to UUID.randomUUID().toString()), + ) + val agent = requireNotNull(workspace.latestBuild.resources.single().agents).single() + val environment = CoderRemoteEnvironment( + context, mockk(relaxed = true), mockk(relaxed = true), Channel(Channel.CONFLATED), workspace, agent, + ) + val sessionId = SessionIdRegistry.startSession(context, workspace.name, agent.name) + val sshCommand = "ssh -o ProxyCommand='coder --token matched-token ssh --stdio' ${environment.id}" + val unrelatedCommand = "helper --token unrelated-token --password unrelated-password" + val sshProcess = mockk { + every { pid() } returns 12345L + every { info().commandLine() } returns Optional.of(sshCommand) + } + val unrelatedProcess = mockk { + every { pid() } returns 23456L + every { info().commandLine() } returns Optional.of(unrelatedCommand) + every { children() } answers { Stream.empty() } + } + val currentProcess = mockk { + every { children() } answers { Stream.of(sshProcess, unrelatedProcess) } + } + calls.clear() + mockkStatic(ProcessHandle::class) + try { + every { ProcessHandle.current() } returns currentProcess + + // when + val pid = SshCommandProcessHandle(context).findByWorkspaceAndAgent(workspace, agent) + + // then + assertEquals(12345L, pid) + verify(exactly = 1) { unrelatedProcess.info().commandLine() } + assertTrue(calls.isEmpty()) + + // when + environment.afterDisconnect(isManual = false) + + // then + val output = calls.joinToString("\n") { it.message } + for (argument in listOf( + sshCommand, + unrelatedCommand, + "matched-token", + "unrelated-token", + "unrelated-password" + )) { + assertFalse(output.contains(argument), "Process arguments must not appear in replayed logs") + } + val prefix = "client_session_id=$sessionId " + val replay = calls.filter { it.message.startsWith("${prefix}[buffered]") } + val reason = "ssh_disconnected ${environment.id}" + assertEquals(3, replay.size) + assertEquals("${prefix}[buffered] replaying 1 buffered entries ($reason)", replay.first().message) + assertTrue(replay[1].message.endsWith("DEBUG ${prefix}SSH command with PID: 12345 matches ${environment.id}")) + assertEquals("${prefix}[buffered] end of buffered logs ($reason)", replay.last().message) + assertTrue(calls.all { it.level == LogLevel.INFO }) + } finally { + unmockkStatic(ProcessHandle::class) + environment.dispose() + SessionIdRegistry.removeSession(workspace.name, agent.name) + } + } + @Test fun `status updates without losing reachability preserve buffered context`() = runTest { // given From c55052248f2751cdbb85c1d646ef08ed27ba0054 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 23 Oct 2026 21:55:03 +0300 Subject: [PATCH 17/17] Capture flight recorder timestamps at buffer acceptance FlightRecorder captures timestamps before message preparation and insertion, so concurrent producers can replay entries with timestamps that disagree with their insertion order. This commit captures timestamps when prepared entries are accepted into the buffer, under the insertion lock. Message suppliers and exception formatting remain outside the lock so their execution does not block other producers. This keeps the timestamp semantics in sync with Coder's VS Code extension, where timestamps also mark acceptance of the prepared entry. The README documents acceptance order, and two-producer regression tests cover lazy evaluation, exception formatting, replay order, and capacity-one eviction. --- README.md | 14 ++- .../toolbox/diagnostics/FlightRecorder.kt | 9 +- .../CoderLoggerFlightRecorderTest.kt | 103 ++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5464b2e..964ddcf 100644 --- a/README.md +++ b/README.md @@ -363,10 +363,12 @@ configure the capacity up to **10000 entries** using **Connection log buffer siz its current log level are logged normally; messages it filters out are captured in a fixed capacity array ring buffer. When the buffer fills, each new entry replaces the oldest entry. -Each entry preserves its original log level, UTC timestamp, message text, and exception details when present. The limit -counts entries, not lines or bytes: a multiline message or a message with a stack trace occupies one entry, so memory -usage depends on message size. Capture covers messages sent through the Coder plugin logger; CLI SSH file logs are -collected separately as described in [Coder Support Bundles](#coder-support-bundles). +Each entry preserves its original log level, message text, and exception details when present. Its UTC timestamp marks +when the prepared entry is accepted into the buffer, after any lazy message evaluation and exception formatting. +Entries are replayed and evicted in that acceptance order. The limit counts entries, not lines or bytes: a multiline +message or a message with a stack trace occupies one entry, so memory usage depends on message size. Capture covers +messages sent through the Coder plugin logger; CLI SSH file logs are collected separately as described in +[Coder Support Bundles](#coder-support-bundles). There is one shared buffer per running plugin instance, so a replay can contain context from several workspaces. Messages retain any `client_session_id` recorded with them. Environment-specific replays also use the triggering @@ -452,8 +454,8 @@ Example message text, before Toolbox adds its own timestamp and metadata: ``` The `retry scheduled` line is a continuation of the first entry. Every continuation line gets a bare `[buffered] ` -prefix. Entry timestamps use ISO 8601 in UTC with millisecond precision and show when the message was originally -logged. Toolbox adds its own timestamp for the replay time, which can use a different time zone. +prefix. Entry timestamps use ISO 8601 in UTC with millisecond precision and show when the prepared entry was accepted +into the buffer. Toolbox adds its own timestamp for the replay time, which can use a different time zone. Each chunk is one multiline Toolbox record. Only its first line receives Toolbox's usual timestamp, severity, and other metadata; subsequent lines contain the buffered message text. Original severity labels such as `DEBUG` and diff --git a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt index df8e68b..2e452f1 100644 --- a/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt +++ b/src/main/kotlin/com/coder/toolbox/diagnostics/FlightRecorder.kt @@ -45,14 +45,13 @@ internal class FlightRecorder( fun record(level: LogLevel, message: String, exception: Throwable? = null) { if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return - record(level, clock.instant(), message, exception) + recordEntry(level, message, exception) } fun record(level: LogLevel, exception: Throwable?, message: () -> String): () -> String { if (capacity == 0 || logger.effectiveLevel.value == LogLevel.OFF || logger.isEnabled(level)) return message - val timestamp = clock.instant() val resolved = message() - record(level, timestamp, resolved, exception) + recordEntry(level, resolved, exception) // Toolbox may evaluate this later if its level changes. Never evaluate the original supplier twice. return { resolved } } @@ -108,11 +107,11 @@ internal class FlightRecorder( return true } - private fun record(level: LogLevel, timestamp: Instant, message: String, exception: Throwable?) { + private fun recordEntry(level: LogLevel, message: String, exception: Throwable?) { val text = if (exception == null) message else "$message\n${exception.stackTraceToString()}" - val entry = FlightRecorderEntry(level, timestamp, text) synchronized(lock) { if (entries.isEmpty()) return + val entry = FlightRecorderEntry(level, clock.instant(), text) val newestRecordIndex = oldestRecordIndex + count entries[if (newestRecordIndex >= entries.size) newestRecordIndex - entries.size else newestRecordIndex] = entry diff --git a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt index f1da14e..e961a64 100644 --- a/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt +++ b/src/test/kotlin/com/coder/toolbox/diagnostics/CoderLoggerFlightRecorderTest.kt @@ -25,10 +25,15 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import java.io.PrintWriter +import java.time.Clock import java.time.Instant import java.time.temporal.ChronoUnit import java.util.Optional import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.stream.Stream import kotlin.test.Test import kotlin.test.assertEquals @@ -451,6 +456,104 @@ class CoderLoggerFlightRecorderTest { assertTrue(lines.drop(1).all { it.startsWith("[buffered] ") }) } + @Test + fun `concurrent lazy logging timestamps entries in buffer acceptance order`() = runTest { + // given + bufferSize.value = 2 + val firstTimestamp = Instant.parse("2026-10-23T12:00:00.001Z") + val secondTimestamp = firstTimestamp.plusMillis(1) + val clock = mockk { + every { instant() } returnsMany listOf(firstTimestamp, secondTimestamp) + } + mockkStatic(Clock::class) + val logger = try { + every { Clock.systemUTC() } returns clock + CoderLogger(delegate, ui, backgroundScope, i18n, settings) + } finally { + unmockkStatic(Clock::class) + } + val supplierStarted = CountDownLatch(1) + val finishSupplier = CountDownLatch(1) + val producers = Executors.newFixedThreadPool(2) + try { + // when + val delayed = producers.submit { + logger.debug { + supplierStarted.countDown() + assertTrue(finishSupplier.await(10, TimeUnit.SECONDS)) + "prepared later" + } + } + assertTrue(supplierStarted.await(5, TimeUnit.SECONDS)) + producers.submit { logger.debug("prepared first") }.get(5, TimeUnit.SECONDS) + finishSupplier.countDown() + delayed.get(5, TimeUnit.SECONDS) + logger.flush("support_bundle") + + // then + assertEquals(listOf("prepared first", "prepared later"), replayedDebugMessages()) + assertEquals( + listOf( + "[buffered] $firstTimestamp DEBUG prepared first", + "[buffered] $secondTimestamp DEBUG prepared later", + ), + calls[1].message.lines(), + ) + } finally { + finishSupplier.countDown() + producers.shutdownNow() + producers.awaitTermination(5, TimeUnit.SECONDS) + } + } + + @Test + fun `concurrent exception formatting retains the newest accepted entry at capacity one`() = runTest { + // given + bufferSize.value = 1 + val firstTimestamp = Instant.parse("2026-10-23T12:00:00.001Z") + val secondTimestamp = firstTimestamp.plusMillis(1) + val clock = mockk { + every { instant() } returnsMany listOf(firstTimestamp, secondTimestamp) + } + mockkStatic(Clock::class) + val logger = try { + every { Clock.systemUTC() } returns clock + CoderLogger(delegate, ui, backgroundScope, i18n, settings) + } finally { + unmockkStatic(Clock::class) + } + val formattingStarted = CountDownLatch(1) + val finishFormatting = CountDownLatch(1) + val exception = object : RuntimeException("delayed exception") { + override fun printStackTrace(writer: PrintWriter) { + formattingStarted.countDown() + assertTrue(finishFormatting.await(10, TimeUnit.SECONDS)) + super.printStackTrace(writer) + } + } + val producers = Executors.newFixedThreadPool(2) + try { + // when + val delayed = producers.submit { logger.debug(exception, "prepared later") } + assertTrue(formattingStarted.await(5, TimeUnit.SECONDS)) + producers.submit { logger.debug("prepared first") }.get(5, TimeUnit.SECONDS) + finishFormatting.countDown() + delayed.get(5, TimeUnit.SECONDS) + logger.flush("support_bundle") + + // then + assertEquals(3, calls.size) + assertEquals("[buffered] replaying 1 buffered entries (support_bundle)", calls.first().message) + assertEquals("[buffered] $secondTimestamp DEBUG prepared later", calls[1].message.lines().first()) + assertTrue(calls[1].message.contains("delayed exception")) + assertTrue(calls.none { it.message.contains("prepared first") }) + } finally { + finishFormatting.countDown() + producers.shutdownNow() + producers.awaitTermination(5, TimeUnit.SECONDS) + } + } + @Test fun `initial OFF records no strings or lambdas and flush makes no logging calls`() = runTest { // given