diff --git a/.changeset/gzip-without-okio.md b/.changeset/gzip-without-okio.md new file mode 100644 index 000000000..07fdfd07a --- /dev/null +++ b/.changeset/gzip-without-okio.md @@ -0,0 +1,5 @@ +--- +"posthog": patch +--- + +Compress request bodies with `java.util.zip.GZIPOutputStream` instead of Okio `GzipSink`, so apps that resolve an Okio version older than 3.11.0 no longer send corrupt gzip bodies diff --git a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt index a84cdff8d..27f36158d 100644 --- a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt +++ b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt @@ -22,15 +22,14 @@ import com.posthog.PostHogCompression import com.posthog.PostHogConfig import com.posthog.PostHogInternal import okhttp3.Interceptor -import okhttp3.MediaType import okhttp3.MultipartBody import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer -import okio.BufferedSink -import okio.GzipSink -import okio.buffer +import java.io.ByteArrayOutputStream import java.io.IOException +import java.util.zip.GZIPOutputStream // https://square.github.io/okhttp/features/interceptors/ @@ -56,7 +55,7 @@ public class GzipRequestInterceptor(private val config: PostHogConfig) : Interce try { originalRequest.newBuilder() .header("Content-Encoding", "gzip") - .method(originalRequest.method, forceContentLength(gzip(body))) + .method(originalRequest.method, gzip(body)) .build() } catch (e: Throwable) { config.logger.log("Failed to gzip the request body: $e.") @@ -67,44 +66,17 @@ public class GzipRequestInterceptor(private val config: PostHogConfig) : Interce } } - private fun gzip(body: RequestBody): RequestBody { - return object : RequestBody() { - override fun contentType(): MediaType? { - return body.contentType() - } - - override fun contentLength(): Long { - return -1 // We don't know the compressed length in advance! - } - - @Throws(IOException::class) - override fun writeTo(sink: BufferedSink) { - val gzipSink = GzipSink(sink).buffer() - body.writeTo(gzipSink) - gzipSink.close() - } - } - } - - // https://github.com/square/okhttp/issues/350 + // Okio's GzipSink can emit corrupt output with Okio versions before 3.11.0 (square/okio#1608). + // Apps resolve their own Okio version, so compress with the JDK instead. + // Buffering the result also sets Content-Length, see https://github.com/square/okhttp/issues/350 @Throws(IOException::class) - private fun forceContentLength(body: RequestBody): RequestBody { - val buffer = Buffer() - body.writeTo(buffer) - - return object : RequestBody() { - override fun contentType(): MediaType? { - return body.contentType() - } + private fun gzip(body: RequestBody): RequestBody { + val uncompressed = Buffer() + body.writeTo(uncompressed) - override fun contentLength(): Long { - return buffer.size - } + val compressed = ByteArrayOutputStream() + GZIPOutputStream(compressed).use { uncompressed.writeTo(it) } - @Throws(IOException::class) - override fun writeTo(sink: BufferedSink) { - sink.write(buffer.snapshot()) - } - } + return compressed.toByteArray().toRequestBody(body.contentType()) } } diff --git a/posthog/src/test/java/com/posthog/internal/GzipRequestInterceptorTest.kt b/posthog/src/test/java/com/posthog/internal/GzipRequestInterceptorTest.kt new file mode 100644 index 000000000..bea099aa4 --- /dev/null +++ b/posthog/src/test/java/com/posthog/internal/GzipRequestInterceptorTest.kt @@ -0,0 +1,69 @@ +package com.posthog.internal + +import com.posthog.API_KEY +import com.posthog.PostHogConfig +import com.posthog.TestHttpServers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Rule +import java.util.Random +import java.util.zip.GZIPInputStream +import kotlin.test.Test +import kotlin.test.assertEquals + +internal class GzipRequestInterceptorTest { + @get:Rule + val httpServers = TestHttpServers() + + private fun post(payload: String): RecordedRequest { + val http = httpServers.mockHttp() + val client = + OkHttpClient.Builder() + .addInterceptor(GzipRequestInterceptor(PostHogConfig(API_KEY))) + .build() + val request = + Request.Builder() + .url(http.url("/batch")) + .post(payload.toRequestBody("application/json; charset=utf-8".toMediaType())) + .build() + client.newCall(request).execute().close() + return http.takeRequest() + } + + private fun largePayload(): String { + val random = Random(42) + return buildString { + append("[") + repeat(20_000) { index -> + if (index > 0) append(",") + append("{\"event\":\"event_$index\",\"properties\":{\"value\":\"") + repeat(64) { append('a' + random.nextInt(26)) } + append("\"}}") + } + append("]") + } + } + + @Test + fun `compressed body of a large payload decompresses to the original payload`() { + val payload = largePayload() + + val request = post(payload) + + assertEquals("gzip", request.getHeader("Content-Encoding")) + val compressed = request.body.readByteArray() + assertEquals(compressed.size.toLong(), request.getHeader("Content-Length")?.toLong()) + val decompressed = GZIPInputStream(compressed.inputStream()).use { it.readBytes().toString(Charsets.UTF_8) } + assertEquals(payload, decompressed) + } + + @Test + fun `compressed body keeps the original content type`() { + val request = post("{}") + + assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")) + } +}