CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-io-ktor--ktor-client-core-tvosarm64

Ktor HTTP Client Core for tvOS ARM64 - multiplatform asynchronous HTTP client library with coroutines support

Pending
Overview
Eval results
Files

response-observation.mddocs/

Response Observation

The Ktor HTTP Client Core provides response observation functionality through the ResponseObserver plugin for monitoring and intercepting HTTP responses. This enables logging, metrics collection, custom processing, and debugging of HTTP client interactions with full access to response data and metadata.

Core Response Observation API

ResponseObserver Plugin

The main plugin for observing HTTP responses that allows registration of multiple observers for different purposes.

object ResponseObserver : HttpClientPlugin<ResponseObserver.Config, ResponseObserver> {
    class Config {
        internal val responseHandlers = mutableListOf<suspend (HttpResponse) -> Unit>()
        
        fun onResponse(block: suspend (response: HttpResponse) -> Unit)
        fun onResponse(handler: ResponseHandler)
    }
    
    interface ResponseHandler {
        suspend fun handle(response: HttpResponse)
    }
}

Basic Usage

Simple Response Logging

val client = HttpClient {
    install(ResponseObserver) {
        onResponse { response ->
            println("Response received: ${response.status} from ${response.call.request.url}")
            println("Content-Type: ${response.contentType()}")
            println("Content-Length: ${response.contentLength()}")
        }
    }
}

// All responses will be logged
val response1 = client.get("https://httpbin.org/json")
val response2 = client.post("https://httpbin.org/post") {
    setBody("test data")
}

client.close()

Multiple Response Observers

val client = HttpClient {
    install(ResponseObserver) {
        // Log response status
        onResponse { response ->
            println("[${System.currentTimeMillis()}] ${response.status}")
        }
        
        // Track response times
        onResponse { response ->
            val requestTime = response.requestTime
            val responseTime = response.responseTime
            val duration = responseTime.timestamp - requestTime.timestamp
            println("Request took ${duration}ms")
        }
        
        // Monitor error responses
        onResponse { response ->
            if (response.status.value >= 400) {
                println("ERROR: ${response.status} - ${response.bodyAsText()}")
            }
        }
    }
}

Advanced Response Observation

Custom Response Handler

class MetricsResponseHandler : ResponseObserver.ResponseHandler {
    private val responseTimes = mutableListOf<Long>()
    private val statusCounts = mutableMapOf<Int, Int>()
    private val errorResponses = mutableListOf<ErrorResponse>()
    
    override suspend fun handle(response: HttpResponse) {
        // Track response time
        val duration = response.responseTime.timestamp - response.requestTime.timestamp
        responseTimes.add(duration)
        
        // Count status codes
        val statusCode = response.status.value
        statusCounts[statusCode] = statusCounts.getOrDefault(statusCode, 0) + 1
        
        // Collect error details
        if (statusCode >= 400) {
            errorResponses.add(
                ErrorResponse(
                    url = response.call.request.url.toString(),
                    status = response.status,
                    timestamp = System.currentTimeMillis(),
                    body = response.bodyAsText()
                )
            )
        }
    }
    
    fun getAverageResponseTime(): Double {
        return if (responseTimes.isNotEmpty()) {
            responseTimes.average()
        } else 0.0
    }
    
    fun getStatusDistribution(): Map<Int, Int> = statusCounts.toMap()
    
    fun getErrorResponses(): List<ErrorResponse> = errorResponses.toList()
}

data class ErrorResponse(
    val url: String,
    val status: HttpStatusCode,
    val timestamp: Long,
    val body: String
)

// Usage
val metricsHandler = MetricsResponseHandler()

val client = HttpClient {
    install(ResponseObserver) {
        onResponse(metricsHandler)
    }
}

// After making requests, analyze metrics
println("Average response time: ${metricsHandler.getAverageResponseTime()}ms")
println("Status distribution: ${metricsHandler.getStatusDistribution()}")

Conditional Response Observation

val client = HttpClient {
    install(ResponseObserver) {
        onResponse { response ->
            val url = response.call.request.url
            
            // Only observe specific endpoints
            if (url.encodedPath.startsWith("/api/")) {
                when {
                    response.status.value >= 500 -> {
                        logServerError(response)
                    }
                    response.status.value >= 400 -> {
                        logClientError(response)
                    }
                    response.status == HttpStatusCode.OK -> {
                        logSuccessfulRequest(response)
                    }
                }
            }
        }
    }
}

suspend fun logServerError(response: HttpResponse) {
    println("SERVER ERROR: ${response.status} at ${response.call.request.url}")
    println("Response body: ${response.bodyAsText()}")
    // Send to error monitoring service
}

suspend fun logClientError(response: HttpResponse) {
    println("CLIENT ERROR: ${response.status} at ${response.call.request.url}")
    // Log for debugging
}

suspend fun logSuccessfulRequest(response: HttpResponse) {
    val size = response.contentLength() ?: -1
    println("SUCCESS: ${response.call.request.url} (${size} bytes)")
}

Response Analysis and Processing

Response Size Monitoring

class ResponseSizeMonitor : ResponseObserver.ResponseHandler {
    private val sizeBuckets = mutableMapOf<String, MutableList<Long>>()
    
    override suspend fun handle(response: HttpResponse) {
        val endpoint = extractEndpoint(response.call.request.url)
        val size = response.contentLength() ?: estimateBodySize(response)
        
        sizeBuckets.getOrPut(endpoint) { mutableListOf() }.add(size)
    }
    
    private fun extractEndpoint(url: Url): String {
        return "${url.host}${url.encodedPath}"
    }
    
    private suspend fun estimateBodySize(response: HttpResponse): Long {
        // For responses without content-length, estimate from body
        val body = response.bodyAsText()
        return body.toByteArray(Charsets.UTF_8).size.toLong()
    }
    
    fun getAverageSize(endpoint: String): Double? {
        return sizeBuckets[endpoint]?.average()
    }
    
    fun getLargestResponse(): Pair<String, Long>? {
        return sizeBuckets.entries
            .mapNotNull { (endpoint, sizes) ->
                sizes.maxOrNull()?.let { endpoint to it }
            }
            .maxByOrNull { it.second }
    }
}

Content Type Analysis

val client = HttpClient {
    install(ResponseObserver) {
        onResponse { response ->
            val contentType = response.contentType()
            val url = response.call.request.url
            
            when {
                contentType?.match(ContentType.Application.Json) == true -> {
                    analyzeJsonResponse(response)
                }
                contentType?.match(ContentType.Text.Html) == true -> {
                    analyzeHtmlResponse(response)
                }
                contentType?.match(ContentType.Image.Any) == true -> {
                    analyzeImageResponse(response)
                }
                else -> {
                    println("Unknown content type: $contentType from $url")
                }
            }
        }
    }
}

suspend fun analyzeJsonResponse(response: HttpResponse) {
    try {
        val jsonText = response.bodyAsText()
        // Parse and validate JSON
        val isValidJson = try {
            Json.parseToJsonElement(jsonText)
            true
        } catch (e: Exception) {
            false
        }
        
        println("JSON response from ${response.call.request.url}: valid=$isValidJson, size=${jsonText.length}")
    } catch (e: Exception) {
        println("Failed to analyze JSON response: ${e.message}")
    }
}

suspend fun analyzeHtmlResponse(response: HttpResponse) {
    val html = response.bodyAsText()
    val titlePattern = Regex("<title>(.*?)</title>", RegexOption.IGNORE_CASE)
    val title = titlePattern.find(html)?.groupValues?.get(1) ?: "No title"
    
    println("HTML response from ${response.call.request.url}: title='$title', size=${html.length}")
}

suspend fun analyzeImageResponse(response: HttpResponse) {
    val size = response.contentLength() ?: -1
    val contentType = response.contentType()
    
    println("Image response from ${response.call.request.url}: type=$contentType, size=$size bytes")
}

Performance Monitoring

class PerformanceMonitor : ResponseObserver.ResponseHandler {
    private val requestMetrics = mutableMapOf<String, RequestMetrics>()
    
    override suspend fun handle(response: HttpResponse) {
        val endpoint = "${response.call.request.method.value} ${response.call.request.url.encodedPath}"
        val duration = response.responseTime.timestamp - response.requestTime.timestamp
        
        val metrics = requestMetrics.getOrPut(endpoint) { RequestMetrics() }
        metrics.addMeasurement(duration, response.status.value)
    }
    
    fun getSlowEndpoints(thresholdMs: Long): List<String> {
        return requestMetrics.entries
            .filter { it.value.averageDuration > thresholdMs }
            .map { it.key }
    }
    
    fun getErrorProneEndpoints(errorRateThreshold: Double): List<String> {
        return requestMetrics.entries
            .filter { it.value.errorRate > errorRateThreshold }
            .map { it.key }
    }
}

class RequestMetrics {
    private val durations = mutableListOf<Long>()
    private val statusCodes = mutableListOf<Int>()
    
    fun addMeasurement(duration: Long, statusCode: Int) {
        durations.add(duration)
        statusCodes.add(statusCode)
    }
    
    val averageDuration: Double get() = durations.average()
    val maxDuration: Long get() = durations.maxOrNull() ?: 0L
    val minDuration: Long get() = durations.minOrNull() ?: 0L
    
    val errorRate: Double get() {
        val errorCount = statusCodes.count { it >= 400 }
        return if (statusCodes.isNotEmpty()) {
            errorCount.toDouble() / statusCodes.size
        } else 0.0
    }
    
    val totalRequests: Int get() = statusCodes.size
}

Response Caching and Storage

Response Archiving

class ResponseArchiver(private val archiveDirectory: File) : ResponseObserver.ResponseHandler {
    
    init {
        archiveDirectory.mkdirs()
    }
    
    override suspend fun handle(response: HttpResponse) {
        if (shouldArchive(response)) {
            archiveResponse(response)
        }
    }
    
    private fun shouldArchive(response: HttpResponse): Boolean {
        // Archive successful responses from specific endpoints
        return response.status == HttpStatusCode.OK &&
                response.call.request.url.encodedPath.startsWith("/api/data/")
    }
    
    private suspend fun archiveResponse(response: HttpResponse) {
        val timestamp = System.currentTimeMillis()
        val url = response.call.request.url
        val filename = "${url.host}_${url.encodedPath.replace("/", "_")}_$timestamp.json"
        val archiveFile = File(archiveDirectory, filename)
        
        val responseData = ResponseArchive(
            url = url.toString(),
            status = response.status.value,
            headers = response.headers.toMap(),
            body = response.bodyAsText(),
            timestamp = timestamp,
            duration = response.responseTime.timestamp - response.requestTime.timestamp
        )
        
        val json = Json.encodeToString(responseData)
        archiveFile.writeText(json)
    }
}

@Serializable
data class ResponseArchive(
    val url: String,
    val status: Int,
    val headers: Map<String, List<String>>,
    val body: String,
    val timestamp: Long,
    val duration: Long
)

Response Validation

val client = HttpClient {
    install(ResponseObserver) {
        onResponse { response ->
            validateResponse(response)
        }
    }
}

suspend fun validateResponse(response: HttpResponse) {
    val url = response.call.request.url
    val contentType = response.contentType()
    
    // Validate content type matches expectations
    when {
        url.encodedPath.startsWith("/api/") -> {
            if (contentType?.match(ContentType.Application.Json) != true) {
                println("WARNING: API endpoint returned non-JSON: $contentType")
            }
        }
        url.encodedPath.endsWith(".json") -> {
            if (contentType?.match(ContentType.Application.Json) != true) {
                println("WARNING: JSON file has incorrect content type: $contentType")
            }
        }
    }
    
    // Validate response integrity
    if (response.status == HttpStatusCode.OK) {
        val contentLength = response.contentLength()
        if (contentLength == 0L) {
            println("WARNING: Empty successful response from $url")
        }
    }
    
    // Validate required headers
    validateRequiredHeaders(response)
}

fun validateRequiredHeaders(response: HttpResponse) {
    val requiredHeaders = listOf("content-type", "date")
    val missingHeaders = requiredHeaders.filter { header ->
        response.headers[header] == null
    }
    
    if (missingHeaders.isNotEmpty()) {
        println("WARNING: Missing headers: $missingHeaders from ${response.call.request.url}")
    }
}

Debugging and Troubleshooting

Response Debugging

class ResponseDebugger : ResponseObserver.ResponseHandler {
    
    override suspend fun handle(response: HttpResponse) {
        if (isDebugEnabled()) {
            printDetailedResponse(response)
        }
    }
    
    private fun isDebugEnabled(): Boolean {
        return System.getProperty("http.debug") == "true"
    }
    
    private suspend fun printDetailedResponse(response: HttpResponse) {
        val request = response.call.request
        
        println("=== HTTP Response Debug ===")
        println("Request: ${request.method.value} ${request.url}")
        println("Status: ${response.status}")
        println("Response Time: ${response.responseTime}")
        println("Duration: ${response.responseTime.timestamp - response.requestTime.timestamp}ms")
        
        println("\nRequest Headers:")
        request.headers.forEach { name, values ->
            values.forEach { value ->
                println("  $name: $value")
            }
        }
        
        println("\nResponse Headers:")
        response.headers.forEach { name, values ->
            values.forEach { value ->
                println("  $name: $value")
            }
        }
        
        println("\nResponse Body:")
        val body = response.bodyAsText()
        if (body.length > 1000) {
            println("${body.take(1000)}... (truncated, total length: ${body.length})")
        } else {
            println(body)
        }
        println("=========================")
    }
}

Error Response Collection

class ErrorCollector : ResponseObserver.ResponseHandler {
    private val errors = Collections.synchronizedList(mutableListOf<ErrorInfo>())
    
    override suspend fun handle(response: HttpResponse) {
        if (response.status.value >= 400) {
            val errorInfo = ErrorInfo(
                timestamp = System.currentTimeMillis(),
                url = response.call.request.url.toString(),
                method = response.call.request.method.value,
                status = response.status.value,
                statusDescription = response.status.description,
                responseBody = response.bodyAsText(),
                requestHeaders = response.call.request.headers.toMap(),
                responseHeaders = response.headers.toMap()
            )
            errors.add(errorInfo)
        }
    }
    
    fun getErrors(): List<ErrorInfo> = errors.toList()
    
    fun getErrorsByStatus(statusCode: Int): List<ErrorInfo> {
        return errors.filter { it.status == statusCode }
    }
    
    fun getRecentErrors(sinceMs: Long): List<ErrorInfo> {
        val cutoff = System.currentTimeMillis() - sinceMs
        return errors.filter { it.timestamp >= cutoff }
    }
    
    fun clear() = errors.clear()
}

data class ErrorInfo(
    val timestamp: Long,
    val url: String,
    val method: String,
    val status: Int,
    val statusDescription: String,
    val responseBody: String,
    val requestHeaders: Map<String, List<String>>,
    val responseHeaders: Map<String, List<String>>
)

Best Practices

  1. Selective Observation: Only observe responses when necessary to avoid performance overhead
  2. Async Processing: Keep response handlers lightweight to avoid blocking request processing
  3. Error Handling: Wrap observation logic in try-catch blocks to prevent failures from affecting requests
  4. Memory Management: Be mindful of memory usage when storing response data or metrics
  5. Thread Safety: Ensure response handlers are thread-safe when accessing shared state
  6. Structured Logging: Use structured logging formats for better log analysis
  7. Conditional Logic: Use conditional logic to observe only relevant responses
  8. Resource Cleanup: Properly clean up resources in response handlers
  9. Performance Impact: Monitor the performance impact of response observation
  10. Privacy Considerations: Be careful when logging sensitive response data
  11. Batching: Consider batching metrics collection for better performance
  12. Rate Limiting: Be aware that excessive logging can impact application performance

Install with Tessl CLI

npx tessl i tessl/maven-io-ktor--ktor-client-core-tvosarm64

docs

builtin-plugins.md

caching.md

cookies.md

engine-configuration.md

forms.md

http-client.md

index.md

plugin-system.md

request-building.md

response-handling.md

response-observation.md

utilities.md

websockets.md

tile.json