diff --git a/.github/workflows/sdk-compliance-tests.yml b/.github/workflows/sdk-compliance-tests.yml new file mode 100644 index 00000000..192b0c49 --- /dev/null +++ b/.github/workflows/sdk-compliance-tests.yml @@ -0,0 +1,30 @@ +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: + +permissions: + contents: read + packages: read + pull-requests: write + +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/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/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..78d1bdeb --- /dev/null +++ b/sdk_compliance_adapter/Dockerfile @@ -0,0 +1,21 @@ +FROM --platform=linux/amd64 gradle:8.5-jdk17 AS builder + +WORKDIR /app + +COPY . . + +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 --from=builder /app/sdk_compliance_adapter/build/install/sdk_compliance_adapter /app + +EXPOSE 8080 + +CMD ["/app/bin/sdk_compliance_adapter"] 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/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 new file mode 100644 index 00000000..ba78aea2 --- /dev/null +++ b/sdk_compliance_adapter/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + kotlin("jvm") + application +} + +group = "com.posthog.compliance" +version = "1.0.0" + +dependencies { + // PostHog Core SDK + implementation(project(":posthog")) + + // 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 +} + +// 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 new file mode 100644 index 00000000..ff43cbce --- /dev/null +++ b/sdk_compliance_adapter/docker-compose.yml @@ -0,0 +1,23 @@ +services: + sdk-adapter: + build: + context: .. + dockerfile: sdk_compliance_adapter/Dockerfile + ports: + - "8080:8080" + networks: + - test-network + environment: + - JAVA_TOOL_OPTIONS=-Xmx512m + + 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: + driver: bridge diff --git a/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt new file mode 100644 index 00000000..5ca234dc --- /dev/null +++ b/sdk_compliance_adapter/src/main/kotlin/com/posthog/compliance/adapter.kt @@ -0,0 +1,377 @@ +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.* +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 com.posthog.internal.PostHogContext +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 +) + +// 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: com.posthog.PostHogInterface? = 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 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, + flushIntervalSeconds = flushIntervalSeconds, + debug = true, + 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 { + 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 + } + + 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 with distinct_id parameter (don't call identify separately) + val properties = req.properties?.toMutableMap() ?: mutableMapOf() + 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 { + 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) +}