Skip to content
Merged
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,11 @@ An experiment binds a flag to a conversion metric; the flag's variants become
the arms and the traffic you already serve becomes the sample. Each arm gets a
rate, a 95 % Wilson interval drawn on a shared scale, and a two-proportion
z-test against the control — in words, with the exact numbers printed beside
every bar. The lifecycle is one-way (draft → running → stopped) because a
reopened measurement window would average two populations into an answer about
every bar. Above the table, the trailing week as hourly conversion-rate lines,
read straight off the pre-aggregated cells: hours with no traffic break the
line rather than plotting 0 %, because those are different facts. The
lifecycle is one-way (draft → running → stopped) because a reopened
measurement window would average two populations into an answer about
neither, and a stopped experiment's results stay exactly as they ended.

### The rest of it
Expand Down
2 changes: 2 additions & 0 deletions crates/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ use crate::routes::{
flagforge_storage::models::Experiment,
flagforge_storage::models::ExperimentState,
flagforge_storage::models::CounterKind,
flagforge_storage::models::VariantSeries,
flagforge_storage::models::SeriesPoint,
flagforge_core::ExperimentSpec,
flagforge_core::VariantCounts,
flagforge_core::VariantResult,
Expand Down
35 changes: 34 additions & 1 deletion crates/api/src/routes/experiments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ pub struct UpdateExperiment {
pub description: Option<Option<String>>,
}

/// How far back the results time series reaches. A week of hourly points is
/// 168 per arm — small on the wire — and the totals above the series already
/// cover all time, so a longer window would add bytes, not information.
const SERIES_WINDOW: chrono::TimeDelta = chrono::TimeDelta::days(7);

/// An experiment next to its judged results, one entry per variant.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ExperimentResults {
Expand All @@ -53,6 +58,10 @@ pub struct ExperimentResults {
/// appears in the counters but not on the flag — possible after a variant
/// rename — is appended rather than hidden.
pub results: Vec<flagforge_core::VariantResult>,
/// Hourly tallies over the trailing week, same variant order as `results`.
/// Hours nobody recorded are absent, not zero: the difference between "no
/// traffic" and "0 % converting" matters to a chart.
pub series: Vec<flagforge_storage::models::VariantSeries>,
}

#[utoipa::path(
Expand Down Expand Up @@ -391,11 +400,35 @@ pub async fn results(
let experiment =
experiments::find_experiment(&state.pool, environment.id, &experiment_key).await?;
let counts = experiments::experiment_counts(&state.pool, experiment.id).await?;
let since = chrono::Utc::now() - SERIES_WINDOW;
let series = experiments::hourly_counts(&state.pool, experiment.id, since).await?;

let judged =
flagforge_core::results(&experiment.control_variant, &in_flag_order(&experiment, counts));

Ok(Json(ExperimentResults { experiment, results: judged }))
Ok(Json(ExperimentResults {
results: judged,
series: series_in_flag_order(&experiment, series),
experiment,
}))
}

/// The series counterpart of [`in_flag_order`]: flag-variant order first,
/// extras appended. Arms without any point in the window are absent rather
/// than zero-filled — the chart treats no traffic as a gap, not as 0 %.
fn series_in_flag_order(
experiment: &Experiment,
mut series: Vec<flagforge_storage::models::VariantSeries>,
) -> Vec<flagforge_storage::models::VariantSeries> {
let mut ordered = Vec::with_capacity(series.len());
for variant in &experiment.variants {
if let Some(index) = series.iter().position(|s| s.variant == variant.key) {
ordered.push(series.swap_remove(index));
}
}
series.sort_by(|a, b| a.variant.cmp(&b.variant));
ordered.extend(series);
ordered
}

/// Orders counts by the flag's variant list, zero-filling arms that have no
Expand Down
24 changes: 17 additions & 7 deletions crates/api/src/seed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ pub async fn run(
/// operator actually encounters, on the first screen they open.
///
/// The counters are deterministic rather than random (seeding must be
/// reproducible), with a small modular wobble so the hourly cells do not all
/// carry the same number: the treatment converts at ~13%, the control at ~9%,
/// enough for the z-test to reach a verdict on a week of data.
/// reproducible), but shaped like traffic rather than like a formula: a daily
/// cosine cycle peaking mid-afternoon, with a small hash-style jitter on both
/// volume and rate so the results chart shows a plausible week, not a
/// sawtooth. The treatment converts at ~13.5%, the control at ~9%, enough for
/// the z-test to reach a verdict on a week of data.
async fn seed_experiment(
pool: &PgPool,
organization_id: Uuid,
Expand Down Expand Up @@ -333,10 +335,18 @@ async fn seed_experiment(
let mut deltas = Vec::new();
for hour in 0..HOURS {
let at = now - TimeDelta::hours(hour);
let exposures = 40 + (hour % 9) as u32;
for (variant, conversions) in
[("on", 5 + (hour % 3) as u32), ("off", 3 + (hour % 3) as u32)]
{
// Daily cycle: quiet around 02:00, peaking around 14:00.
let hour_of_day = ((24 + 14 - (hour % 24)) % 24) as f64;
let cycle = 1.0 + (hour_of_day * std::f64::consts::PI / 12.0).cos();
let exposures = (26.0 + 14.0 * cycle) as u32 + (hour * 37 % 7) as u32;

for (variant, rate) in [("on", 0.135), ("off", 0.091)] {
// ±1.6 points of deterministic jitter, uncorrelated between
// arms so the two lines do not move in lockstep.
let salt = if variant == "on" { 53 } else { 71 };
let jitter = ((hour * salt % 9) as f64 - 4.0) * 0.004;
let conversions = (f64::from(exposures) * (rate + jitter)).round() as u32;

deltas.push(CounterDelta {
experiment_key: EXPERIMENT_KEY.to_owned(),
variant: variant.to_owned(),
Expand Down
58 changes: 58 additions & 0 deletions crates/api/tests/experiments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,64 @@ async fn duplicate_cells_in_one_batch_are_summed_not_a_server_error(pool: PgPool
assert_eq!(results["results"][0]["exposures"], 6, "{results}");
}

#[sqlx::test(migrations = "../../migrations")]
async fn the_results_series_buckets_by_hour_and_windows_a_week(pool: PgPool) {
let app = TestApp::new(pool);
let tenant = app.bootstrap("Acme", "ada@acme.test").await;
app.create_flag(&tenant.token, "checkout.v2").await;

app.post(EXPERIMENTS, Some(&tenant.token), checkout_experiment())
.await
.expect(StatusCode::CREATED);
app.post(&format!("{EXPERIMENTS}/checkout-cta/start"), Some(&tenant.token), json!({}))
.await
.expect(StatusCode::OK);

// Two events inside one hour, one in the next, and one far outside the
// window — the first two must merge, the last must not appear at all.
let now = chrono::Utc::now();
let hour_a = now - chrono::TimeDelta::hours(2);
let hour_a_later = hour_a + chrono::TimeDelta::minutes(20);
let hour_b = now - chrono::TimeDelta::hours(1);
let ancient = now - chrono::TimeDelta::days(30);
let events = json!({
"events": [
{"experiment_key": "checkout-cta", "variant": "on", "kind": "exposure",
"count": 3, "at": hour_a},
{"experiment_key": "checkout-cta", "variant": "on", "kind": "exposure",
"count": 2, "at": hour_a_later},
{"experiment_key": "checkout-cta", "variant": "on", "kind": "conversion",
"count": 1, "at": hour_b},
{"experiment_key": "checkout-cta", "variant": "on", "kind": "exposure",
"count": 99, "at": ancient},
]
});
app.post("/api/v1/events", Some(&tenant.sdk_key), events).await.expect(StatusCode::ACCEPTED);

let body = app
.get(&format!("{EXPERIMENTS}/checkout-cta/results"), Some(&tenant.token))
.await
.expect(StatusCode::OK);

let series = body["series"].as_array().expect("series array");
assert_eq!(series.len(), 1, "only arms with points in the window appear: {body}");
assert_eq!(series[0]["variant"], "on");

let points = series[0]["points"].as_array().expect("points");
assert_eq!(points.len(), 2, "two hours touched inside the window: {body}");
assert_eq!(points[0]["exposures"], 5, "same-hour events must merge into one point");
assert_eq!(points[0]["conversions"], 0);
assert_eq!(points[1]["conversions"], 1);
// Oldest first, and truncated to the hour.
let first = points[0]["hour"].as_str().unwrap();
let second = points[1]["hour"].as_str().unwrap();
assert!(first < second, "points must come oldest first: {first} vs {second}");
assert!(first.contains(":00:00"), "hours must be truncated: {first}");

// The totals still count everything, window or not.
assert_eq!(body["results"][0]["exposures"], 104, "{body}");
}

#[sqlx::test(migrations = "../../migrations")]
async fn events_require_an_sdk_key(pool: PgPool) {
let app = TestApp::new(pool);
Expand Down
47 changes: 46 additions & 1 deletion crates/storage/src/experiments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use sqlx::PgPool;
use uuid::Uuid;

use crate::error::{FoundExt, Result, StorageError};
use crate::models::{CounterDelta, CounterKind, Experiment, ExperimentState};
use crate::models::{
CounterDelta, CounterKind, Experiment, ExperimentState, SeriesPoint, VariantSeries,
};

/// The columns every experiment read selects: the row itself plus the flag's
/// key and variants, which every consumer needs (see [`Experiment`]).
Expand Down Expand Up @@ -385,6 +387,49 @@ pub async fn experiment_counts(pool: &PgPool, experiment_id: Uuid) -> Result<Vec
Ok(by_variant)
}

/// Hourly tallies per variant since `since`, oldest hour first.
///
/// This is the read the pre-aggregated schema was shaped for: the window is a
/// range scan of the primary key, and its size is bounded by hours elapsed
/// times variants — the same number whether the experiment saw ten requests
/// or ten million.
pub async fn hourly_counts(
pool: &PgPool,
experiment_id: Uuid,
since: DateTime<Utc>,
) -> Result<Vec<VariantSeries>> {
let rows = sqlx::query!(
r#"
SELECT variant, kind, hour, count
FROM experiment_counters
WHERE experiment_id = $1 AND hour >= $2
ORDER BY variant, hour
"#,
experiment_id,
since,
)
.fetch_all(pool)
.await?;

let mut series: Vec<VariantSeries> = Vec::new();
for row in rows {
if series.last().is_none_or(|s| s.variant != row.variant) {
series.push(VariantSeries { variant: row.variant.clone(), points: Vec::new() });
}
let points = &mut series.last_mut().expect("just ensured").points;
if points.last().is_none_or(|p| p.hour != row.hour) {
points.push(SeriesPoint { hour: row.hour, exposures: 0, conversions: 0 });
}
let point = points.last_mut().expect("just ensured");
let count = u64::try_from(row.count).unwrap_or(0);
match row.kind.as_str() {
"exposure" => point.exposures += count,
_ => point.conversions += count,
}
}
Ok(series)
}

/// The running experiments of one environment, for snapshot loading.
pub(crate) async fn load_running_experiments(
pool: &PgPool,
Expand Down
16 changes: 16 additions & 0 deletions crates/storage/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,22 @@ impl CounterKind {
}
}

/// One hour of one variant's tallies, for the results time series.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)]
pub struct SeriesPoint {
pub hour: DateTime<Utc>,
pub exposures: u64,
pub conversions: u64,
}

/// One variant's hourly history. Hours nobody recorded are simply absent —
/// the consumer decides whether a gap is a zero or a break in the line.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, ToSchema)]
pub struct VariantSeries {
pub variant: String,
pub points: Vec<SeriesPoint>,
}

/// One increment on its way into `experiment_counters`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CounterDelta {
Expand Down
18 changes: 18 additions & 0 deletions crates/web/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,11 +226,29 @@ pub mod models {
pub version: i64,
}

/// One hour of one variant's tallies.
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
pub struct SeriesPoint {
pub hour: chrono::DateTime<chrono::Utc>,
pub exposures: u64,
pub conversions: u64,
}

/// One variant's hourly history over the server's window. Absent hours are
/// gaps — no traffic — which the chart renders as breaks, not zeroes.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct VariantSeries {
pub variant: String,
pub points: Vec<SeriesPoint>,
}

/// An experiment next to its judged results.
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ExperimentResults {
pub experiment: Experiment,
pub results: Vec<flagforge_core::VariantResult>,
#[serde(default)]
pub series: Vec<VariantSeries>,
}

#[derive(Debug, Clone, PartialEq, Deserialize)]
Expand Down
Loading
Loading