or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

builtin-plugins.mdcaching.mdcookies.mdengine-configuration.mdforms.mdhttp-client.mdindex.mdplugin-system.mdrequest-building.mdresponse-handling.mdresponse-observation.mdutilities.mdwebsockets.md

response-observation.mddocs/

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