From 4f1c279154ef6929a36107f0dc3375df0eac34f1 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 29 Jul 2025 01:51:49 +0300 Subject: [PATCH 1/8] impl: relax json syntax rules for deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some clients, workspace polling fails due to the following error: ``` com.squareup.moshi.JsonEncodingException: Use JsonReader.setLenient(true) to accept malformed JSON at path $ ``` I haven’t been able to reproduce this issue, even using the same version deployed at the client (2.20.2). This change is an attempt to relax the JSON parsing rules by enabling lenient mode, in the hope that it will resolve the issue on the client side. --- CHANGELOG.md | 4 ++++ src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cab6bf..3bc6c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- the http client has relaxed syntax rules when deserializing JSON responses + ## 0.6.0 - 2025-07-25 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1a0f18e..fecbed5 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -74,10 +74,10 @@ open class CoderRestClient( var builder = OkHttpClient.Builder() if (context.proxySettings.getProxy() != null) { - context.logger.debug("proxy: ${context.proxySettings.getProxy()}") + context.logger.info("proxy: ${context.proxySettings.getProxy()}") builder.proxy(context.proxySettings.getProxy()) } else if (context.proxySettings.getProxySelector() != null) { - context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}") + context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}") builder.proxySelector(context.proxySettings.getProxySelector()!!) } @@ -133,7 +133,7 @@ open class CoderRestClient( retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory(MoshiConverterFactory.create(moshi).asLenient()) .build().create(CoderV2RestFacade::class.java) } From 7d4a8ad00e887367e6613c03fa7a3e745d1eb879 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Fri, 1 Aug 2025 01:18:55 +0300 Subject: [PATCH 2/8] impl: add error logging for malformed JSON responses in Coder REST API calls Wraps Moshi converter to log raw response body when JSON parsing fails. It helps debug malformed JSON during workspace polling by logging full response content, content type, and error details when a exception during marshalling occurs. A couple of approaches were tried, unfortunately by the time the exception is raised the response body has already been consumed by Moshi's converter, so you can't read it again. The interceptors are also not really a viable option, they are called before the converters which means: - we don't know if the response body is in the correct form or not. This is problematic because it means for every rest call we have to read the body twice (interceptor and by moshi converter) - we also have to save the intercepted body and store it until we have an exception from moshi, in which case it will have to be logged. This approach only logs on conversion failures, and the only performance impact on successful responses is the fact that we convert the response body byte stream to a string representation that can later be printed, and then again back to a byte stream by the moshi converter. --- .../com/coder/toolbox/sdk/CoderRestClient.kt | 8 ++- .../sdk/convertors/LoggingConverterFactory.kt | 53 +++++++++++++++++++ .../sdk/convertors/LoggingMoshiConverter.kt | 34 ++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index fecbed5..fca46a3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.sdk import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.sdk.convertors.ArchConverter import com.coder.toolbox.sdk.convertors.InstantConverter +import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException @@ -133,7 +134,12 @@ open class CoderRestClient( retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi).asLenient()) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(moshi).asLenient() + ) + ) .build().create(CoderV2RestFacade::class.java) } diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt new file mode 100644 index 0000000..839d753 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingConverterFactory.kt @@ -0,0 +1,53 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class LoggingConverterFactory private constructor( + private val context: CoderToolboxContext, + private val delegate: Converter.Factory, +) : Converter.Factory() { + + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? { + // Get the delegate converter + val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit) + ?: return null + + @Suppress("UNCHECKED_CAST") + return LoggingMoshiConverter(context, delegateConverter as Converter) + } + + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + retrofit: Retrofit + ): Converter<*, RequestBody>? { + return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit) + } + + override fun stringConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter<*, String>? { + return delegate.stringConverter(type, annotations, retrofit) + } + + companion object { + fun wrap( + context: CoderToolboxContext, + delegate: Converter.Factory, + ): LoggingConverterFactory { + return LoggingConverterFactory(context, delegate) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt new file mode 100644 index 0000000..9cc548a --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/convertors/LoggingMoshiConverter.kt @@ -0,0 +1,34 @@ +package com.coder.toolbox.sdk.convertors + +import com.coder.toolbox.CoderToolboxContext +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Converter + +class LoggingMoshiConverter( + private val context: CoderToolboxContext, + private val delegate: Converter +) : Converter { + + override fun convert(value: ResponseBody): Any? { + val bodyString = value.string() + + return try { + // Parse with Moshi + delegate.convert(bodyString.toResponseBody(value.contentType())) + } catch (e: Exception) { + // Log the raw content that failed to parse + context.logger.error( + """ + |Moshi parsing failed: + |Content-Type: ${value.contentType()} + |Content: $bodyString + |Error: ${e.message} + """.trimMargin() + ) + + // Re-throw so the onFailure callback still gets called + throw e + } + } +} \ No newline at end of file From 8fca449ef5a8e57805faa03b627214ca6b9e5b8a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 5 Aug 2025 23:49:02 +0300 Subject: [PATCH 3/8] impl: configuration for controlling the rest api client log level --- .../toolbox/settings/ReadOnlyCoderSettings.kt | 33 +++++++++++++++++++ .../coder/toolbox/store/CoderSettingsStore.kt | 3 ++ .../com/coder/toolbox/store/StoreKeys.kt | 2 ++ 3 files changed, 38 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 0775a63..0000ea6 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -38,6 +38,11 @@ interface ReadOnlyCoderSettings { */ val fallbackOnCoderForSignatures: SignatureFallbackStrategy + /** + * Controls the logging for the rest client. + */ + val httpClientLogLevel: HttpLoggingVerbosity + /** * Default CLI binary name based on OS and architecture */ @@ -216,4 +221,32 @@ enum class SignatureFallbackStrategy { else -> NOT_CONFIGURED } } +} + +enum class HttpLoggingVerbosity { + NONE, + + /** + * Logs URL, method, and status + */ + BASIC, + + /** + * Logs BASIC + sanitized headers + */ + HEADERS, + + /** + * Logs HEADERS + body content + */ + BODY; + + companion object { + fun fromValue(value: String?): HttpLoggingVerbosity = when (value?.lowercase(getDefault())) { + "basic" -> BASIC + "headers" -> HEADERS + "body" -> BODY + else -> NONE + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index 82b6e80..c0d6721 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -1,6 +1,7 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment +import com.coder.toolbox.settings.HttpLoggingVerbosity import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings import com.coder.toolbox.settings.SignatureFallbackStrategy @@ -42,6 +43,8 @@ class CoderSettingsStore( get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false override val fallbackOnCoderForSignatures: SignatureFallbackStrategy get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) + override val httpClientLogLevel: HttpLoggingVerbosity + get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index 1626ce1..5f8f5af 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -14,6 +14,8 @@ internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation" internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" +internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" From f8606a044a14063bb7b4eb7c56fc73607c441ad3 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 00:49:10 +0300 Subject: [PATCH 4/8] impl: configure the rest api client log level from the Settings page This commit adds support for allowing the user to configure the rest api client log level in the Settings page. --- .../coder/toolbox/store/CoderSettingsStore.kt | 5 +++++ .../coder/toolbox/views/CoderSettingsPage.kt | 20 +++++++++++++++++++ .../resources/localization/defaultMessages.po | 12 +++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index c0d6721..f770da8 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -182,6 +182,11 @@ class CoderSettingsStore( } } + fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) { + if (level == null) return + store[HTTP_CLIENT_LOG_LEVEL] = level.toString() + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index d27a1c0..e937600 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -1,8 +1,14 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC +import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY +import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS +import com.coder.toolbox.settings.HttpLoggingVerbosity.NONE import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription 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.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.UiField @@ -44,6 +50,18 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf settings.fallbackOnCoderForSignatures.isAllowed(), context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + + private val httpLoggingField = ComboBoxField( + ComboBoxField.Label(context.i18n.ptrl("HTTP logging level:")), + settings.httpClientLogLevel, + listOf( + LabelledValue(context.i18n.ptrl("None"), NONE, listOf("" to "No logs")), + LabelledValue(context.i18n.ptrl("Basic"), BASIC, listOf("" to "Method, URL and status")), + LabelledValue(context.i18n.ptrl("Header"), HEADERS, listOf("" to " Basic + sanitized headers")), + LabelledValue(context.i18n.ptrl("Body"), BODY, listOf("" to "Headers + body content")), + ) + ) + private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -80,6 +98,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf enableBinaryDirectoryFallbackField, disableSignatureVerificationField, signatureFallbackStrategyField, + httpLoggingField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -103,6 +122,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value) context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) + context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 30c4484..8aabe3f 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -167,4 +167,16 @@ msgid "Run anyway" msgstr "" msgid "Disable Coder CLI signature verification" +msgstr "" + +msgid "None" +msgstr "" + +msgid "Basic" +msgstr "" + +msgid "Headers" +msgstr "" + +msgid "Body" msgstr "" \ No newline at end of file From 4cab21685aa0f4f889f7e074b5745dab9541597b Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 00:54:26 +0300 Subject: [PATCH 5/8] impl: log the rest api client request and response A new interceptor is now available in the rest client that is able to log different level of details regarding the request/response: - if None is configured by user we skip logging - Basic level prints the method + url + response code - Headers prints in addition the request and response headers sanitized first - Body also prints the request/response body --- CHANGELOG.md | 1 + .../com/coder/toolbox/sdk/CoderRestClient.kt | 2 + .../sdk/interceptors/LoggingInterceptor.kt | 143 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index aff8bac..4dd898a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - URL validation is stricter in the connection screen and URI protocol handler - the http client has relaxed syntax rules when deserializing JSON responses +- support for verbose logging a sanitized version of the REST API request and responses ## 0.6.0 - 2025-07-25 diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index fca46a3..225533b 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -7,6 +7,7 @@ import com.coder.toolbox.sdk.convertors.LoggingConverterFactory import com.coder.toolbox.sdk.convertors.OSConverter import com.coder.toolbox.sdk.convertors.UUIDConverter import com.coder.toolbox.sdk.ex.APIResponseException +import com.coder.toolbox.sdk.interceptors.LoggingInterceptor import com.coder.toolbox.sdk.v2.CoderV2RestFacade import com.coder.toolbox.sdk.v2.models.ApiErrorResponse import com.coder.toolbox.sdk.v2.models.BuildInfo @@ -130,6 +131,7 @@ open class CoderRestClient( } it.proceed(request) } + .addInterceptor(LoggingInterceptor(context)) .build() retroRestClient = diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt new file mode 100644 index 0000000..f923fdb --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -0,0 +1,143 @@ +package com.coder.toolbox.sdk.interceptors + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.HttpLoggingVerbosity +import okhttp3.Headers +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import java.nio.charset.StandardCharsets + +class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val logLevel = context.settingsStore.httpClientLogLevel + if (logLevel == HttpLoggingVerbosity.NONE) { + return chain.proceed(chain.request()) + } + val request = chain.request() + val requestLog = StringBuilder() + requestLog.append("request --> ${request.method} ${request.url}\n") + if (logLevel == HttpLoggingVerbosity.HEADERS) { + requestLog.append(request.headers.toSanitizedString()) + } + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body.toPrintableString()?.let { + requestLog.append(it) + } + } + context.logger.info(requestLog.toString()) + + val response = chain.proceed(request) + val responseLog = StringBuilder() + responseLog.append("response <-- ${response.code} ${response.message} ${request.url}\n") + if (logLevel == HttpLoggingVerbosity.HEADERS) { + responseLog.append(response.headers.toSanitizedString()) + } + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body.toPrintableString()?.let { + responseLog.append(it) + } + } + + context.logger.info(responseLog.toString()) + return response + } + + private fun Headers.toSanitizedString(): String { + val result = StringBuilder() + this.forEach { + if (it.first == "Coder-Session-Token" || it.first == "Proxy-Authorization") { + result.append("${it.first}: \n") + } else { + result.append("${it.first}: ${it.second}\n") + } + } + return result.toString() + } + + /** + * Converts a RequestBody to a printable string representation. + * Handles different content types appropriately. + * + * @return String representation of the body, or metadata if not readable + */ + fun RequestBody?.toPrintableString(): String? { + if (this == null) { + return null + } + + if (!contentType().isPrintable()) { + return "[Binary request body: ${contentLength().formatBytes()}, Content-Type: ${contentType()}]\n" + } + + return try { + val buffer = Buffer() + writeTo(buffer) + + val charset = contentType()?.charset() ?: StandardCharsets.UTF_8 + buffer.readString(charset) + } catch (e: Exception) { + "[Error reading request body: ${e.message}]\n" + } + } + + /** + * Converts a ResponseBody to a printable string representation. + * Handles different content types appropriately. + * + * @return String representation of the body, or metadata if not readable + */ + fun ResponseBody?.toPrintableString(): String? { + if (this == null) { + return null + } + + if (!contentType().isPrintable()) { + return "[Binary response body: ${contentLength().formatBytes()}, Content-Type: ${contentType()}]\n" + } + + return try { + val source = source() + source.request(Long.MAX_VALUE) + val charset = contentType()?.charset() ?: StandardCharsets.UTF_8 + source.buffer.clone().readString(charset) + } catch (e: Exception) { + "[Error reading response body: ${e.message}]\n" + } + } + + /** + * Checks if a MediaType represents printable/readable content + */ + private fun MediaType?.isPrintable(): Boolean { + if (this == null) return false + + return when { + // Text types + type == "text" -> true + + // JSON variants + subtype == "json" -> true + subtype.endsWith("+json") -> true + + // Default to non-printable for safety + else -> false + } + } + + /** + * Formats byte count in human-readable format + */ + private fun Long.formatBytes(): String { + return when { + this < 0 -> "unknown size" + this < 1024 -> "${this}B" + this < 1024 * 1024 -> "${this / 1024}KB" + this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" + else -> "${this / (1024 * 1024 * 1024)}GB" + } + } +} \ No newline at end of file From 8a3a698123e1b52f3d1a49a665ceb18629da0b22 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 00:59:53 +0300 Subject: [PATCH 6/8] chore: remove support for lenient moshi marshaller It is dangerous because it can allow dangerous operations in the plugin due to insufficient data that can be interpreted as default state. --- CHANGELOG.md | 1 - src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dd898a..f5e89de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ ### Changed - URL validation is stricter in the connection screen and URI protocol handler -- the http client has relaxed syntax rules when deserializing JSON responses - support for verbose logging a sanitized version of the REST API request and responses ## 0.6.0 - 2025-07-25 diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 225533b..9b2e7b3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -139,7 +139,7 @@ open class CoderRestClient( .addConverterFactory( LoggingConverterFactory.wrap( context, - MoshiConverterFactory.create(moshi).asLenient() + MoshiConverterFactory.create(moshi) ) ) .build().create(CoderV2RestFacade::class.java) From 2decf9b2868fc60cb5cf908d934288e388a61ccf Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 01:03:54 +0300 Subject: [PATCH 7/8] chore: refactor interceptor around the kotlin buildString DSL --- .../sdk/interceptors/LoggingInterceptor.kt | 175 ++++++++---------- 1 file changed, 76 insertions(+), 99 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt index f923fdb..4bbb1b9 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -5,139 +5,116 @@ import com.coder.toolbox.settings.HttpLoggingVerbosity import okhttp3.Headers import okhttp3.Interceptor import okhttp3.MediaType +import okhttp3.Request import okhttp3.RequestBody import okhttp3.Response import okhttp3.ResponseBody import okio.Buffer import java.nio.charset.StandardCharsets +private val SENSITIVE_HEADERS = setOf("Coder-Session-Token", "Proxy-Authorization") + class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { val logLevel = context.settingsStore.httpClientLogLevel if (logLevel == HttpLoggingVerbosity.NONE) { return chain.proceed(chain.request()) } + val request = chain.request() - val requestLog = StringBuilder() - requestLog.append("request --> ${request.method} ${request.url}\n") - if (logLevel == HttpLoggingVerbosity.HEADERS) { - requestLog.append(request.headers.toSanitizedString()) - } - if (logLevel == HttpLoggingVerbosity.BODY) { - request.body.toPrintableString()?.let { - requestLog.append(it) - } - } - context.logger.info(requestLog.toString()) + logRequest(request, logLevel) val response = chain.proceed(request) - val responseLog = StringBuilder() - responseLog.append("response <-- ${response.code} ${response.message} ${request.url}\n") - if (logLevel == HttpLoggingVerbosity.HEADERS) { - responseLog.append(response.headers.toSanitizedString()) - } - if (logLevel == HttpLoggingVerbosity.BODY) { - response.body.toPrintableString()?.let { - responseLog.append(it) - } - } + logResponse(response, request, logLevel) - context.logger.info(responseLog.toString()) return response } - private fun Headers.toSanitizedString(): String { - val result = StringBuilder() - this.forEach { - if (it.first == "Coder-Session-Token" || it.first == "Proxy-Authorization") { - result.append("${it.first}: \n") - } else { - result.append("${it.first}: ${it.second}\n") - } - } - return result.toString() - } + private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("request --> ${request.method} ${request.url}") - /** - * Converts a RequestBody to a printable string representation. - * Handles different content types appropriately. - * - * @return String representation of the body, or metadata if not readable - */ - fun RequestBody?.toPrintableString(): String? { - if (this == null) { - return null - } + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${request.headers.sanitized()}") + } - if (!contentType().isPrintable()) { - return "[Binary request body: ${contentLength().formatBytes()}, Content-Type: ${contentType()}]\n" + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } } - return try { - val buffer = Buffer() - writeTo(buffer) - - val charset = contentType()?.charset() ?: StandardCharsets.UTF_8 - buffer.readString(charset) - } catch (e: Exception) { - "[Error reading request body: ${e.message}]\n" - } + context.logger.info(log) } - /** - * Converts a ResponseBody to a printable string representation. - * Handles different content types appropriately. - * - * @return String representation of the body, or metadata if not readable - */ - fun ResponseBody?.toPrintableString(): String? { - if (this == null) { - return null - } + private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("response <-- ${response.code} ${response.message} ${request.url}") - if (!contentType().isPrintable()) { - return "[Binary response body: ${contentLength().formatBytes()}, Content-Type: ${contentType()}]\n" - } + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${response.headers.sanitized()}") + } - return try { - val source = source() - source.request(Long.MAX_VALUE) - val charset = contentType()?.charset() ?: StandardCharsets.UTF_8 - source.buffer.clone().readString(charset) - } catch (e: Exception) { - "[Error reading response body: ${e.message}]\n" + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } } + + context.logger.info(log) } +} - /** - * Checks if a MediaType represents printable/readable content - */ - private fun MediaType?.isPrintable(): Boolean { - if (this == null) return false +// Extension functions for cleaner code +private fun Headers.sanitized(): String = buildString { + this@sanitized.forEach { (name, value) -> + val displayValue = if (name in SENSITIVE_HEADERS) "" else value + append("$name: $displayValue\n") + } +} - return when { - // Text types - type == "text" -> true +private fun RequestBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } - // JSON variants - subtype == "json" -> true - subtype.endsWith("+json") -> true + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} - // Default to non-printable for safety - else -> false - } +private fun ResponseBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" } - /** - * Formats byte count in human-readable format - */ - private fun Long.formatBytes(): String { - return when { - this < 0 -> "unknown size" - this < 1024 -> "${this}B" - this < 1024 * 1024 -> "${this / 1024}KB" - this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" - else -> "${this / (1024 * 1024 * 1024)}GB" - } + return try { + val source = source() + source.request(Long.MAX_VALUE) + source.buffer.clone().readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" } +} + +private fun MediaType?.isPrintable(): Boolean = when { + this == null -> false + type == "text" -> true + subtype == "json" || subtype.endsWith("+json") -> true + else -> false +} + +private fun Long.formatBytes(): String = when { + this < 0 -> "unknown" + this < 1024 -> "${this}B" + this < 1024 * 1024 -> "${this / 1024}KB" + this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB" + else -> "${this / (1024 * 1024 * 1024)}GB" } \ No newline at end of file From 3899d77f6f7fd66bad7b7aa894e87fb76ea4bf37 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Wed, 6 Aug 2025 23:06:41 +0300 Subject: [PATCH 8/8] doc: how to enable http logging --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/README.md b/README.md index 0c671ce..3e5da52 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,64 @@ via Toolbox App Menu > About > Show log files. Alternatively, you can generate a ZIP file using the Workspace action menu, available either on the main Workspaces page in Coder or within the individual workspace view, under the option labeled _Collect logs_. +### HTTP Request Logging + +The Coder Toolbox plugin includes comprehensive HTTP request logging capabilities to help diagnose API communication +issues with Coder deployments. +This feature allows you to monitor all HTTP requests and responses made by the plugin. + +#### Configuring HTTP Logging + +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) +3. Select "Settings" +4. Find the "HTTP logging level" dropdown + +#### Available Logging Levels + +The plugin supports four levels of HTTP logging verbosity: + +- **None**: No HTTP request/response logging (default) +- **Basic**: Logs HTTP method, URL, and response status code +- **Headers**: Logs basic information plus sanitized request and response headers +- **Body**: Logs headers plus request and response body content + +#### Log Output Format + +HTTP logs follow this format: + +``` +request --> GET https://your-coder-deployment.com/api/v2/users/me +User-Agent: Coder Toolbox/1.0.0 (darwin; amd64) +Coder-Session-Token: + +response <-- 200 https://your-coder-deployment.com/api/v2/users/me +Content-Type: application/json +Content-Length: 245 + +{"id":"12345678-1234-1234-1234-123456789012","username":"coder","email":"coder@example.com"} +``` + +#### Use Cases + +HTTP logging is particularly useful for: + +- **API Debugging**: Diagnosing issues with Coder API communication +- **Authentication Problems**: Troubleshooting token or certificate authentication issues +- **Network Issues**: Identifying connectivity problems with Coder deployments +- **Performance Analysis**: Monitoring request/response times and payload sizes + +#### Troubleshooting with HTTP Logs + +When reporting issues, include HTTP logs to help diagnose: + +1. **Authentication Failures**: Check for 401/403 responses and token headers +2. **Network Connectivity**: Look for connection timeouts or DNS resolution issues +3. **API Compatibility**: Verify request/response formats match expected API versions +4. **Proxy Issues**: Monitor proxy authentication and routing problems + ## Coder Settings The Coder Settings allows users to control CLI download behavior, SSH configuration, TLS parameters, and data