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
12 changes: 11 additions & 1 deletion .github/workflows/sdk-compliance-tests-v0.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 15 additions & 3 deletions .github/workflows/sdk-compliance-tests-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions compliance/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion compliance/adapter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"]
71 changes: 71 additions & 0 deletions compliance/adapter/src/blocking_client.rs
Original file line number Diff line number Diff line change
@@ -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<posthog_rs::Client>);

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<FeatureFlagEvaluations, Error> {
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);
}
}
106 changes: 93 additions & 13 deletions compliance/adapter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String>,
Expand Down Expand Up @@ -140,6 +147,21 @@ fn compression_capability(c: CaptureCompression) -> &'static str {
}
}

fn profile_name(compression: Option<CaptureCompression>) -> 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<AppState>) -> Json<HealthResponse> {
let mut capabilities: Vec<String> = Vec::new();
if cfg!(feature = "capture-v1") {
Expand All @@ -151,13 +173,8 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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())
);
}
}
}
11 changes: 11 additions & 0 deletions compliance/blocking/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions compliance/v0/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions compliance/v1/br.Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
11 changes: 11 additions & 0 deletions compliance/v1/deflate.Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading