From e7ca114205f12a6001d45ce4784b4951bdea5056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sequeira?= Date: Fri, 2 Jan 2026 12:15:51 +0100 Subject: [PATCH 1/4] feat: SDK Compliance --- .github/workflows/sdk-compliance-tests.yml | 25 ++ .../main/java/com/posthog/PostHogConfig.kt | 13 + .../java/com/posthog/internal/PostHogApi.kt | 2 +- sdk_compliance_adapter/Dockerfile | 20 + .../IMPLEMENTATION_NOTES.md | 162 ++++++++ sdk_compliance_adapter/adapter.kt | 347 ++++++++++++++++++ sdk_compliance_adapter/build.gradle.kts | 44 +++ sdk_compliance_adapter/docker-compose.yml | 37 ++ settings.gradle.kts | 1 + 9 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sdk-compliance-tests.yml create mode 100644 sdk_compliance_adapter/Dockerfile create mode 100644 sdk_compliance_adapter/IMPLEMENTATION_NOTES.md create mode 100644 sdk_compliance_adapter/adapter.kt create mode 100644 sdk_compliance_adapter/build.gradle.kts create mode 100644 sdk_compliance_adapter/docker-compose.yml diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml new file mode 100644 index 00000000..1055d559 --- /dev/null +++ b/.github/workflows/sdk-compliance-tests.yml @@ -0,0 +1,25 @@ +name: SDK Compliance Tests + +on: + push: + branches: [ main, master ] + paths: + - 'posthog/**' + - 'sdk_compliance_adapter/**' + - '.github/workflows/sdk-compliance-tests.yml' + pull_request: + branches: [ main, master ] + paths: + - 'posthog/**' + - 'sdk_compliance_adapter/**' + - '.github/workflows/sdk-compliance-tests.yml' + workflow_dispatch: + +jobs: + test-android-sdk: + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main + with: + adapter-dockerfile: sdk_compliance_adapter/Dockerfile + adapter-context: . + test-harness-version: latest + report-name: android-sdk-compliance-report diff --git a/posthog/src/main/java/com/posthog/PostHogConfig.kt b/posthog/src/main/java/com/posthog/PostHogConfig.kt index 1c6a1b69..b6d8991c 100644 --- a/posthog/src/main/java/com/posthog/PostHogConfig.kt +++ b/posthog/src/main/java/com/posthog/PostHogConfig.kt @@ -207,6 +207,19 @@ public open class PostHogConfig( * Default: `null` (no proxy). */ public var proxy: Proxy? = null, + /** + * Optional custom OkHttpClient for HTTP requests. + * + * When set, the SDK will use this client instead of creating its own. + * The provided client should be configured with any necessary interceptors, + * timeouts, and other settings required for your use case. + * + * Note: If both `proxy` and `httpClient` are set, the `httpClient` takes precedence + * and the `proxy` setting will be ignored. + * + * Default: `null` (SDK creates its own client). + */ + public var httpClient: okhttp3.OkHttpClient? = null, /** * Configuration for PostHog Surveys feature. (Intended for internal use only) * diff --git a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt index 11481582..283f29cb 100644 --- a/posthog/src/main/java/com/posthog/internal/PostHogApi.kt +++ b/posthog/src/main/java/com/posthog/internal/PostHogApi.kt @@ -39,7 +39,7 @@ public class PostHogApi( } private val client: OkHttpClient = - OkHttpClient.Builder() + config.httpClient ?: OkHttpClient.Builder() .proxy(config.proxy) .addInterceptor(GzipRequestInterceptor(config)) .build() diff --git a/sdk_compliance_adapter/Dockerfile b/sdk_compliance_adapter/Dockerfile new file mode 100644 index 00000000..e6e9c0e6 --- /dev/null +++ b/sdk_compliance_adapter/Dockerfile @@ -0,0 +1,20 @@ +FROM --platform=linux/amd64 gradle:8.5-jdk17 AS builder + +WORKDIR /app + +COPY . . + +RUN gradle :sdk_compliance_adapter:build --no-daemon + +FROM eclipse-temurin:11-jre + +WORKDIR /app + +# Copy the built JAR +COPY --from=builder /app/sdk_compliance_adapter/build/libs/*.jar /app/adapter.jar + +# Expose port +EXPOSE 8080 + +# Run the adapter +CMD ["java", "-jar", "/app/adapter.jar"] diff --git a/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md new file mode 100644 index 00000000..305c2c8e --- /dev/null +++ b/sdk_compliance_adapter/IMPLEMENTATION_NOTES.md @@ -0,0 +1,162 @@ +# PostHog Android SDK Compliance Adapter - Implementation Notes + +## Overview + +This directory contains the compliance test adapter for the PostHog Android SDK. The adapter wraps the SDK and exposes a standardized HTTP API for automated compliance testing. + +## Architecture + +### Components + +1. **adapter.kt** - Ktor-based HTTP server that implements the compliance adapter API +2. **TrackingInterceptor** - OkHttp interceptor that monitors HTTP requests made by the SDK +3. **AdapterState** - Tracks captured events, sent events, retries, and request metadata +4. **Docker** - Containerized build and runtime environment + +### Key Implementation Details + +#### HTTP Request Tracking + +The adapter uses a custom OkHttpClient with a `TrackingInterceptor` that: +- Intercepts all HTTP requests to the `/batch/` endpoint +- Parses request bodies to extract event UUIDs using regex +- Tracks request count, status codes, retry attempts, and event counts +- Updates adapter state with request metadata + +#### Event UUID Tracking + +Events are tracked using two mechanisms: +1. **beforeSend hook** - Captures UUIDs as events are queued +2. **HTTP interceptor** - Extracts UUIDs from outgoing HTTP requests + +This dual approach ensures UUIDs are available immediately when events are captured AND verified when actually sent. + +#### SDK Configuration + +The adapter configures the PostHog SDK for optimal testing: +- `flushAt = 1` - Send events immediately (or as configured) +- `flushIntervalSeconds` - Fast flush intervals for tests +- `debug = true` - Enable logging +- `httpClient` - Custom OkHttpClient with tracking interceptor + +## SDK Modifications Required + +To enable HTTP request tracking, the following changes were made to the core SDK: + +### PostHogConfig.kt + +Added optional `httpClient` parameter: + +```kotlin +public var httpClient: okhttp3.OkHttpClient? = null +``` + +This allows test adapters to inject a custom OkHttpClient with interceptors. + +### PostHogApi.kt + +Modified to use injected client if provided: + +```kotlin +private val client: OkHttpClient = + config.httpClient ?: OkHttpClient.Builder() + .proxy(config.proxy) + .addInterceptor(GzipRequestInterceptor(config)) + .build() +``` + +**These changes are backward compatible** - existing code works unchanged. + +## Building + +### Local Build (requires Java 8, 11, and 17) + +```bash +./gradlew :sdk_compliance_adapter:build +``` + +### Docker Build (recommended) + +```bash +docker build -f sdk_compliance_adapter/Dockerfile -t posthog-android-adapter . +``` + +The Dockerfile uses Gradle toolchain auto-download to fetch required Java versions. + +## Running Tests + +### With Docker Compose + +```bash +cd sdk_compliance_adapter +docker-compose up --build --abort-on-container-exit +``` + +This runs: +- **test-harness** - Compliance test runner +- **adapter** - This SDK adapter +- **mock-server** - Mock PostHog server + +### SDK Type + +The Android SDK uses **server SDK format**: +- Endpoint: `/batch/` +- Format: `{api_key: "...", batch: [{event}, {event}], sent_at: "..."}` + +Tests run with `--sdk-type server` flag. + +## API Endpoints + +The adapter implements the standard compliance adapter API: + +- `GET /health` - Health check with SDK version info +- `POST /init` - Initialize SDK with config +- `POST /capture` - Capture a single event +- `POST /flush` - Force flush all pending events +- `GET /state` - Get adapter state for assertions +- `POST /reset` - Reset SDK and adapter state + +See [test-harness CONTRACT.yaml](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/CONTRACT.yaml) for full API spec. + +## Testing Philosophy + +The adapter tests the **core PostHog SDK** (`:posthog` module) which contains: +- All HTTP communication logic +- Retry behavior with exponential backoff +- Event batching and queueing +- Error handling + +The `:posthog-android` module is a thin wrapper that adds Android-specific features (lifecycle tracking, etc.) but doesn't change the core compliance behavior. + +## Known Limitations + +### Java 8 on ARM64 + +Java 8 is not available for ARM64 (Apple Silicon). The project requires Java 8 for the core module. Solutions: + +1. **Docker** (recommended) - Uses Gradle toolchain auto-download +2. **CI/CD** - GitHub Actions provides Java 8 for Linux x64 +3. **Modify core** - Upgrade to Java 11 (not recommended - breaks compatibility) + +### Flush Timing + +The `/flush` endpoint includes a 2-second wait to account for: +- SDK's internal flush timer +- Network latency in Docker environment +- Mock server processing time + +This may need adjustment based on test results. + +## Future Improvements + +1. **Reduce flush wait time** - Profile actual flush timing and optimize +2. **Add compression support** - Currently the adapter doesn't test gzip compression +3. **More detailed error tracking** - Capture and report SDK errors in state +4. **Performance metrics** - Track request timing, payload sizes + +## References + +- [Test Harness Repository](https://github.com/PostHog/posthog-sdk-test-harness) +- [Browser SDK Adapter](../../posthog-js/packages/browser/sdk_compliance_adapter/) - Reference implementation +- [Adapter Guide](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/ADAPTER_GUIDE.md) +- [Contract Specification](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/CONTRACT.yaml) diff --git a/sdk_compliance_adapter/adapter.kt b/sdk_compliance_adapter/adapter.kt new file mode 100644 index 00000000..adeef4eb --- /dev/null +++ b/sdk_compliance_adapter/adapter.kt @@ -0,0 +1,347 @@ +package com.posthog.compliance + +import com.posthog.PostHog +import com.posthog.PostHogConfig +import io.ktor.http.* +import io.ktor.serialization.gson.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import okhttp3.Interceptor +import okhttp3.Response +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * PostHog Android SDK Compliance Adapter + * + * HTTP wrapper around the PostHog Android SDK for compliance testing. + */ + +// State tracking +data class RequestRecord( + val timestamp_ms: Long, + val status_code: Int, + val retry_attempt: Int, + val event_count: Int, + val uuid_list: List +) + +data class AdapterState( + var pendingEvents: Int = 0, + var totalEventsCaptured: Int = 0, + var totalEventsSent: Int = 0, + var totalRetries: Int = 0, + var lastError: String? = null, + val requestsMade: MutableList = mutableListOf() +) + +// Request/Response models +data class HealthResponse( + val sdk_name: String, + val sdk_version: String, + val adapter_version: String +) + +data class InitRequest( + val api_key: String, + val host: String, + val flush_at: Int? = null, + val flush_interval_ms: Int? = null, + val max_retries: Int? = null, + val enable_compression: Boolean? = null +) + +data class CaptureRequest( + val distinct_id: String, + val event: String, + val properties: Map? = null, + val timestamp: String? = null +) + +data class CaptureResponse( + val success: Boolean, + val uuid: String +) + +data class FlushResponse( + val success: Boolean, + val events_flushed: Int +) + +data class StateResponse( + val pending_events: Int, + val total_events_captured: Int, + val total_events_sent: Int, + val total_retries: Int, + val last_error: String?, + val requests_made: List +) + +data class SuccessResponse( + val success: Boolean +) + +// Global state +object AdapterContext { + val state = AdapterState() + val lock = ReentrantLock() + var postHog: PostHog? = null + val capturedEvents = mutableListOf() // Store UUIDs of captured events +} + +// OkHttp Interceptor to track requests +class TrackingInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val url = request.url.toString() + + println("[ADAPTER] Intercepting request to: $url") + + // Copy the request body so we can read it + val requestBody = request.body + var eventCount = 0 + val uuidList = mutableListOf() + + // Parse request body before sending + if (url.contains("/batch") && !url.contains("/flags") && requestBody != null) { + try { + val buffer = okio.Buffer() + requestBody.writeTo(buffer) + val bodyString = buffer.readUtf8() + + println("[ADAPTER] Request body: ${bodyString.take(200)}...") + + // Parse JSON to extract UUIDs + // The body format is: {"api_key": "...", "batch": [{event}, {event}], "sent_at": "..."} + val uuidRegex = """"uuid"\s*:\s*"([^"]+)"""".toRegex() + uuidRegex.findAll(bodyString).forEach { match -> + uuidList.add(match.groupValues[1]) + } + eventCount = uuidList.size + + println("[ADAPTER] Extracted $eventCount events with UUIDs: ${uuidList.joinToString()}") + } catch (e: Exception) { + println("[ADAPTER] Error parsing request body: ${e.message}") + e.printStackTrace() + } + } + + val response = chain.proceed(request) + + // Track the response + if (url.contains("/batch") && !url.contains("/flags")) { + println("[ADAPTER] Tracking batch request: status=${response.code}") + + try { + // Extract retry count from URL if present + val retryCount = request.url.queryParameter("retry_count")?.toIntOrNull() ?: 0 + + AdapterContext.lock.withLock { + val record = RequestRecord( + timestamp_ms = System.currentTimeMillis(), + status_code = response.code, + retry_attempt = retryCount, + event_count = eventCount, + uuid_list = uuidList + ) + + AdapterContext.state.requestsMade.add(record) + + if (response.isSuccessful) { + AdapterContext.state.totalEventsSent += eventCount + AdapterContext.state.pendingEvents = maxOf(0, AdapterContext.state.pendingEvents - eventCount) + } + + if (retryCount > 0) { + AdapterContext.state.totalRetries++ + } + } + + println("[ADAPTER] Recorded request: status=${response.code}, retry=$retryCount, events=$eventCount") + } catch (e: Exception) { + println("[ADAPTER] Error tracking request: ${e.message}") + e.printStackTrace() + } + } + + return response + } +} + +fun main() { + println("[ADAPTER] Starting PostHog Android SDK Compliance Adapter") + + embeddedServer(Netty, port = 8080) { + install(ContentNegotiation) { + gson { + setPrettyPrinting() + } + } + + routing { + get("/health") { + println("[ADAPTER] GET /health") + call.respond( + HealthResponse( + sdk_name = "posthog-android", + sdk_version = "3.0.0", // TODO: Get from BuildConfig + adapter_version = "1.0.0" + ) + ) + } + + post("/init") { + println("[ADAPTER] POST /init") + val req = call.receive() + println("[ADAPTER] Initializing with api_key=${req.api_key}, host=${req.host}") + + AdapterContext.lock.withLock { + // Reset state + AdapterContext.state.pendingEvents = 0 + AdapterContext.state.totalEventsCaptured = 0 + AdapterContext.state.totalEventsSent = 0 + AdapterContext.state.totalRetries = 0 + AdapterContext.state.lastError = null + AdapterContext.state.requestsMade.clear() + AdapterContext.capturedEvents.clear() + + // Close existing instance if any + AdapterContext.postHog?.close() + + // Create OkHttpClient with tracking interceptor + val httpClient = okhttp3.OkHttpClient.Builder() + .addInterceptor(TrackingInterceptor()) + .build() + + // Create new config + val config = PostHogConfig( + apiKey = req.api_key, + host = req.host, + flushAt = req.flush_at ?: 1, // Flush immediately for tests + flushIntervalSeconds = (req.flush_interval_ms ?: 100) / 1000, + debug = true, + httpClient = httpClient + ) + + // Add beforeSend hook to track captured events + config.addBeforeSend { event -> + AdapterContext.lock.withLock { + event.uuid?.let { uuid -> + AdapterContext.capturedEvents.add(uuid.toString()) + } + } + event + } + + // Create PostHog instance + AdapterContext.postHog = PostHog.with(config) + + println("[ADAPTER] PostHog initialized with tracking interceptor") + } + + call.respond(SuccessResponse(success = true)) + } + + post("/capture") { + println("[ADAPTER] POST /capture") + val req = call.receive() + println("[ADAPTER] Capturing event: ${req.event} for user: ${req.distinct_id}") + + val ph = AdapterContext.lock.withLock { + AdapterContext.postHog ?: run { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "SDK not initialized")) + return@post + } + } + + // Set distinct_id + ph.identify(req.distinct_id) + + // Remember the count before capturing + val beforeCount = AdapterContext.capturedEvents.size + + // Capture event + val properties = req.properties?.toMutableMap() ?: mutableMapOf() + ph.capture(req.event, properties) + + // Get the UUID that was just captured (via beforeSend hook) + val uuid = AdapterContext.lock.withLock { + AdapterContext.state.totalEventsCaptured++ + AdapterContext.state.pendingEvents++ + + // The last UUID added is the one we just captured + if (AdapterContext.capturedEvents.size > beforeCount) { + AdapterContext.capturedEvents.last() + } else { + // Fallback if beforeSend didn't fire yet + UUID.randomUUID().toString() + } + } + + call.respond(CaptureResponse(success = true, uuid = uuid)) + } + + post("/flush") { + println("[ADAPTER] POST /flush") + + AdapterContext.postHog?.flush() + + // Wait for events to be sent (generous timeout for Docker network latency) + Thread.sleep(2000) + + val eventsFlushed = AdapterContext.lock.withLock { + val flushed = AdapterContext.state.totalEventsSent + AdapterContext.state.pendingEvents = 0 + flushed + } + + println("[ADAPTER] Flush complete: $eventsFlushed events sent") + + call.respond(FlushResponse(success = true, events_flushed = eventsFlushed)) + } + + get("/state") { + println("[ADAPTER] GET /state") + + val stateSnapshot = AdapterContext.lock.withLock { + StateResponse( + pending_events = AdapterContext.state.pendingEvents, + total_events_captured = AdapterContext.state.totalEventsCaptured, + total_events_sent = AdapterContext.state.totalEventsSent, + total_retries = AdapterContext.state.totalRetries, + last_error = AdapterContext.state.lastError, + requests_made = AdapterContext.state.requestsMade.toList() + ) + } + + println("[ADAPTER] State: $stateSnapshot") + + call.respond(stateSnapshot) + } + + post("/reset") { + println("[ADAPTER] POST /reset") + + AdapterContext.lock.withLock { + AdapterContext.postHog?.reset() + + AdapterContext.state.pendingEvents = 0 + AdapterContext.state.totalEventsCaptured = 0 + AdapterContext.state.totalEventsSent = 0 + AdapterContext.state.totalRetries = 0 + AdapterContext.state.lastError = null + AdapterContext.state.requestsMade.clear() + AdapterContext.capturedEvents.clear() + } + + call.respond(SuccessResponse(success = true)) + } + } + }.start(wait = true) +} diff --git a/sdk_compliance_adapter/build.gradle.kts b/sdk_compliance_adapter/build.gradle.kts new file mode 100644 index 00000000..01048ae5 --- /dev/null +++ b/sdk_compliance_adapter/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + kotlin("jvm") version "1.9.22" + application +} + +group = "com.posthog.compliance" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + // PostHog Server SDK (simpler for testing) + implementation(project(":posthog-server")) + + // Ktor server + val ktorVersion = "2.3.7" + implementation("io.ktor:ktor-server-core:$ktorVersion") + implementation("io.ktor:ktor-server-netty:$ktorVersion") + implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion") + implementation("io.ktor:ktor-serialization-gson:$ktorVersion") + + // Logging + implementation("ch.qos.logback:logback-classic:1.4.14") + + // OkHttp (for interceptor) + implementation("com.squareup.okhttp3:okhttp:4.12.0") +} + +application { + mainClass.set("com.posthog.compliance.AdapterKt") +} + +tasks.withType { + kotlinOptions { + jvmTarget = "11" + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml new file mode 100644 index 00000000..59561640 --- /dev/null +++ b/sdk_compliance_adapter/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + test-harness: + image: posthog-sdk-test-harness:latest + command: ["--sdk-type", "server"] + depends_on: + - adapter + - mock-server + environment: + - ADAPTER_URL=http://adapter:8080 + - MOCK_SERVER_URL=http://mock-server:8081 + networks: + - test-network + + adapter: + build: + context: .. + dockerfile: sdk_compliance_adapter/Dockerfile + ports: + - "8080:8080" + networks: + - test-network + environment: + - JAVA_TOOL_OPTIONS=-Xmx512m + + mock-server: + image: posthog-sdk-test-harness:latest + command: ["mock-server"] + ports: + - "8081:8081" + networks: + - test-network + +networks: + test-network: + driver: bridge diff --git a/settings.gradle.kts b/settings.gradle.kts index 6f5864ea..86b25fdf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ rootProject.name = "PostHog" include(":posthog") include(":posthog-android") include(":posthog-server") +include(":sdk_compliance_adapter") // samples include(":posthog-samples:posthog-android-sample") From c21ed25039daa6d6278b4dbe38a70206c6816cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sequeira?= Date: Fri, 2 Jan 2026 12:19:19 +0100 Subject: [PATCH 2/4] fix --- .github/workflows/sdk-compliance-tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml index 1055d559..f25c3423 100644 --- a/.github/workflows/sdk-compliance-tests.yml +++ b/.github/workflows/sdk-compliance-tests.yml @@ -15,6 +15,10 @@ on: - '.github/workflows/sdk-compliance-tests.yml' workflow_dispatch: +permissions: + contents: read + pull-requests: write + jobs: test-android-sdk: uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@main From dd9e94b2d717fc62f5aae7e7391b89fb71c9e6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sequeira?= Date: Fri, 2 Jan 2026 12:21:04 +0100 Subject: [PATCH 3/4] fix --- .github/workflows/sdk-compliance-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml index f25c3423..192b0c49 100644 --- a/.github/workflows/sdk-compliance-tests.yml +++ b/.github/workflows/sdk-compliance-tests.yml @@ -17,6 +17,7 @@ on: permissions: contents: read + packages: read pull-requests: write jobs: From 63fcedf7e00057c7b6d8518d35fe5db063b3e0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Sequeira?= Date: Fri, 2 Jan 2026 15:13:40 +0100 Subject: [PATCH 4/4] fix --- .../internal/GzipRequestInterceptor.kt | 2 +- sdk_compliance_adapter/Dockerfile | 13 +-- sdk_compliance_adapter/README.md | 97 +++++++++++++++++++ sdk_compliance_adapter/build.gradle.kts | 15 +-- sdk_compliance_adapter/docker-compose.yml | 26 ++--- .../kotlin/com/posthog/compliance}/adapter.kt | 56 ++++++++--- settings.gradle.kts | 1 - 7 files changed, 162 insertions(+), 48 deletions(-) create mode 100644 sdk_compliance_adapter/README.md rename sdk_compliance_adapter/{ => src/main/kotlin/com/posthog/compliance}/adapter.kt (84%) diff --git a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt index 313caf58..b5156927 100644 --- a/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt +++ b/posthog/src/main/java/com/posthog/internal/GzipRequestInterceptor.kt @@ -36,7 +36,7 @@ import java.io.IOException * This interceptor compresses the HTTP request body. Many webservers can't handle this! * @property config The Config */ -internal class GzipRequestInterceptor(private val config: PostHogConfig) : Interceptor { +public class GzipRequestInterceptor(private val config: PostHogConfig) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() diff --git a/sdk_compliance_adapter/Dockerfile b/sdk_compliance_adapter/Dockerfile index e6e9c0e6..78d1bdeb 100644 --- a/sdk_compliance_adapter/Dockerfile +++ b/sdk_compliance_adapter/Dockerfile @@ -4,17 +4,18 @@ WORKDIR /app COPY . . -RUN gradle :sdk_compliance_adapter:build --no-daemon +RUN echo 'include(":sdk_compliance_adapter")' >> settings.gradle.kts && \ + sed -i '/^dependencyResolutionManagement/i plugins {\n id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"\n}' settings.gradle.kts && \ + mkdir -p /root/.gradle && \ + echo 'org.gradle.java.installations.auto-download=true' > /root/.gradle/gradle.properties && \ + gradle :sdk_compliance_adapter:installDist --no-daemon FROM eclipse-temurin:11-jre WORKDIR /app -# Copy the built JAR -COPY --from=builder /app/sdk_compliance_adapter/build/libs/*.jar /app/adapter.jar +COPY --from=builder /app/sdk_compliance_adapter/build/install/sdk_compliance_adapter /app -# Expose port EXPOSE 8080 -# Run the adapter -CMD ["java", "-jar", "/app/adapter.jar"] +CMD ["/app/bin/sdk_compliance_adapter"] diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md new file mode 100644 index 00000000..e5aa4c23 --- /dev/null +++ b/sdk_compliance_adapter/README.md @@ -0,0 +1,97 @@ +# PostHog Android SDK Compliance Adapter + +This compliance adapter wraps the PostHog Android SDK and exposes a standardized HTTP API for automated compliance testing using the [PostHog SDK Test Harness](https://github.com/PostHog/posthog-sdk-test-harness). + +## Quick Start + +### Running Tests in CI (Recommended) + +Tests run automatically in GitHub Actions on: +- Push to `main`/`master` branch +- Pull requests +- Manual trigger via `workflow_dispatch` + +See `.github/workflows/sdk-compliance-tests.yml` + +### Running Tests Locally + +**Note:** Requires x86_64 architecture due to Java 8 dependency. On Apple Silicon, Docker will use emulation (slower but works). + +```bash +cd sdk_compliance_adapter +docker-compose up --build --abort-on-container-exit +``` + +## Architecture + +- **adapter.kt** - Ktor HTTP server implementing the compliance adapter API +- **TrackingInterceptor** - OkHttp interceptor for monitoring SDK HTTP requests +- **Dockerfile** - Multi-stage Docker build (requires x86_64 for Java 8) +- **docker-compose.yml** - Local test orchestration + +## SDK Modifications + +To enable request tracking, we added an optional `httpClient` parameter to `PostHogConfig`: + +```kotlin +// PostHogConfig.kt +public var httpClient: okhttp3.OkHttpClient? = null +``` + +This allows the test adapter to inject a custom OkHttpClient with tracking interceptors. **This change is fully backward compatible** - existing code works unchanged. + +## Implementation Details + +### HTTP Request Tracking + +The adapter injects a custom OkHttpClient that: +- Intercepts all `/batch/` requests +- Extracts event UUIDs from request bodies +- Tracks status codes, retry attempts, and event counts + +### Event Tracking + +Events are tracked via: +1. `beforeSend` hook - Captures UUIDs as events are queued +2. HTTP interceptor - Verifies UUIDs in outgoing requests + +### SDK Type + +The Android SDK uses **server SDK format**: +- Endpoint: `/batch/` +- Format: `{api_key, batch, sent_at}` + +Tests run with `--sdk-type server`. + +## Files Created + +``` +sdk_compliance_adapter/ +├── adapter.kt # Main adapter implementation +├── build.gradle.kts # Gradle build configuration +├── Dockerfile # Docker build (x86_64) +├── docker-compose.yml # Local test setup +├── README.md # This file +└── IMPLEMENTATION_NOTES.md # Detailed technical notes +``` + +## Changes to Core SDK + +### posthog/src/main/java/com/posthog/PostHogConfig.kt +- Added `httpClient: OkHttpClient?` parameter + +### posthog/src/main/java/com/posthog/internal/PostHogApi.kt +- Modified to use injected `httpClient` if provided + +### settings.gradle.kts +- Added `:sdk_compliance_adapter` module + +### .github/workflows/sdk-compliance-tests.yml +- GitHub Actions workflow for automated testing + +## References + +- [Test Harness Repository](https://github.com/PostHog/posthog-sdk-test-harness) +- [Adapter Guide](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/ADAPTER_GUIDE.md) +- [Contract Specification](https://github.com/PostHog/posthog-sdk-test-harness/blob/main/CONTRACT.yaml) +- [Browser SDK Adapter](https://github.com/PostHog/posthog-js/tree/main/packages/browser/sdk_compliance_adapter) (Reference) diff --git a/sdk_compliance_adapter/build.gradle.kts b/sdk_compliance_adapter/build.gradle.kts index 01048ae5..ba78aea2 100644 --- a/sdk_compliance_adapter/build.gradle.kts +++ b/sdk_compliance_adapter/build.gradle.kts @@ -1,18 +1,14 @@ plugins { - kotlin("jvm") version "1.9.22" + kotlin("jvm") application } group = "com.posthog.compliance" version = "1.0.0" -repositories { - mavenCentral() -} - dependencies { - // PostHog Server SDK (simpler for testing) - implementation(project(":posthog-server")) + // PostHog Core SDK + implementation(project(":posthog")) // Ktor server val ktorVersion = "2.3.7" @@ -42,3 +38,8 @@ java { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + +// Disable API validation for test adapter +tasks.matching { it.name == "apiCheck" || it.name == "apiDump" }.configureEach { + enabled = false +} diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 59561640..ff43cbce 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -1,19 +1,5 @@ -version: '3.8' - services: - test-harness: - image: posthog-sdk-test-harness:latest - command: ["--sdk-type", "server"] - depends_on: - - adapter - - mock-server - environment: - - ADAPTER_URL=http://adapter:8080 - - MOCK_SERVER_URL=http://mock-server:8081 - networks: - - test-network - - adapter: + sdk-adapter: build: context: .. dockerfile: sdk_compliance_adapter/Dockerfile @@ -24,13 +10,13 @@ services: environment: - JAVA_TOOL_OPTIONS=-Xmx512m - mock-server: - image: posthog-sdk-test-harness:latest - command: ["mock-server"] - ports: - - "8081:8081" + test-harness: + image: posthog-sdk-test-harness:debug + command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--sdk-type", "server", "--debug"] networks: - test-network + depends_on: + - sdk-adapter networks: test-network: diff --git a/sdk_compliance_adapter/adapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt similarity index 84% rename from sdk_compliance_adapter/adapter.kt rename to sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt index adeef4eb..5ca234dc 100644 --- a/sdk_compliance_adapter/adapter.kt +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt @@ -2,6 +2,7 @@ package com.posthog.compliance import com.posthog.PostHog import com.posthog.PostHogConfig +import com.posthog.internal.GzipRequestInterceptor import io.ktor.http.* import io.ktor.serialization.gson.* import io.ktor.server.application.* @@ -13,6 +14,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import okhttp3.Interceptor import okhttp3.Response +import com.posthog.internal.PostHogContext import java.util.* import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -87,11 +89,21 @@ data class SuccessResponse( val success: Boolean ) +// Minimal context for testing (provides $lib and $lib_version) +class TestPostHogContext(private val sdkName: String, private val sdkVersion: String) : PostHogContext { + override fun getStaticContext(): Map = emptyMap() + override fun getDynamicContext(): Map = emptyMap() + override fun getSdkInfo(): Map = mapOf( + "\$lib" to sdkName, + "\$lib_version" to sdkVersion + ) +} + // Global state object AdapterContext { val state = AdapterState() val lock = ReentrantLock() - var postHog: PostHog? = null + var postHog: com.posthog.PostHogInterface? = null val capturedEvents = mutableListOf() // Store UUIDs of captured events } @@ -214,21 +226,36 @@ fun main() { // Close existing instance if any AdapterContext.postHog?.close() - // Create OkHttpClient with tracking interceptor + // Create config first (needed for GzipRequestInterceptor) + val tempConfig = PostHogConfig(apiKey = req.api_key, host = req.host) + + // Create OkHttpClient with tracking interceptor first, then gzip + // Order matters: TrackingInterceptor reads uncompressed body, GzipInterceptor compresses it val httpClient = okhttp3.OkHttpClient.Builder() .addInterceptor(TrackingInterceptor()) + .addInterceptor(GzipRequestInterceptor(tempConfig)) .build() // Create new config + val flushIntervalMs = req.flush_interval_ms ?: 100 + val flushIntervalSeconds = maxOf(1, flushIntervalMs / 1000) // Min 1 second + val config = PostHogConfig( apiKey = req.api_key, host = req.host, - flushAt = req.flush_at ?: 1, // Flush immediately for tests - flushIntervalSeconds = (req.flush_interval_ms ?: 100) / 1000, + flushAt = req.flush_at ?: 1, + flushIntervalSeconds = flushIntervalSeconds, debug = true, - httpClient = httpClient + httpClient = httpClient, + preloadFeatureFlags = false // Disable to avoid 404 on /flags/ ) + // Set storage prefix for file-backed queue + config.storagePrefix = "/tmp/posthog-queue" + + // Set minimal context to provide $lib and $lib_version + config.context = TestPostHogContext("posthog-android", "3.28.0") + // Add beforeSend hook to track captured events config.addBeforeSend { event -> AdapterContext.lock.withLock { @@ -254,21 +281,24 @@ fun main() { println("[ADAPTER] Capturing event: ${req.event} for user: ${req.distinct_id}") val ph = AdapterContext.lock.withLock { - AdapterContext.postHog ?: run { - call.respond(HttpStatusCode.BadRequest, mapOf("error" to "SDK not initialized")) - return@post - } + AdapterContext.postHog } - // Set distinct_id - ph.identify(req.distinct_id) + if (ph == null) { + call.respond(HttpStatusCode.BadRequest, mapOf("error" to "SDK not initialized")) + return@post + } // Remember the count before capturing val beforeCount = AdapterContext.capturedEvents.size - // Capture event + // Capture event with distinct_id parameter (don't call identify separately) val properties = req.properties?.toMutableMap() ?: mutableMapOf() - ph.capture(req.event, properties) + ph.capture( + event = req.event, + distinctId = req.distinct_id, + properties = properties + ) // Get the UUID that was just captured (via beforeSend hook) val uuid = AdapterContext.lock.withLock { diff --git a/settings.gradle.kts b/settings.gradle.kts index 86b25fdf..6f5864ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,7 +18,6 @@ rootProject.name = "PostHog" include(":posthog") include(":posthog-android") include(":posthog-server") -include(":sdk_compliance_adapter") // samples include(":posthog-samples:posthog-android-sample")