Skip to main content

Warehouse contract

For deployments at Rung 4 of the adoption ladder, evaluation records land in a customer-controlled data warehouse. This page is the table contract between exd and the warehouse: the column schema, the ID-resolution model, partitioning recommendations, and the staging-aggregation pipeline shape that downstream analysis layers (DIY dbt, Eppo, Statsig WHN) consume.

This reference defines the contract; it does not commit exd to running any warehouse-side compute. The implementation ships DDL files and a reference dbt package, both of which are starting points the customer adapts.


The flag_evaluations table

The canonical warehouse table mirrors the EvaluationRecord schema field-for-field, plus warehouse-side metadata.

ColumnType (Snowflake)SourceNotes
evaluation_idVARCHAR(26)recordULID, unique. Primary key.
timestampTIMESTAMP_TZrecordUTC.
ingested_atTIMESTAMP_TZwarehouse loaderWhen the row landed. Used for late-arrival detection.
_partition_dateDATEderived from timestampUsed for partitioning.
schema_versionNUMBER(3)record
namespaceVARCHAR(63)recordFlag-namespace slug.
environmentVARCHAR(63)record
flag_keyVARCHAR(63)record
variant_keyVARCHAR(63)record
variant_valueVARIANTrecordThe typed-variant inline-table object.
evaluation_reasonVARCHAR(31)recordEnum value.
matched_rule_idVARCHAR(63)recordNullable.
manifest_versionNUMBER(20)record
manifest_etagVARCHAR(64)recordNullable.
unit_id_hashVARCHAR(64)recordNullable when no bucketing identifier was present.
unit_id_typeVARCHAR(31)recordNullable when unit_id_hash is null.
secondary_unit_idsOBJECTrecordMap. May be {}.
context_attributesOBJECTrecordMap. May be {}.
sdk_nameVARCHAR(63)record
sdk_versionVARCHAR(31)record
request_idVARCHAR(128)recordNullable.
trace_idVARCHAR(32)recordNullable.
span_idVARCHAR(16)recordNullable.

The columns are the same across warehouses; only the type spellings differ. Operators target Snowflake, BigQuery, Databricks, and Redshift today — match the column types in the table above to the closest native types in your engine of choice.


Partitioning and clustering recommendations

Recommendations are informative — customers may adapt to local cost and query patterns.

Snowflake

CREATE OR REPLACE TABLE flag_evaluations (
...
)
CLUSTER BY (namespace, _partition_date, flag_key);

Cluster on namespace first; most queries scope to a flag namespace. Cluster on _partition_date second to keep recent-window queries cheap.

BigQuery

CREATE TABLE flag_evaluations (
...
)
PARTITION BY _partition_date
CLUSTER BY namespace, flag_key, unit_id_hash;

Clustering on unit_id_hash enables efficient telemetry.user lookups at low cost.

Databricks

CREATE TABLE flag_evaluations (
...
)
PARTITIONED BY (_partition_date)
ZORDER BY (flag_key, unit_id_hash);

Redshift

CREATE TABLE flag_evaluations (
...
)
DISTKEY (unit_id_hash)
SORTKEY (_partition_date, flag_key);

DISTKEY on unit_id_hash keeps a single user's history co-located, accelerating the telemetry.user and per-user metric joins.


Loading patterns

Three supported loading patterns, in order of operational complexity:

The SDK's ObjectStoreSink writes Parquet to a bucket. A scheduled warehouse job (Snowpipe, BigQuery's LOAD DATA, Databricks Auto Loader, Redshift COPY) ingests on a cadence.

Failure isolation is excellent: a warehouse outage does not lose records because the bucket is durable.

Streaming insert

Records flow SDK → streaming bus → Snowflake Streaming / BigQuery Storage Write API / Databricks Auto Loader (continuous) / Redshift Streaming Ingestion. Latency drops from minutes to seconds.

Right when Rung 5 consumers also subscribe to the same bus.

Log shipper passthrough

If the customer already routes structured app logs into the warehouse, evaluation records ride that pipeline as a structured log type. No new infra; caveats around retention and any sampling applied at the log layer.


ID resolution

Experiments randomized on one ID type but analyzed on another (cookie pre-login, user post-login) require an ID-stitching table. The shape is documented; the table itself is customer-defined because the source of stitching is customer-specific.

Recommended shape:

CREATE TABLE id_stitching (
primary_unit_id_hash VARCHAR(64),
primary_unit_id_type VARCHAR(31),
secondary_unit_id_hash VARCHAR(64),
secondary_unit_id_type VARCHAR(31),
associated_at TIMESTAMP_TZ,
PRIMARY KEY (primary_unit_id_hash, primary_unit_id_type, secondary_unit_id_hash, secondary_unit_id_type)
);

The customer populates this from their identity service. Downstream analysis joins flag_evaluations.unit_id_hash against either column, depending on the unit-of-randomization choice for the analysis.

The secondary_unit_ids column on flag_evaluations is an SDK-side stitching hint: when the SDK call site has both a cookie and a user ID, it can carry both, and downstream analysis needs less reliance on the stitching table for those records.


Reference dbt package

The dbt-exd-experimentation package (separate repo, not part of this codebase) implements the staging-aggregation-summary model graph that turns the raw flag_evaluations table into experiment-ready outputs:

ModelPurpose
stg_first_exposuresFirst-exposure-per-(unit, flag) deduplication.
stg_metric_eventsConformed shape over customer-supplied metric SQL views.
int_unit_day_metricsPer-(unit, metric, date) rollups.
int_exposure_metric_joinExposures joined to metric events with timestamp filter.
mart_experiment_summaryGroup-level (variation × metric) summaries: count, sum, mean, variance.

The package is reference, not blessed: customers and vendors are expected to substitute their own models for any stage that needs different semantics.


Metric definitions

Metrics consumed by the analysis layer are SQL views over customer-side event tables. The conformed shape:

CREATE OR REPLACE VIEW mtr_checkout_conversion AS
SELECT
user_id_hash AS unit_id_hash,
'user' AS unit_id_type,
order_completed_at AS timestamp,
1 AS metric_value,
'checkout_conversion' AS metric_name
FROM analytics.orders
WHERE order_status = 'completed';

Required columns: unit_id_hash, unit_id_type, timestamp, metric_value (numeric), metric_name (string). Additional columns are permitted and pass through to downstream models unchanged.

This contract is the same one Eppo and Statsig WHN expect. A customer running the reference dbt package can drop in any vendor's analysis layer over the same metric views with no schema changes.


Cost considerations

Informative.

  • The _partition_date partition on every supported warehouse is the single most important cost lever. Queries scoped by --since MUST translate to a partition predicate, not a timestamp predicate alone, to avoid full-table scans.
  • The int_exposure_metric_join model can be expensive on large customers (unit-day metrics × millions of users). Consider materializing it as an incremental dbt model that only processes new dates.
  • Separate compute pools for interactive vs. scheduled queries. The reference dbt package emits scheduled jobs; ad-hoc exd telemetry export queries are interactive. Snowflake virtual warehouses, BigQuery slots, and Databricks SQL warehouses all support this.

See also