diff --git a/CHANGELOG.md b/CHANGELOG.md index 640bb7c..f5e89de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Changed - URL validation is stricter in the connection screen and URI protocol handler +- support for verbose logging a sanitized version of the REST API request and responses ## 0.6.0 - 2025-07-25 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 diff --git a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt index 1a0f18e..9b2e7b3 100644 --- a/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt +++ b/src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt @@ -3,9 +3,11 @@ 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 +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 @@ -74,10 +76,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()!!) } @@ -129,11 +131,17 @@ open class CoderRestClient( } it.proceed(request) } + .addInterceptor(LoggingInterceptor(context)) .build() retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient) - .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addConverterFactory( + LoggingConverterFactory.wrap( + context, + MoshiConverterFactory.create(moshi) + ) + ) .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 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..4bbb1b9 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/sdk/interceptors/LoggingInterceptor.kt @@ -0,0 +1,120 @@ +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.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() + logRequest(request, logLevel) + + val response = chain.proceed(request) + logResponse(response, request, logLevel) + + return response + } + + private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("request --> ${request.method} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${request.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + request.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } + + private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) { + val log = buildString { + append("response <-- ${response.code} ${response.message} ${request.url}") + + if (logLevel >= HttpLoggingVerbosity.HEADERS) { + append("\n${response.headers.sanitized()}") + } + + if (logLevel == HttpLoggingVerbosity.BODY) { + response.body?.let { body -> + append("\n${body.toPrintableString()}") + } + } + } + + context.logger.info(log) + } +} + +// 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") + } +} + +private fun RequestBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + return try { + val buffer = Buffer() + writeTo(buffer) + buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8) + } catch (e: Exception) { + "[Error reading body: ${e.message}]" + } +} + +private fun ResponseBody.toPrintableString(): String { + if (!contentType().isPrintable()) { + return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]" + } + + 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 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..f770da8 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()) @@ -179,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/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" 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