1515
1616from collections .abc import Sequence
1717from dataclasses import dataclass
18+ from datetime import datetime
1819from typing import Any
1920
20- from experimentation .dataclasses import MetricSpec
21+ from experimentation .constants import EXPOSURE_EVENT_NAME
22+ from experimentation .dataclasses import ConversionBucket , MetricSpec
2123from experimentation .models import MetricAggregation
2224from experimentation .stats import VariantStats
2325
2426_FLOAT_VALUE = "toFloat64OrZero(m.value)"
2527
2628# Events are delivered at-least-once, so dedup keeps duplicates from inflating
27- # counts. Shared by the exposures and results queries.
29+ # counts. Shared by the exposures, results and conversions queries.
2830_EXPOSURES_CTE = """
2931WITH exposures AS (
3032 SELECT
5052GROUP BY variant"""
5153)
5254
53- _METRIC_JOIN = """ LEFT JOIN events AS m
55+
56+ def exposure_window_params (
57+ * ,
58+ environment_key : str ,
59+ feature_name : str ,
60+ window_start : datetime ,
61+ window_end : datetime ,
62+ ) -> dict [str , object ]:
63+ """The parameters ``_EXPOSURES_CTE`` binds, for every query that starts
64+ from it."""
65+ return {
66+ "environment_key" : environment_key ,
67+ "exposure_event" : EXPOSURE_EVENT_NAME ,
68+ "feature_name" : feature_name ,
69+ "window_start" : window_start ,
70+ "window_end" : window_end ,
71+ }
72+
73+
74+ def _metric_join (events_param : str ) -> str :
75+ """Join each exposed identity to its metric events. ``events_param`` names
76+ the bound list of event names, so a query can join only the events it
77+ aggregates."""
78+ return f""" LEFT JOIN events AS m
5479 ON m.identifier = e.identifier
5580 AND m.environment_key = %(environment_key)s
56- AND m.event IN %(metric_events )s
81+ AND m.event IN %({ events_param } )s
5782 AND m.timestamp >= %(window_start)s
5883 AND m.timestamp < %(window_end)s"""
5984
@@ -104,6 +129,23 @@ def outer_select(self) -> str:
104129 a = self ._alias
105130 return f"sum({ a } ) AS { a } _sum, sum({ a } * { a } ) AS { a } _sum_squares"
106131
132+ @property
133+ def conversion_alias (self ) -> str :
134+ return f"c{ self .index } "
135+
136+ def first_conversion_select (self ) -> str | None :
137+ """Per-identity timestamp of the first post-exposure conversion, NULL
138+ when the identity never converted. Same attribution condition as
139+ unit_select, so bucket totals add up to the metric's ``sum``.
140+
141+ None for value metrics: a count or sum accrues per event rather than
142+ once per identity, so a first-conversion timestamp can't chart it."""
143+ if self .spec .aggregation != MetricAggregation .OCCURRENCE :
144+ return None
145+ return (
146+ f"minIfOrNull(m.timestamp, { self ._condition ()} ) AS { self .conversion_alias } "
147+ )
148+
107149 def decode (self , n : int , row : Sequence [Any ], index : dict [str , int ]) -> VariantStats :
108150 """Read this slot's two columns (sum, sum_squares) from a row by name."""
109151 return VariantStats (
@@ -134,7 +176,7 @@ def build_query(self) -> str:
134176 e.variant AS variant,
135177 { unit_selects }
136178 FROM exposures AS e
137- { _METRIC_JOIN }
179+ { _metric_join ( "metric_events" ) }
138180 WHERE e.quarantined = 0
139181 GROUP BY e.identifier, e.variant
140182)
@@ -144,13 +186,92 @@ def build_query(self) -> str:
144186GROUP BY variant"""
145187 )
146188
147- def add_metric_params (self , params : dict [str , object ]) -> None :
148- """Add per-metric query parameters into an existing params dict."""
189+ def build_conversions_query (self , * , bucket_function : str ) -> str | None :
190+ """Per variant and charted metric, how many identities first converted
191+ in each time bucket. None when no attached metric charts, since there
192+ is nothing to query."""
193+ slots = self ._charted_slots
194+ if not slots :
195+ return None
196+
197+ first_conversion_selects = ",\n " .join (
198+ select for s in slots if (select := s .first_conversion_select ())
199+ )
200+ indexes = ", " .join (str (s .index ) for s in slots )
201+ aliases = ", " .join (s .conversion_alias for s in slots )
202+
203+ return (
204+ _EXPOSURES_CTE
205+ + f""",
206+ first_conversions AS (
207+ SELECT
208+ e.variant AS variant,
209+ { first_conversion_selects }
210+ FROM exposures AS e
211+ { _metric_join ("conversion_events" )}
212+ WHERE e.quarantined = 0
213+ GROUP BY e.identifier, e.variant
214+ )
215+ SELECT
216+ variant,
217+ metric_index,
218+ { bucket_function } (first_conversion, 'UTC') AS bucket,
219+ count() AS converted_identities
220+ FROM first_conversions
221+ ARRAY JOIN [{ indexes } ] AS metric_index, [{ aliases } ] AS first_conversion
222+ WHERE first_conversion IS NOT NULL
223+ GROUP BY variant, metric_index, bucket
224+ ORDER BY bucket"""
225+ )
226+
227+ @property
228+ def _charted_slots (self ) -> list [_MetricSlot ]:
229+ return [s for s in self ._slots if s .first_conversion_select () is not None ]
230+
231+ def params (
232+ self ,
233+ * ,
234+ environment_key : str ,
235+ feature_name : str ,
236+ window_start : datetime ,
237+ window_end : datetime ,
238+ ) -> dict [str , object ]:
239+ """Every parameter the results and conversions queries bind: the
240+ exposure window, each metric's event, and the event lists each join
241+ narrows to."""
242+ params = exposure_window_params (
243+ environment_key = environment_key ,
244+ feature_name = feature_name ,
245+ window_start = window_start ,
246+ window_end = window_end ,
247+ )
149248 if not self ._slots :
150- return
249+ return params
151250 params ["metric_events" ] = [s .spec .event for s in self ._slots ]
251+ params ["conversion_events" ] = [s .spec .event for s in self ._charted_slots ]
152252 for slot in self ._slots :
153253 params [f"metric_{ slot .index } _event" ] = slot .spec .event
254+ return params
255+
256+ def decode_conversion_rows (
257+ self , rows : Sequence [Sequence [Any ]], column_names : Sequence [str ]
258+ ) -> dict [int , list [ConversionBucket ]]:
259+ """Group conversions-query rows by the metric behind each slot index.
260+ Every charted metric gets a key, empty when nobody converted yet."""
261+ index = {name : position for position , name in enumerate (column_names )}
262+ buckets : dict [int , list [ConversionBucket ]] = {
263+ slot .spec .metric_id : [] for slot in self ._charted_slots
264+ }
265+ for row in rows :
266+ metric_id = self ._slots [int (row [index ["metric_index" ]])].spec .metric_id
267+ buckets [metric_id ].append (
268+ ConversionBucket (
269+ variant = str (row [index ["variant" ]]),
270+ bucket = row [index ["bucket" ]],
271+ converted_identities = int (row [index ["converted_identities" ]]),
272+ )
273+ )
274+ return buckets
154275
155276 def decode_rows (
156277 self , rows : list [Any ], column_names : Sequence [str ]
0 commit comments