Skip to content

Commit 7cc14b7

Browse files
Add multi-step conversion funnel support (#119)
* Add multi-step conversion funnel support Extends type: conversion metrics to support N-step funnels via a steps list field, generating per-entity BOOL_OR aggregation for each step. Backwards compatible with existing base_event/conversion_event usage. * Auto-update JSON schema * Fix: monotonic funnel counts, entrant-only totals, validate conversion_window * Fix: enforce chronological funnel order, normalize qualified filters * Fix: derive step timestamps sequentially from prior-step completion * Fix: alias timestamp expressions in step CTEs, parenthesize step predicates * Fix: normalize timestamp expression in step source subqueries * Fix: normalize step predicates to CTE source alias * Fix: resolve {model} in filters, add metric-named output column _strip_model_prefixes now replaces {model} with the actual model name before sqlglot parsing, so placeholder-based filters no longer leak literal {model} tokens into WHERE clauses. Multistep funnel output now includes the last-step count aliased to the metric name, so ORDER BY metric_name works without runtime errors. * Fix: use sqlglot for model prefix stripping to preserve string literals * Fix: parenthesize filters, always qualify step N with 's' alias * Fix: resolve entity through model dimension sql_expr, normalize for each CTE alias * Fix: qualify entity_sql_s with qualify_bare=True for step N --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 765ae8a commit 7cc14b7

5 files changed

Lines changed: 1385 additions & 20 deletions

File tree

sidemantic-schema.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,22 @@
629629
"description": "SQL expression or formula (accepts 'expr' as alias)",
630630
"title": "Sql"
631631
},
632+
"steps": {
633+
"anyOf": [
634+
{
635+
"items": {
636+
"type": "string"
637+
},
638+
"type": "array"
639+
},
640+
{
641+
"type": "null"
642+
}
643+
],
644+
"default": null,
645+
"description": "N-step funnel filter expressions (overrides base_event/conversion_event)",
646+
"title": "Steps"
647+
},
632648
"time_offset": {
633649
"anyOf": [
634650
{
@@ -1620,6 +1636,22 @@
16201636
"description": "SQL expression or formula (accepts 'expr' as alias)",
16211637
"title": "Sql"
16221638
},
1639+
"steps": {
1640+
"anyOf": [
1641+
{
1642+
"items": {
1643+
"type": "string"
1644+
},
1645+
"type": "array"
1646+
},
1647+
{
1648+
"type": "null"
1649+
}
1650+
],
1651+
"default": null,
1652+
"description": "N-step funnel filter expressions (overrides base_event/conversion_event)",
1653+
"title": "Steps"
1654+
},
16231655
"time_offset": {
16241656
"anyOf": [
16251657
{
@@ -2361,6 +2393,22 @@
23612393
"description": "SQL expression or formula (accepts 'expr' as alias)",
23622394
"title": "Sql"
23632395
},
2396+
"steps": {
2397+
"anyOf": [
2398+
{
2399+
"items": {
2400+
"type": "string"
2401+
},
2402+
"type": "array"
2403+
},
2404+
{
2405+
"type": "null"
2406+
}
2407+
],
2408+
"default": null,
2409+
"description": "N-step funnel filter expressions (overrides base_event/conversion_event)",
2410+
"title": "Steps"
2411+
},
23642412
"time_offset": {
23652413
"anyOf": [
23662414
{

sidemantic/adapters/sidemantic.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ def _parse_model(self, model_def: dict) -> Model | None:
296296
base_event=measure_def.get("base_event"),
297297
conversion_event=measure_def.get("conversion_event"),
298298
conversion_window=measure_def.get("conversion_window"),
299+
steps=measure_def.get("steps"),
299300
offset_window=measure_def.get("offset_window"),
300301
# Retention parameters
301302
cohort_event=measure_def.get("cohort_event"),
@@ -419,6 +420,7 @@ def _parse_metric(self, metric_def: dict) -> Metric | None:
419420
base_event=metric_def.get("base_event"),
420421
conversion_event=metric_def.get("conversion_event"),
421422
conversion_window=metric_def.get("conversion_window"),
423+
steps=metric_def.get("steps"),
422424
offset_window=metric_def.get("offset_window"),
423425
cohort_event=metric_def.get("cohort_event"),
424426
activity_event=metric_def.get("activity_event"),
@@ -585,6 +587,8 @@ def _export_model(self, model: Model) -> dict:
585587
measure_def["conversion_event"] = measure.conversion_event
586588
if measure.conversion_window:
587589
measure_def["conversion_window"] = measure.conversion_window
590+
if measure.steps:
591+
measure_def["steps"] = measure.steps
588592
if measure.offset_window:
589593
measure_def["offset_window"] = measure.offset_window
590594
# Retention parameters
@@ -675,6 +679,8 @@ def _export_metric(self, measure: Metric, graph) -> dict:
675679
result["conversion_event"] = measure.conversion_event
676680
if measure.conversion_window:
677681
result["conversion_window"] = measure.conversion_window
682+
if measure.steps:
683+
result["steps"] = measure.steps
678684
if measure.offset_window:
679685
result["offset_window"] = measure.offset_window
680686
if measure.cohort_event:

sidemantic/core/metric.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,16 @@ def validate_type_specific_fields(self):
200200
if self.type == "conversion":
201201
if not self.entity:
202202
raise ValueError("conversion metric requires 'entity' field")
203-
if not self.base_event:
204-
raise ValueError("conversion metric requires 'base_event' field")
205-
if not self.conversion_event:
206-
raise ValueError("conversion metric requires 'conversion_event' field")
203+
if self.steps:
204+
if len(self.steps) < 2:
205+
raise ValueError("conversion metric 'steps' requires at least 2 steps")
206+
if self.conversion_window:
207+
raise ValueError(
208+
"conversion metric cannot specify both 'steps' and 'conversion_window'; "
209+
"conversion_window is only supported for base_event/conversion_event funnels"
210+
)
211+
elif not self.base_event or not self.conversion_event:
212+
raise ValueError("conversion metric requires 'steps' or both 'base_event' and 'conversion_event'")
207213
if self.type == "retention":
208214
if not self.entity:
209215
raise ValueError("retention metric requires 'entity' field")
@@ -256,6 +262,9 @@ def validate_type_specific_fields(self):
256262
base_event: str | None = Field(None, description="Starting event filter")
257263
conversion_event: str | None = Field(None, description="Target event filter")
258264
conversion_window: str | None = Field(None, description="Conversion time window")
265+
steps: list[str] | None = Field(
266+
None, description="N-step funnel filter expressions (overrides base_event/conversion_event)"
267+
)
259268

260269
# Retention parameters
261270
cohort_event: str | None = Field(

0 commit comments

Comments
 (0)