From 09d5f79fe45ec38c8d197bf2a47cc11ec7ed51e5 Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Mon, 7 Sep 2026 15:46:14 -0400 Subject: [PATCH] ci: expand Rust SDK harness codec and blocking flags coverage --- .github/workflows/sdk-compliance-tests-v0.yml | 12 +- .github/workflows/sdk-compliance-tests-v1.yml | 18 ++- compliance/README.md | 68 +++++++++++ compliance/adapter/Cargo.toml | 5 +- compliance/adapter/src/blocking_client.rs | 71 ++++++++++++ compliance/adapter/src/main.rs | 106 +++++++++++++++--- compliance/blocking/Dockerfile | 11 ++ compliance/v0/docker-compose.yml | 4 +- compliance/v1/br.Dockerfile | 11 ++ compliance/v1/deflate.Dockerfile | 11 ++ compliance/v1/docker-compose.yml | 6 +- compliance/v1/zstd.Dockerfile | 11 ++ 12 files changed, 311 insertions(+), 23 deletions(-) create mode 100644 compliance/README.md create mode 100644 compliance/adapter/src/blocking_client.rs create mode 100644 compliance/blocking/Dockerfile create mode 100644 compliance/v1/br.Dockerfile create mode 100644 compliance/v1/deflate.Dockerfile create mode 100644 compliance/v1/zstd.Dockerfile diff --git a/.github/workflows/sdk-compliance-tests-v0.yml b/.github/workflows/sdk-compliance-tests-v0.yml index 0dabe2b8..04d6d321 100644 --- a/.github/workflows/sdk-compliance-tests-v0.yml +++ b/.github/workflows/sdk-compliance-tests-v0.yml @@ -30,6 +30,16 @@ jobs: with: adapter-dockerfile: compliance/v0/Dockerfile adapter-context: . - test-harness-version: "0.10.0" + test-harness-version: "1.0.0" report-name: rust-sdk-compliance-report-v0 concurrency: 10 + + test-rust-sdk-blocking-flags: + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@6d19abb9c81e2262dacbe340e7dddda9c871c178 + with: + adapter-dockerfile: compliance/blocking/Dockerfile + adapter-context: . + test-harness-version: "1.0.0" + suite: feature_flags + report-name: rust-sdk-compliance-report-blocking-flags + concurrency: 10 diff --git a/.github/workflows/sdk-compliance-tests-v1.yml b/.github/workflows/sdk-compliance-tests-v1.yml index 3ceab073..328dbbb4 100644 --- a/.github/workflows/sdk-compliance-tests-v1.yml +++ b/.github/workflows/sdk-compliance-tests-v1.yml @@ -26,10 +26,22 @@ permissions: jobs: test-rust-sdk-v1: + strategy: + fail-fast: false + matrix: + include: + - codec: gzip + dockerfile: compliance/v1/Dockerfile + - codec: deflate + dockerfile: compliance/v1/deflate.Dockerfile + - codec: br + dockerfile: compliance/v1/br.Dockerfile + - codec: zstd + dockerfile: compliance/v1/zstd.Dockerfile uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@6d19abb9c81e2262dacbe340e7dddda9c871c178 with: - adapter-dockerfile: compliance/v1/Dockerfile + adapter-dockerfile: ${{ matrix.dockerfile }} adapter-context: . - test-harness-version: "0.10.0" - report-name: rust-sdk-compliance-report-v1 + test-harness-version: "1.0.0" + report-name: rust-sdk-compliance-report-v1-${{ matrix.codec }} concurrency: 10 diff --git a/compliance/README.md b/compliance/README.md new file mode 100644 index 00000000..9bf8f207 --- /dev/null +++ b/compliance/README.md @@ -0,0 +1,68 @@ +# Rust SDK compliance profiles + +CI uses harness **1.0.0** with server-wire assertions and parallel test isolation. +The adapter links the in-tree `posthog-rs` crate and forwards capture, timestamp, +flag evaluation, flush and shutdown through its public APIs. `test-harness` +enables the existing queue-depth observation and test-ID headers. + +| Profile | SDK entry | Selection | Expected cases | +| --- | --- | --- | ---: | +| V0 gzip | Async client (default features) | Capture V0 + flags | 47 | +| V1 gzip | Async client + `capture-v1` | Capture V1 + flags | 112 | +| V1 deflate / br / zstd | Async client + `capture-v1` | Capture V1 + flags, per codec | 111 each | +| Blocking flags | Blocking client (`--no-default-features`) | `feature_flags` | 17 | + +Both capture suites include non-UTC timestamp override coverage. The blocking +profile uses `Client::evaluate_flags` followed by the SDK snapshot's `get_flag`; +request construction, retries, result parsing and `$feature_flag_called` remain +SDK-owned. Its ordinary and side-effect captures use the shared background +transport, so it does not duplicate the full capture suite. Blocking operations +and client destruction run in Tokio's blocking context. + +`COMPRESSION` selects a process codec; `/init` only enables that codec when +`enable_compression` is true. Use `gzip` for V0; the other codecs require V1. +The disabled-compression assertion runs within each V1 codec profile. Separate +Dockerfiles configure the V1 launches because the pinned reusable workflow does +not accept adapter environment or build arguments. Each profile has a distinct +artifact name and health name (used for the workflow's PR report comment). +Compliance remains advisory; ordinary SDK build/test gates are unchanged. + +## Local runs + +From the repository root: + +```sh +cargo build --locked --manifest-path compliance/adapter/Cargo.toml +COMPRESSION=gzip PORT=18240 target/debug/sdk-adapter + +# Build V1 instead: +cargo build --locked --manifest-path compliance/adapter/Cargo.toml --features capture-v1 +# Launch with COMPRESSION=gzip, deflate, br or zstd. + +# Build the blocking flags profile instead: +cargo build --locked --manifest-path compliance/adapter/Cargo.toml --no-default-features +``` + +Use the adapter manifest when selecting features: the workspace's Cargo resolver +applies root-level `--no-default-features` to the root package, not the adapter. + +Run harness 1.0.0 against that listener with `--sdk-type server` and +`--concurrency 10`; add `--suite feature_flags` for the blocking build. Choose a +free mock port with `--mock-port` and `--mock-url`. `PORT` defaults to 8080. +The V0/V1 Compose files run capture and flags; the V1 file accepts +`COMPRESSION=deflate docker compose -f compliance/v1/docker-compose.yml up --build` +(and similarly `br` or `zstd`). + +## Coverage limits + +Non-gzip V1 codec cases check **Content-Encoding headers only**. Harness 1.0.0 +does not decode those bodies or provide meaningful compressed per-event results; +these passes are not decoded-delivery or partial-response certification. +Dedicated AI and local flag evaluation are not covered. + +Wire-suite success does not establish full adapter-interface compliance. The +existing capture response UUID is separate from the SDK-generated wire UUID; +health reports the adapter package version; state uses queue-depth estimates +rather than confirmed deliveries and retries. Flush forwards the SDK's single +attempt barrier, not a terminal retry-completion barrier. These interface fields +are not asserted by the selected wire definitions. diff --git a/compliance/adapter/Cargo.toml b/compliance/adapter/Cargo.toml index bc2fd9e2..93fd4b65 100644 --- a/compliance/adapter/Cargo.toml +++ b/compliance/adapter/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -posthog-rs = { path = "../..", features = ["async-client", "test-harness"] } +posthog-rs = { path = "../..", default-features = false, features = ["test-harness"] } axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } @@ -14,4 +14,7 @@ uuid = { version = "1", features = ["v7"] } chrono = "0.4" [features] +default = ["async-client", "error-tracking"] +async-client = ["posthog-rs/async-client"] +error-tracking = ["posthog-rs/error-tracking"] capture-v1 = ["posthog-rs/capture-v1"] diff --git a/compliance/adapter/src/blocking_client.rs b/compliance/adapter/src/blocking_client.rs new file mode 100644 index 00000000..151ff3d5 --- /dev/null +++ b/compliance/adapter/src/blocking_client.rs @@ -0,0 +1,71 @@ +//! Run the public blocking client outside Tokio's asynchronous worker context. +//! Capture still uses the SDK's shared background transport. + +use posthog_rs::{ClientOptions, Error, EvaluateFlagsOptions, FeatureFlagEvaluations}; +use std::ops::Deref; +use tokio::task::block_in_place; + +pub struct Client(Option); + +pub async fn client(options: ClientOptions) -> Client { + Client(Some(block_in_place(|| posthog_rs::client(options)))) +} + +impl Client { + pub async fn evaluate_flags( + &self, + distinct_id: String, + options: EvaluateFlagsOptions, + ) -> Result { + block_in_place(|| self.deref().evaluate_flags(distinct_id, options)) + } + + pub async fn flush(&self) { + block_in_place(|| self.deref().flush()); + } + + pub async fn shutdown(&self) { + block_in_place(|| self.deref().shutdown()); + } +} + +impl Deref for Client { + type Target = posthog_rs::Client; + + fn deref(&self) -> &Self::Target { + self.0.as_ref().expect("client is alive") + } +} + +impl Drop for Client { + fn drop(&mut self) { + // reqwest's blocking client must also be destroyed outside async context, + // including when the last reference is released by reset or re-init. + block_in_place(|| drop(self.0.take())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn blocking_client_lifecycle_in_async_server() { + let options = posthog_rs::ClientOptionsBuilder::default() + .api_key(String::new()) + .host("http://127.0.0.1:0".to_string()) + .build() + .unwrap(); + let client = client(options).await; + client.capture(posthog_rs::Event::new("disabled", "test")); + client.flush().await; + assert_eq!(client.pending_events(), 0); + let flags = client + .evaluate_flags("test".to_string(), EvaluateFlagsOptions::default()) + .await + .unwrap(); + assert!(flags.get_flag("test").is_none()); + client.shutdown().await; + drop(client); + } +} diff --git a/compliance/adapter/src/main.rs b/compliance/adapter/src/main.rs index 190ff8f6..23bcb57f 100644 --- a/compliance/adapter/src/main.rs +++ b/compliance/adapter/src/main.rs @@ -11,7 +11,14 @@ use axum::{ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; -use posthog_rs::{CaptureCompression, Client, ClientOptionsBuilder, EvaluateFlagsOptions, Event}; +use posthog_rs::{CaptureCompression, ClientOptionsBuilder, EvaluateFlagsOptions, Event}; + +#[cfg(not(feature = "async-client"))] +mod blocking_client; +#[cfg(not(feature = "async-client"))] +use blocking_client::{client, Client}; +#[cfg(feature = "async-client")] +use posthog_rs::{client, Client}; const SUPPORTS_PARALLEL: bool = true; @@ -104,7 +111,7 @@ impl TestIdParam { #[derive(Serialize)] struct HealthResponse { - sdk_name: &'static str, + sdk_name: String, sdk_version: &'static str, adapter_version: &'static str, capabilities: Vec, @@ -140,6 +147,21 @@ fn compression_capability(c: CaptureCompression) -> &'static str { } } +fn profile_name(compression: Option) -> String { + let protocol = if cfg!(feature = "capture-v1") { + "v1" + } else { + "v0" + }; + let runtime = if cfg!(feature = "async-client") { + "async" + } else { + "blocking" + }; + let codec = compression.map(compression_capability).unwrap_or("none"); + format!("posthog-rs-{protocol}-{runtime}-{codec}") +} + async fn health(State(state): State) -> Json { let mut capabilities: Vec = Vec::new(); if cfg!(feature = "capture-v1") { @@ -151,13 +173,8 @@ async fn health(State(state): State) -> Json { capabilities.push(compression_capability(algo).to_string()); } Json(HealthResponse { - // Per-build name so the v0 and v1 compliance jobs post distinct PR - // comments instead of overwriting one shared report. - sdk_name: if cfg!(feature = "capture-v1") { - "posthog-rs-v1" - } else { - "posthog-rs-v0" - }, + // The reusable workflow keys PR comments by SDK name, not artifact name. + sdk_name: profile_name(state.compression), sdk_version: env!("CARGO_PKG_VERSION"), adapter_version: env!("CARGO_PKG_VERSION"), capabilities, @@ -213,7 +230,7 @@ async fn init( match builder.build() { Ok(opts) => { - let client = posthog_rs::client(opts).await; + let client = client(opts).await; s.client = Some(Arc::new(client)); Json(serde_json::json!({ "success": true })).into_response() } @@ -471,9 +488,72 @@ async fn main() { .route("/reset", post(reset)) .with_state(state); - let listener = tokio::net::TcpListener::bind("0.0.0.0:8080") + let port: u16 = std::env::var("PORT") + .unwrap_or_else(|_| "8080".to_string()) + .parse() + .expect("PORT must be a valid port number"); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::UNSPECIFIED, port)) .await - .expect("failed to bind to port 8080"); - eprintln!("Listening on 0.0.0.0:8080"); + .expect("failed to bind adapter listener"); + eprintln!("Listening on 0.0.0.0:{port}"); axum::serve(listener, app).await.expect("server error"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn health_identifies_each_codec_profile() { + let mut names = std::collections::HashSet::new(); + for compression in [ + None, + Some(CaptureCompression::Gzip), + Some(CaptureCompression::Deflate), + Some(CaptureCompression::Br), + Some(CaptureCompression::Zstd), + ] { + if !cfg!(feature = "capture-v1") + && matches!( + compression, + Some( + CaptureCompression::Deflate + | CaptureCompression::Br + | CaptureCompression::Zstd + ) + ) + { + continue; + } + let Json(response) = health(State(AppState { + instances: Arc::new(Mutex::new(HashMap::new())), + compression, + })) + .await; + assert!(names.insert(response.sdk_name.clone())); + assert!(response + .sdk_name + .contains(if cfg!(feature = "async-client") { + "-async-" + } else { + "-blocking-" + })); + assert!(response + .capabilities + .contains(&if cfg!(feature = "capture-v1") { + "capture_v1".to_string() + } else { + "capture_v0".to_string() + })); + if let Some(codec) = compression { + assert!(response + .capabilities + .contains(&compression_capability(codec).to_string())); + } + assert_eq!( + response.capabilities.len(), + 1 + usize::from(compression.is_some()) + ); + } + } +} diff --git a/compliance/blocking/Dockerfile b/compliance/blocking/Dockerfile new file mode 100644 index 00000000..31cb0243 --- /dev/null +++ b/compliance/blocking/Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --locked --release --manifest-path compliance/adapter/Cargo.toml --no-default-features + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/sdk-adapter /usr/local/bin/ +ENV COMPRESSION=gzip +EXPOSE 8080 +CMD ["sdk-adapter"] diff --git a/compliance/v0/docker-compose.yml b/compliance/v0/docker-compose.yml index 0feedd6d..f87082b1 100644 --- a/compliance/v0/docker-compose.yml +++ b/compliance/v0/docker-compose.yml @@ -11,8 +11,8 @@ services: - test-network test-harness: - image: ghcr.io/posthog/sdk-test-harness:0.10.0 - command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--concurrency", "10", "--suite", "capture"] + image: ghcr.io/posthog/sdk-test-harness:1.0.0 + command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--concurrency", "10"] networks: - test-network depends_on: diff --git a/compliance/v1/br.Dockerfile b/compliance/v1/br.Dockerfile new file mode 100644 index 00000000..1213215c --- /dev/null +++ b/compliance/v1/br.Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release -p sdk-adapter --features sdk-adapter/capture-v1 + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/sdk-adapter /usr/local/bin/ +ENV COMPRESSION=br +EXPOSE 8080 +CMD ["sdk-adapter"] diff --git a/compliance/v1/deflate.Dockerfile b/compliance/v1/deflate.Dockerfile new file mode 100644 index 00000000..dbaa6bc0 --- /dev/null +++ b/compliance/v1/deflate.Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release -p sdk-adapter --features sdk-adapter/capture-v1 + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/sdk-adapter /usr/local/bin/ +ENV COMPRESSION=deflate +EXPOSE 8080 +CMD ["sdk-adapter"] diff --git a/compliance/v1/docker-compose.yml b/compliance/v1/docker-compose.yml index 2877c373..081c7ead 100644 --- a/compliance/v1/docker-compose.yml +++ b/compliance/v1/docker-compose.yml @@ -4,15 +4,15 @@ services: context: ../.. dockerfile: compliance/v1/Dockerfile environment: - - COMPRESSION=gzip + - COMPRESSION=${COMPRESSION:-gzip} ports: - "8080:8080" networks: - test-network test-harness: - image: ghcr.io/posthog/sdk-test-harness:0.10.0 - command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--concurrency", "10", "--suite", "capture_v1"] + image: ghcr.io/posthog/sdk-test-harness:1.0.0 + command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081", "--concurrency", "10"] networks: - test-network depends_on: diff --git a/compliance/v1/zstd.Dockerfile b/compliance/v1/zstd.Dockerfile new file mode 100644 index 00000000..d8b182b7 --- /dev/null +++ b/compliance/v1/zstd.Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1-bookworm AS builder +WORKDIR /app +COPY . . +RUN cargo build --release -p sdk-adapter --features sdk-adapter/capture-v1 + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/sdk-adapter /usr/local/bin/ +ENV COMPRESSION=zstd +EXPOSE 8080 +CMD ["sdk-adapter"]