0
# Response Observation
1
2
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.
3
4
## Core Response Observation API
5
6
### ResponseObserver Plugin
7
8
The main plugin for observing HTTP responses that allows registration of multiple observers for different purposes.
9
10
```kotlin { .api }
11
object ResponseObserver : HttpClientPlugin<ResponseObserver.Config, ResponseObserver> {
12
class Config {
13
internal val responseHandlers = mutableListOf<suspend (HttpResponse) -> Unit>()
14
15
fun onResponse(block: suspend (response: HttpResponse) -> Unit)
16
fun onResponse(handler: ResponseHandler)
17
}
18
19
interface ResponseHandler {
20
suspend fun handle(response: HttpResponse)
21
}
22
}
23
```
24
25
## Basic Usage
26
27
### Simple Response Logging
28
29
```kotlin
30
val client = HttpClient {
31
install(ResponseObserver) {
32
onResponse { response ->
33
println("Response received: ${response.status} from ${response.call.request.url}")
34
println("Content-Type: ${response.contentType()}")
35
println("Content-Length: ${response.contentLength()}")
36
}
37
}
38
}
39
40
// All responses will be logged
41
val response1 = client.get("https://httpbin.org/json")
42
val response2 = client.post("https://httpbin.org/post") {
43
setBody("test data")
44
}
45
46
client.close()
47
```
48
49
### Multiple Response Observers
50
51
```kotlin
52
val client = HttpClient {
53
install(ResponseObserver) {
54
// Log response status
55
onResponse { response ->
56
println("[${System.currentTimeMillis()}] ${response.status}")
57
}
58
59
// Track response times
60
onResponse { response ->
61
val requestTime = response.requestTime
62
val responseTime = response.responseTime
63
val duration = responseTime.timestamp - requestTime.timestamp
64
println("Request took ${duration}ms")
65
}
66
67
// Monitor error responses
68
onResponse { response ->
69
if (response.status.value >= 400) {
70
println("ERROR: ${response.status} - ${response.bodyAsText()}")
71
}
72
}
73
}
74
}
75
```
76
77
## Advanced Response Observation
78
79
### Custom Response Handler
80
81
```kotlin
82
class MetricsResponseHandler : ResponseObserver.ResponseHandler {
83
private val responseTimes = mutableListOf<Long>()
84
private val statusCounts = mutableMapOf<Int, Int>()
85
private val errorResponses = mutableListOf<ErrorResponse>()
86
87
override suspend fun handle(response: HttpResponse) {
88
// Track response time
89
val duration = response.responseTime.timestamp - response.requestTime.timestamp
90
responseTimes.add(duration)
91
92
// Count status codes
93
val statusCode = response.status.value
94
statusCounts[statusCode] = statusCounts.getOrDefault(statusCode, 0) + 1
95
96
// Collect error details
97
if (statusCode >= 400) {
98
errorResponses.add(
99
ErrorResponse(
100
url = response.call.request.url.toString(),
101
status = response.status,
102
timestamp = System.currentTimeMillis(),
103
body = response.bodyAsText()
104
)
105
)
106
}
107
}
108
109
fun getAverageResponseTime(): Double {
110
return if (responseTimes.isNotEmpty()) {
111
responseTimes.average()
112
} else 0.0
113
}
114
115
fun getStatusDistribution(): Map<Int, Int> = statusCounts.toMap()
116
117
fun getErrorResponses(): List<ErrorResponse> = errorResponses.toList()
118
}
119
120
data class ErrorResponse(
121
val url: String,
122
val status: HttpStatusCode,
123
val timestamp: Long,
124
val body: String
125
)
126
127
// Usage
128
val metricsHandler = MetricsResponseHandler()
129
130
val client = HttpClient {
131
install(ResponseObserver) {
132
onResponse(metricsHandler)
133
}
134
}
135
136
// After making requests, analyze metrics
137
println("Average response time: ${metricsHandler.getAverageResponseTime()}ms")
138
println("Status distribution: ${metricsHandler.getStatusDistribution()}")
139
```
140
141
### Conditional Response Observation
142
143
```kotlin
144
val client = HttpClient {
145
install(ResponseObserver) {
146
onResponse { response ->
147
val url = response.call.request.url
148
149
// Only observe specific endpoints
150
if (url.encodedPath.startsWith("/api/")) {
151
when {
152
response.status.value >= 500 -> {
153
logServerError(response)
154
}
155
response.status.value >= 400 -> {
156
logClientError(response)
157
}
158
response.status == HttpStatusCode.OK -> {
159
logSuccessfulRequest(response)
160
}
161
}
162
}
163
}
164
}
165
}
166
167
suspend fun logServerError(response: HttpResponse) {
168
println("SERVER ERROR: ${response.status} at ${response.call.request.url}")
169
println("Response body: ${response.bodyAsText()}")
170
// Send to error monitoring service
171
}
172
173
suspend fun logClientError(response: HttpResponse) {
174
println("CLIENT ERROR: ${response.status} at ${response.call.request.url}")
175
// Log for debugging
176
}
177
178
suspend fun logSuccessfulRequest(response: HttpResponse) {
179
val size = response.contentLength() ?: -1
180
println("SUCCESS: ${response.call.request.url} (${size} bytes)")
181
}
182
```
183
184
## Response Analysis and Processing
185
186
### Response Size Monitoring
187
188
```kotlin
189
class ResponseSizeMonitor : ResponseObserver.ResponseHandler {
190
private val sizeBuckets = mutableMapOf<String, MutableList<Long>>()
191
192
override suspend fun handle(response: HttpResponse) {
193
val endpoint = extractEndpoint(response.call.request.url)
194
val size = response.contentLength() ?: estimateBodySize(response)
195
196
sizeBuckets.getOrPut(endpoint) { mutableListOf() }.add(size)
197
}
198
199
private fun extractEndpoint(url: Url): String {
200
return "${url.host}${url.encodedPath}"
201
}
202
203
private suspend fun estimateBodySize(response: HttpResponse): Long {
204
// For responses without content-length, estimate from body
205
val body = response.bodyAsText()
206
return body.toByteArray(Charsets.UTF_8).size.toLong()
207
}
208
209
fun getAverageSize(endpoint: String): Double? {
210
return sizeBuckets[endpoint]?.average()
211
}
212
213
fun getLargestResponse(): Pair<String, Long>? {
214
return sizeBuckets.entries
215
.mapNotNull { (endpoint, sizes) ->
216
sizes.maxOrNull()?.let { endpoint to it }
217
}
218
.maxByOrNull { it.second }
219
}
220
}
221
```
222
223
### Content Type Analysis
224
225
```kotlin
226
val client = HttpClient {
227
install(ResponseObserver) {
228
onResponse { response ->
229
val contentType = response.contentType()
230
val url = response.call.request.url
231
232
when {
233
contentType?.match(ContentType.Application.Json) == true -> {
234
analyzeJsonResponse(response)
235
}
236
contentType?.match(ContentType.Text.Html) == true -> {
237
analyzeHtmlResponse(response)
238
}
239
contentType?.match(ContentType.Image.Any) == true -> {
240
analyzeImageResponse(response)
241
}
242
else -> {
243
println("Unknown content type: $contentType from $url")
244
}
245
}
246
}
247
}
248
}
249
250
suspend fun analyzeJsonResponse(response: HttpResponse) {
251
try {
252
val jsonText = response.bodyAsText()
253
// Parse and validate JSON
254
val isValidJson = try {
255
Json.parseToJsonElement(jsonText)
256
true
257
} catch (e: Exception) {
258
false
259
}
260
261
println("JSON response from ${response.call.request.url}: valid=$isValidJson, size=${jsonText.length}")
262
} catch (e: Exception) {
263
println("Failed to analyze JSON response: ${e.message}")
264
}
265
}
266
267
suspend fun analyzeHtmlResponse(response: HttpResponse) {
268
val html = response.bodyAsText()
269
val titlePattern = Regex("<title>(.*?)</title>", RegexOption.IGNORE_CASE)
270
val title = titlePattern.find(html)?.groupValues?.get(1) ?: "No title"
271
272
println("HTML response from ${response.call.request.url}: title='$title', size=${html.length}")
273
}
274
275
suspend fun analyzeImageResponse(response: HttpResponse) {
276
val size = response.contentLength() ?: -1
277
val contentType = response.contentType()
278
279
println("Image response from ${response.call.request.url}: type=$contentType, size=$size bytes")
280
}
281
```
282
283
### Performance Monitoring
284
285
```kotlin
286
class PerformanceMonitor : ResponseObserver.ResponseHandler {
287
private val requestMetrics = mutableMapOf<String, RequestMetrics>()
288
289
override suspend fun handle(response: HttpResponse) {
290
val endpoint = "${response.call.request.method.value} ${response.call.request.url.encodedPath}"
291
val duration = response.responseTime.timestamp - response.requestTime.timestamp
292
293
val metrics = requestMetrics.getOrPut(endpoint) { RequestMetrics() }
294
metrics.addMeasurement(duration, response.status.value)
295
}
296
297
fun getSlowEndpoints(thresholdMs: Long): List<String> {
298
return requestMetrics.entries
299
.filter { it.value.averageDuration > thresholdMs }
300
.map { it.key }
301
}
302
303
fun getErrorProneEndpoints(errorRateThreshold: Double): List<String> {
304
return requestMetrics.entries
305
.filter { it.value.errorRate > errorRateThreshold }
306
.map { it.key }
307
}
308
}
309
310
class RequestMetrics {
311
private val durations = mutableListOf<Long>()
312
private val statusCodes = mutableListOf<Int>()
313
314
fun addMeasurement(duration: Long, statusCode: Int) {
315
durations.add(duration)
316
statusCodes.add(statusCode)
317
}
318
319
val averageDuration: Double get() = durations.average()
320
val maxDuration: Long get() = durations.maxOrNull() ?: 0L
321
val minDuration: Long get() = durations.minOrNull() ?: 0L
322
323
val errorRate: Double get() {
324
val errorCount = statusCodes.count { it >= 400 }
325
return if (statusCodes.isNotEmpty()) {
326
errorCount.toDouble() / statusCodes.size
327
} else 0.0
328
}
329
330
val totalRequests: Int get() = statusCodes.size
331
}
332
```
333
334
## Response Caching and Storage
335
336
### Response Archiving
337
338
```kotlin
339
class ResponseArchiver(private val archiveDirectory: File) : ResponseObserver.ResponseHandler {
340
341
init {
342
archiveDirectory.mkdirs()
343
}
344
345
override suspend fun handle(response: HttpResponse) {
346
if (shouldArchive(response)) {
347
archiveResponse(response)
348
}
349
}
350
351
private fun shouldArchive(response: HttpResponse): Boolean {
352
// Archive successful responses from specific endpoints
353
return response.status == HttpStatusCode.OK &&
354
response.call.request.url.encodedPath.startsWith("/api/data/")
355
}
356
357
private suspend fun archiveResponse(response: HttpResponse) {
358
val timestamp = System.currentTimeMillis()
359
val url = response.call.request.url
360
val filename = "${url.host}_${url.encodedPath.replace("/", "_")}_$timestamp.json"
361
val archiveFile = File(archiveDirectory, filename)
362
363
val responseData = ResponseArchive(
364
url = url.toString(),
365
status = response.status.value,
366
headers = response.headers.toMap(),
367
body = response.bodyAsText(),
368
timestamp = timestamp,
369
duration = response.responseTime.timestamp - response.requestTime.timestamp
370
)
371
372
val json = Json.encodeToString(responseData)
373
archiveFile.writeText(json)
374
}
375
}
376
377
@Serializable
378
data class ResponseArchive(
379
val url: String,
380
val status: Int,
381
val headers: Map<String, List<String>>,
382
val body: String,
383
val timestamp: Long,
384
val duration: Long
385
)
386
```
387
388
### Response Validation
389
390
```kotlin
391
val client = HttpClient {
392
install(ResponseObserver) {
393
onResponse { response ->
394
validateResponse(response)
395
}
396
}
397
}
398
399
suspend fun validateResponse(response: HttpResponse) {
400
val url = response.call.request.url
401
val contentType = response.contentType()
402
403
// Validate content type matches expectations
404
when {
405
url.encodedPath.startsWith("/api/") -> {
406
if (contentType?.match(ContentType.Application.Json) != true) {
407
println("WARNING: API endpoint returned non-JSON: $contentType")
408
}
409
}
410
url.encodedPath.endsWith(".json") -> {
411
if (contentType?.match(ContentType.Application.Json) != true) {
412
println("WARNING: JSON file has incorrect content type: $contentType")
413
}
414
}
415
}
416
417
// Validate response integrity
418
if (response.status == HttpStatusCode.OK) {
419
val contentLength = response.contentLength()
420
if (contentLength == 0L) {
421
println("WARNING: Empty successful response from $url")
422
}
423
}
424
425
// Validate required headers
426
validateRequiredHeaders(response)
427
}
428
429
fun validateRequiredHeaders(response: HttpResponse) {
430
val requiredHeaders = listOf("content-type", "date")
431
val missingHeaders = requiredHeaders.filter { header ->
432
response.headers[header] == null
433
}
434
435
if (missingHeaders.isNotEmpty()) {
436
println("WARNING: Missing headers: $missingHeaders from ${response.call.request.url}")
437
}
438
}
439
```
440
441
## Debugging and Troubleshooting
442
443
### Response Debugging
444
445
```kotlin
446
class ResponseDebugger : ResponseObserver.ResponseHandler {
447
448
override suspend fun handle(response: HttpResponse) {
449
if (isDebugEnabled()) {
450
printDetailedResponse(response)
451
}
452
}
453
454
private fun isDebugEnabled(): Boolean {
455
return System.getProperty("http.debug") == "true"
456
}
457
458
private suspend fun printDetailedResponse(response: HttpResponse) {
459
val request = response.call.request
460
461
println("=== HTTP Response Debug ===")
462
println("Request: ${request.method.value} ${request.url}")
463
println("Status: ${response.status}")
464
println("Response Time: ${response.responseTime}")
465
println("Duration: ${response.responseTime.timestamp - response.requestTime.timestamp}ms")
466
467
println("\nRequest Headers:")
468
request.headers.forEach { name, values ->
469
values.forEach { value ->
470
println(" $name: $value")
471
}
472
}
473
474
println("\nResponse Headers:")
475
response.headers.forEach { name, values ->
476
values.forEach { value ->
477
println(" $name: $value")
478
}
479
}
480
481
println("\nResponse Body:")
482
val body = response.bodyAsText()
483
if (body.length > 1000) {
484
println("${body.take(1000)}... (truncated, total length: ${body.length})")
485
} else {
486
println(body)
487
}
488
println("=========================")
489
}
490
}
491
```
492
493
### Error Response Collection
494
495
```kotlin
496
class ErrorCollector : ResponseObserver.ResponseHandler {
497
private val errors = Collections.synchronizedList(mutableListOf<ErrorInfo>())
498
499
override suspend fun handle(response: HttpResponse) {
500
if (response.status.value >= 400) {
501
val errorInfo = ErrorInfo(
502
timestamp = System.currentTimeMillis(),
503
url = response.call.request.url.toString(),
504
method = response.call.request.method.value,
505
status = response.status.value,
506
statusDescription = response.status.description,
507
responseBody = response.bodyAsText(),
508
requestHeaders = response.call.request.headers.toMap(),
509
responseHeaders = response.headers.toMap()
510
)
511
errors.add(errorInfo)
512
}
513
}
514
515
fun getErrors(): List<ErrorInfo> = errors.toList()
516
517
fun getErrorsByStatus(statusCode: Int): List<ErrorInfo> {
518
return errors.filter { it.status == statusCode }
519
}
520
521
fun getRecentErrors(sinceMs: Long): List<ErrorInfo> {
522
val cutoff = System.currentTimeMillis() - sinceMs
523
return errors.filter { it.timestamp >= cutoff }
524
}
525
526
fun clear() = errors.clear()
527
}
528
529
data class ErrorInfo(
530
val timestamp: Long,
531
val url: String,
532
val method: String,
533
val status: Int,
534
val statusDescription: String,
535
val responseBody: String,
536
val requestHeaders: Map<String, List<String>>,
537
val responseHeaders: Map<String, List<String>>
538
)
539
```
540
541
## Best Practices
542
543
1. **Selective Observation**: Only observe responses when necessary to avoid performance overhead
544
2. **Async Processing**: Keep response handlers lightweight to avoid blocking request processing
545
3. **Error Handling**: Wrap observation logic in try-catch blocks to prevent failures from affecting requests
546
4. **Memory Management**: Be mindful of memory usage when storing response data or metrics
547
5. **Thread Safety**: Ensure response handlers are thread-safe when accessing shared state
548
6. **Structured Logging**: Use structured logging formats for better log analysis
549
7. **Conditional Logic**: Use conditional logic to observe only relevant responses
550
8. **Resource Cleanup**: Properly clean up resources in response handlers
551
9. **Performance Impact**: Monitor the performance impact of response observation
552
10. **Privacy Considerations**: Be careful when logging sensitive response data
553
11. **Batching**: Consider batching metrics collection for better performance
554
12. **Rate Limiting**: Be aware that excessive logging can impact application performance