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.
| Column | Type (Snowflake) | Source | Notes |
|---|---|---|---|
evaluation_id | VARCHAR(26) | record | ULID, unique. Primary key. |
timestamp | TIMESTAMP_TZ | record | UTC. |
ingested_at | TIMESTAMP_TZ | warehouse loader | When the row landed. Used for late-arrival detection. |
_partition_date | DATE | derived from timestamp | Used for partitioning. |
schema_version | NUMBER(3) | record | |
namespace | VARCHAR(63) | record | Flag-namespace slug. |
environment | VARCHAR(63) | record | |
flag_key | VARCHAR(63) | record | |
variant_key | VARCHAR(63) | record | |
variant_value | VARIANT | record | The typed-variant inline-table object. |
evaluation_reason | VARCHAR(31) | record | Enum value. |
matched_rule_id | VARCHAR(63) | record | Nullable. |
manifest_version | NUMBER(20) | record | |
manifest_etag | VARCHAR(64) | record | Nullable. |
unit_id_hash | VARCHAR(64) | record | Nullable when no bucketing identifier was present. |
unit_id_type | VARCHAR(31) | record | Nullable when unit_id_hash is null. |
secondary_unit_ids | OBJECT | record | Map. May be {}. |
context_attributes | OBJECT | record | Map. May be {}. |
sdk_name | VARCHAR(63) | record | |
sdk_version | VARCHAR(31) | record | |
request_id | VARCHAR(128) | record | Nullable. |
trace_id | VARCHAR(32) | record | Nullable. |
span_id | VARCHAR(16) | record | Nullable. |
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:
Object-store staging (recommended default)
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:
| Model | Purpose |
|---|---|
stg_first_exposures | First-exposure-per-(unit, flag) deduplication. |
stg_metric_events | Conformed shape over customer-supplied metric SQL views. |
int_unit_day_metrics | Per-(unit, metric, date) rollups. |
int_exposure_metric_join | Exposures joined to metric events with timestamp filter. |
mart_experiment_summary | Group-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_datepartition on every supported warehouse is the single most important cost lever. Queries scoped by--sinceMUST translate to a partition predicate, not atimestamppredicate alone, to avoid full-table scans. - The
int_exposure_metric_joinmodel 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 exportqueries are interactive. Snowflake virtual warehouses, BigQuery slots, and Databricks SQL warehouses all support this.
See also
- evaluation-record — the on-the-wire shape that maps to this table.
- sinks §
ObjectStoreSink— the SDK side of the object-store staging path. reference/cli/exd/telemetry/export— the escape-hatch CLI that runs raw SQL against this table.