Skip to content

impl: improved logging and error collection for the http client #165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 6, 2025
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <redacted>

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":"[email protected]"}
```

#### 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
Expand Down
14 changes: 11 additions & 3 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()!!)
}

Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
// Get the delegate converter
val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit)
?: return null

@Suppress("UNCHECKED_CAST")
return LoggingMoshiConverter(context, delegateConverter as Converter<ResponseBody, Any?>)
}

override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<Annotation>,
methodAnnotations: Array<Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit)
}

override fun stringConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<*, String>? {
return delegate.stringConverter(type, annotations, retrofit)
}

companion object {
fun wrap(
context: CoderToolboxContext,
delegate: Converter.Factory,
): LoggingConverterFactory {
return LoggingConverterFactory(context, delegate)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ResponseBody, Any?>
) : Converter<ResponseBody, Any> {

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
}
}
}
Original file line number Diff line number Diff line change
@@ -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 {
[email protected] { (name, value) ->
val displayValue = if (name in SENSITIVE_HEADERS) "<redacted>" 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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
}
}
}
Loading
Loading