Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Please add your entries according to this format.
### Added

- Long-press the payload copy button to choose between copying the raw body or the formatted body [#1613]
- `ChuckerGrpcInterceptor` — a gRPC `ClientInterceptor` that captures all four gRPC call types (unary, server-streaming, client-streaming, bidirectional) and displays them alongside HTTP traffic in Chucker. Streaming responses update live as messages arrive. Add it to your channel via `OkHttpChannelBuilder.intercept(ChuckerGrpcInterceptor(collector, context))`. Swap `library-no-op`'s no-op variant for release builds.
Comment on lines 10 to +11

### Fixed

Expand Down
17 changes: 17 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ androidxEspresso = "3.7.0"
nexusPublishPlugin = "2.0.0"
uiTestJunit4 = "1.11.1"

# gRPC
grpc = "1.73.0"
protobuf = "4.31.1"
protobufGradlePlugin = "0.9.4"

[libraries]
# Google Libraries
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
Expand Down Expand Up @@ -127,6 +132,17 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" }

# gRPC
grpc-api = { group = "io.grpc", name = "grpc-api", version.ref = "grpc" }
grpc-stub = { group = "io.grpc", name = "grpc-stub", version.ref = "grpc" }
grpc-okhttp = { group = "io.grpc", name = "grpc-okhttp", version.ref = "grpc" }
grpc-netty-shaded = { group = "io.grpc", name = "grpc-netty-shaded", version.ref = "grpc" }
grpc-protobuf = { group = "io.grpc", name = "grpc-protobuf", version.ref = "grpc" }
protobuf-java = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" }
protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protoc-gen-grpc-java = { group = "io.grpc", name = "protoc-gen-grpc-java", version.ref = "grpc" }
javax-annotation-api = { group = "javax.annotation", name = "javax.annotation-api", version = "1.3.2" }

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
Expand All @@ -140,4 +156,5 @@ kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-co
wire = { id = "com.squareup.wire", version.ref = "wire" }
nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublishPlugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufGradlePlugin" }

6 changes: 6 additions & 0 deletions library-no-op/api/library-no-op.api
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ public final class com/chuckerteam/chucker/api/ChuckerCollector {
public static synthetic fun writeTransactions$default (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;Ljava/lang/Long;Lcom/chuckerteam/chucker/api/ExportFormat;ILjava/lang/Object;)Landroid/net/Uri;
}

public final class com/chuckerteam/chucker/api/ChuckerGrpcInterceptor : io/grpc/ClientInterceptor {
public fun <init> (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;)V
public synthetic fun <init> (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun interceptCall (Lio/grpc/MethodDescriptor;Lio/grpc/CallOptions;Lio/grpc/Channel;)Lio/grpc/ClientCall;
}

public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Interceptor {
public fun <init> (Landroid/content/Context;)V
public synthetic fun <init> (Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
1 change: 1 addition & 0 deletions library-no-op/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ tasks.withType<KotlinCompile>().configureEach {

dependencies {
api(libs.okhttp)
compileOnly(libs.grpc.api)
implementation(libs.jetbrains.kotlin.stdlib)
}
Comment on lines 53 to 57

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.chuckerteam.chucker.api

import android.content.Context
import io.grpc.CallOptions
import io.grpc.Channel
import io.grpc.ClientCall
import io.grpc.ClientInterceptor
import io.grpc.MethodDescriptor

/**
* No-op implementation of [ChuckerGrpcInterceptor] for use in release builds.
*
* All parameters are accepted for source-level API compatibility with the debug variant
* but have no effect at runtime; calls pass straight through to the channel.
*/
public class ChuckerGrpcInterceptor private constructor(
@Suppress("UNUSED_PARAMETER") private val collector: ChuckerCollector,
@Suppress("UNUSED_PARAMETER") private val maxContentLength: Long,
@Suppress("UNUSED_PARAMETER") private val headersToRedact: Set<String>,
) : ClientInterceptor {
public constructor(
collector: ChuckerCollector,
@Suppress("UNUSED_PARAMETER") context: Context,
maxContentLength: Long = 250_000L,
redactHeaders: Set<String> = emptySet(),
) : this(collector, maxContentLength, redactHeaders)

override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
next: Channel,
): ClientCall<ReqT, RespT> = next.newCall(method, callOptions)
}
6 changes: 6 additions & 0 deletions library/api/library.api
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ public final class com/chuckerteam/chucker/api/ChuckerCollector {
public static synthetic fun writeTransactions$default (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;Ljava/lang/Long;Lcom/chuckerteam/chucker/api/ExportFormat;ILjava/lang/Object;)Landroid/net/Uri;
}

public final class com/chuckerteam/chucker/api/ChuckerGrpcInterceptor : io/grpc/ClientInterceptor {
public fun <init> (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;)V
public synthetic fun <init> (Lcom/chuckerteam/chucker/api/ChuckerCollector;Landroid/content/Context;JLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun interceptCall (Lio/grpc/MethodDescriptor;Lio/grpc/CallOptions;Lio/grpc/Channel;)Lio/grpc/ClientCall;
}

public final class com/chuckerteam/chucker/api/ChuckerInterceptor : okhttp3/Interceptor {
public fun <init> (Landroid/content/Context;)V
public synthetic fun <init> (Lcom/chuckerteam/chucker/api/ChuckerInterceptor$Builder;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
1 change: 1 addition & 0 deletions library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ dependencies {

api(libs.okhttp)
api(libs.okhttp3.okhttp)
compileOnly(libs.grpc.api)
testImplementation(libs.mockwebserver)
Comment on lines 98 to 101
testRuntimeOnly(libs.junit.engine)
testRuntimeOnly(libs.junit.platform.launcher)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package com.chuckerteam.chucker.api

import android.content.Context
import com.chuckerteam.chucker.internal.data.entity.HttpHeader
import com.chuckerteam.chucker.internal.data.entity.HttpTransaction
import com.chuckerteam.chucker.internal.support.JsonConverter
import com.google.gson.reflect.TypeToken
import io.grpc.CallOptions
import io.grpc.Channel
import io.grpc.ClientCall
import io.grpc.ClientInterceptor
import io.grpc.ForwardingClientCall
import io.grpc.ForwardingClientCallListener
import io.grpc.Metadata
import io.grpc.MethodDescriptor
import io.grpc.Status
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong

/**
* A gRPC [ClientInterceptor] that records requests and responses for inspection in Chucker.
*
* Add it to your gRPC channel:
* ```kotlin
* val channel = OkHttpChannelBuilder.forAddress(host, port)
* .intercept(ChuckerGrpcInterceptor(collector, context))
* .build()
* ```
*
* In release builds swap to [com.chuckerteam.chucker.api.ChuckerGrpcInterceptor] from the
* `library-no-op` artifact to eliminate all overhead.
*
* Streaming calls update live: the transaction appears in Chucker as soon as the server
* sends its initial headers, and the response body grows as messages arrive.
*
* @param collector [ChuckerCollector] that stores and displays the intercepted transactions.
* @param context Android context (unused at runtime, kept for API parity with the no-op variant).
* @param maxContentLength Maximum body size in bytes to record. Bodies larger than this are
* truncated. Defaults to 250 000 bytes.
* @param redactHeaders Header names whose values should be replaced with `**REDACTED**`.
*/
public class ChuckerGrpcInterceptor private constructor(
private val collector: ChuckerCollector,
private val maxContentLength: Long,
private val headersToRedact: Set<String>,
) : ClientInterceptor {
public constructor(
collector: ChuckerCollector,
@Suppress("UNUSED_PARAMETER") context: Context,
maxContentLength: Long = 250_000L,
redactHeaders: Set<String> = emptySet(),
) : this(collector, maxContentLength, redactHeaders.map { it.lowercase() }.toSet())

override fun <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
next: Channel,
): ClientCall<ReqT, RespT> {
val transaction = HttpTransaction()
transaction.requestDate = System.currentTimeMillis()
transaction.method = method.type.name
transaction.protocol = "gRPC"

val authority = next.authority() ?: "unknown"
transaction.host = authority.substringBefore(":")
transaction.path = "/${method.fullMethodName}"
transaction.scheme = if (authority.substringAfterLast(':').toIntOrNull() == HTTPS_PORT) "https" else "http"
transaction.url = "${transaction.scheme}://$authority${transaction.path}"
Comment on lines +64 to +68

// Guards to ensure onRequestSent is called exactly once before any onResponseReceived.
val transactionStarted = AtomicBoolean(false)

val requestBodyLock = Any()
val requestBody = StringBuilder()
val requestBodySize = AtomicLong(0L)

val responseBodyLock = Any()
val responseBody = StringBuilder()
val responseBodySize = AtomicLong(0L)

fun ensureStarted() {
if (transactionStarted.compareAndSet(false, true)) {
collector.onRequestSent(transaction)
}
}

fun updateRequestBody() {
val body = synchronized(requestBodyLock) { requestBody.toString() }
val size = requestBodySize.get()
transaction.requestBody = body.truncatedTo(size, maxContentLength)
transaction.requestPayloadSize = size
}

return object : ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
next.newCall(method, callOptions),
) {
override fun start(
responseListener: Listener<RespT>,
headers: Metadata,
) {
transaction.setRequestHeaders(headers.toHttpHeaders(headersToRedact))
transaction.requestContentType = "application/grpc"

super.start(
object : ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(
responseListener,
) {
override fun onHeaders(responseHeaders: Metadata) {
transaction.responseDate = System.currentTimeMillis()
transaction.setResponseHeaders(responseHeaders.toHttpHeaders(headersToRedact))
transaction.responseContentType = "application/grpc"
// Insert the transaction so it appears in Chucker immediately.
ensureStarted()
super.onHeaders(responseHeaders)
}

override fun onMessage(message: RespT) {
val text = message.toString()
val size = responseBodySize.addAndGet(text.length.toLong())
if (size <= maxContentLength) {
synchronized(responseBodyLock) { responseBody.append(text) }
}
// Live update: refresh the response body after each message.
val body = synchronized(responseBodyLock) { responseBody.toString() }
transaction.responseBody = body.truncatedTo(responseBodySize.get(), maxContentLength)
transaction.responsePayloadSize = responseBodySize.get()
collector.onResponseReceived(transaction)
super.onMessage(message)
}

override fun onClose(
status: Status,
trailers: Metadata,
) {
transaction.responseCode = status.code.value()
transaction.responseMessage =
buildString {
append(status.code.name)
status.description?.let { append(" ($it)") }
}
status.cause?.let { transaction.error = it.toString() }
transaction.appendResponseTrailers(trailers.toHttpHeaders(headersToRedact))
transaction.tookMs =
System.currentTimeMillis() - (transaction.requestDate ?: System.currentTimeMillis())

// Ensure the transaction was started even on immediate errors
// (where onHeaders was never called).
ensureStarted()
collector.onResponseReceived(transaction)
super.onClose(status, trailers)
}
},
headers,
)
}

override fun sendMessage(message: ReqT) {
val text = message.toString()
val size = requestBodySize.addAndGet(text.length.toLong())
if (size <= maxContentLength) {
synchronized(requestBodyLock) { requestBody.append(text) }
}
// Keep the visible request body current for bidi-streaming calls
// where responses (and thus onRequestSent) may have already fired.
if (transactionStarted.get()) {
updateRequestBody()
collector.onResponseReceived(transaction)
}
super.sendMessage(message)
}

override fun halfClose() {
// Client finished sending — capture the final request body.
updateRequestBody()
// If server headers haven't arrived yet (e.g. slow server), ensure
// the transaction is in the DB so the request side is visible.
ensureStarted()
collector.onResponseReceived(transaction)
super.halfClose()
}
}
}

private fun Metadata.toHttpHeaders(redact: Set<String>): List<HttpHeader> =
keys().flatMap { key ->
val isBinary = key.endsWith(Metadata.BINARY_HEADER_SUFFIX)
val values: Iterable<String> =
if (isBinary) {
getAll(Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER))
?.map { it.toString(Charsets.UTF_8) + " (binary)" }
?: emptyList()
} else {
getAll(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)) ?: emptyList()
}
val displayKey = key.lowercase()
values.map { HttpHeader(key, if (redact.contains(displayKey)) "**REDACTED**" else it) }
}

private fun HttpTransaction.appendResponseTrailers(trailers: List<HttpHeader>) {
if (trailers.isEmpty()) return
val type = object : TypeToken<List<HttpHeader>>() {}.type
val existing =
JsonConverter.instance.fromJson<List<HttpHeader>>(responseHeaders ?: "[]", type)
?: emptyList()
responseHeaders =
JsonConverter.instance.toJson(
existing + trailers.map { HttpHeader("(Trailer) ${it.name}", it.value) },
)
}

private fun String.truncatedTo(
actualSize: Long,
max: Long,
): String = if (actualSize > max) take(max.toInt()) + "\n\n--- (truncated)" else this

private companion object {
const val HTTPS_PORT = 443
}
}
Loading
Loading