From 7e0a0a2b6afc2e6068e2a980f164b44553296e69 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:56:29 +0000 Subject: [PATCH] fix(aio): stop mixing arrayJoin and duplicated JSON-property reads with aggregates in personal spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ClickHouse "Not found column in block" failures on `/api/llm_analytics/@me/spend/`: - `_fetch_by_tool` ran `arrayJoin(splitByChar(...))` in the same SELECT as `sum()`/`avg()` over a materialized property column. ClickHouse evaluates the array join before aggregation, and the materialized projection didn't survive into the post-array-join block. - `_fetch_summary` inlined the same `product_filter` JSON-property comparison twice (once in `countIf`, once in `sumIf`), which triggered the same class of failure once ClickHouse optimizes the two occurrences differently. Both queries now read the property once into a plain column/alias in an inner subquery, and aggregate over that column outside — the standard fix for this ClickHouse limitation. The `test_personal_spend.ambr` snapshot for the two queries is hand-updated to match; I don't have ClickHouse available in this sandbox to run `@snapshot_clickhouse_queries` and regenerate it, so it needs verification in CI. Generated-By: PostHog Code Task-Id: 8d08e155-1921-43d8-a5ba-44195abc585c --- .../backend/api/personal_spend.py | 48 +++++++--- .../__snapshots__/test_personal_spend.ambr | 95 ++++++++++--------- 2 files changed, 86 insertions(+), 57 deletions(-) diff --git a/products/ai_observability/backend/api/personal_spend.py b/products/ai_observability/backend/api/personal_spend.py index 49b6417210ec..b48be4efffd6 100644 --- a/products/ai_observability/backend/api/personal_spend.py +++ b/products/ai_observability/backend/api/personal_spend.py @@ -531,15 +531,25 @@ def _truncate(rows: list[dict[str, Any]], limit: int) -> dict[str, Any]: def _fetch_summary( team: Team, email: str, from_dt: datetime.datetime, to_dt: datetime.datetime, product: str ) -> dict[str, Any]: + # `{product_filter}` is read into `is_scoped` once in the inner subquery rather than being + # inlined twice (in `countIf` and `sumIf`) against the outer aggregate. Duplicating a + # property-read expression like that across sibling aggregate clauses has caused ClickHouse + # to lose track of the projected column ("Not found column in block") once optimizations like + # PREWHERE pushdown treat the two textually-identical occurrences differently. query = parse_select( """ SELECT - countIf({product_filter}) AS scoped_event_count, - round(sumIf(toFloat(properties.$ai_total_cost_usd), {product_filter}), 6) AS scoped_cost_usd, + countIf(is_scoped) AS scoped_event_count, + round(sumIf(cost_usd, is_scoped), 6) AS scoped_cost_usd, count() AS event_count, - round(sum(toFloat(properties.$ai_total_cost_usd)), 6) AS total_cost_usd - FROM events - WHERE {event_in} AND {email_filter} AND {timestamp_filter} + round(sum(cost_usd), 6) AS total_cost_usd + FROM ( + SELECT + toFloat(properties.$ai_total_cost_usd) AS cost_usd, + {product_filter} AS is_scoped + FROM events + WHERE {event_in} AND {email_filter} AND {timestamp_filter} + ) """ ) result = execute_hogql_query( @@ -615,18 +625,30 @@ def _fetch_by_tool( # generation (e.g. "Bash,Read"). Split it so multi-tool generations contribute to # each individual tool row. nullIf restores the no-tool case (where the property # is NULL and coalesce produces an empty string) to a NULL bucket. + # + # The arrayJoin runs in an inner subquery, projecting the raw numeric columns + # alongside it, and the aggregates run outside. ClickHouse evaluates arrayJoin + # before aggregation, and mixing it into the same SELECT as an aggregate over a + # materialized property column has caused the materialized projection to go + # missing from the post-array-join block ("Not found column in block"). query = parse_select( """ SELECT - nullIf(arrayJoin(splitByChar(',', coalesce(properties.$ai_tools_called, ''))), '') AS tool, + tool, count() AS generation_count, - round(sum(toFloat(properties.$ai_total_cost_usd)), 6) AS cost_usd, - round(avg(toFloat(properties.$ai_input_tokens)), 0) AS avg_input_tokens - FROM events - WHERE equals(event, '$ai_generation') - AND {product_filter} - AND {email_filter} - AND {timestamp_filter} + round(sum(cost_usd), 6) AS cost_usd, + round(avg(input_tokens), 0) AS avg_input_tokens + FROM ( + SELECT + nullIf(arrayJoin(splitByChar(',', coalesce(properties.$ai_tools_called, ''))), '') AS tool, + toFloat(properties.$ai_total_cost_usd) AS cost_usd, + toFloat(properties.$ai_input_tokens) AS input_tokens + FROM events + WHERE equals(event, '$ai_generation') + AND {product_filter} + AND {email_filter} + AND {timestamp_filter} + ) GROUP BY tool ORDER BY cost_usd DESC LIMIT {limit} diff --git a/products/ai_observability/backend/api/test/__snapshots__/test_personal_spend.ambr b/products/ai_observability/backend/api/test/__snapshots__/test_personal_spend.ambr index 4606a957cfbf..b9583e97fc5e 100644 --- a/products/ai_observability/backend/api/test/__snapshots__/test_personal_spend.ambr +++ b/products/ai_observability/backend/api/test/__snapshots__/test_personal_spend.ambr @@ -2,29 +2,32 @@ # name: TestPersonalSpendQueries.test_empty_result_when_no_events ''' /* user_id:0 request:_snapshot_@me_spend_ */ - SELECT countIf(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, 'ai_product'), ''), 'null'), '^"|"$', ''), 'posthog_code'), 0)) AS scoped_event_count, - round(sumIf(accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_total_cost_usd'), ''), 'null'), '^"|"$', ''), 'Float64'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, 'ai_product'), ''), 'null'), '^"|"$', ''), 'posthog_code'), 0)), 6) AS scoped_cost_usd, + SELECT countIf(is_scoped) AS scoped_event_count, + round(sumIf(cost_usd, is_scoped), 6) AS scoped_cost_usd, count() AS event_count, - round(sum(accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_total_cost_usd'), ''), 'null'), '^"|"$', ''), 'Float64')), 6) AS total_cost_usd - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 99999) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - LEFT JOIN - (SELECT person.id AS id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'email'), ''), 'null'), '^"|"$', '') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - GROUP BY person.id - HAVING and(equals(argMax(person.is_deleted, person.version), 0), less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))))))) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), in(events.event, tuple('$ai_generation', '$ai_embedding')), ifNull(equals(events__person.properties___email, 'user1@posthog.com'), 0), and(greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')))) + round(sum(cost_usd), 6) AS total_cost_usd + FROM + (SELECT accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_total_cost_usd'), ''), 'null'), '^"|"$', ''), 'Float64') AS cost_usd, + ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, 'ai_product'), ''), 'null'), '^"|"$', ''), 'posthog_code'), 0) AS is_scoped + FROM events + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 99999) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) + LEFT JOIN + (SELECT person.id AS id, + replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'email'), ''), 'null'), '^"|"$', '') AS properties___email + FROM person + WHERE and(equals(person.team_id, 99999), in(tuple(person.id, person.version), + (SELECT person.id AS id, max(person.version) AS version + FROM person + WHERE equals(person.team_id, 99999) + GROUP BY person.id + HAVING and(equals(argMax(person.is_deleted, person.version), 0), less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))))))) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) + WHERE and(equals(events.team_id, 99999), in(events.event, tuple('$ai_generation', '$ai_embedding')), ifNull(equals(events__person.properties___email, 'user1@posthog.com'), 0), and(greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC'))))) LIMIT 100 SETTINGS readonly=2, max_execution_time=60, allow_experimental_object_type=1, @@ -42,29 +45,33 @@ # name: TestPersonalSpendQueries.test_empty_result_when_no_events.1 ''' /* user_id:0 request:_snapshot_@me_spend_ */ - SELECT nullIf(arrayJoin(splitByChar(',', coalesce(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_tools_called'), ''), 'null'), '^"|"$', ''), ''))), '') AS tool, + SELECT tool, count() AS generation_count, - round(sum(accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_total_cost_usd'), ''), 'null'), '^"|"$', ''), 'Float64')), 6) AS cost_usd, - round(avg(accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_input_tokens'), ''), 'null'), '^"|"$', ''), 'Float64')), 0) AS avg_input_tokens - FROM events - LEFT OUTER JOIN - (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, - person_distinct_id_overrides.distinct_id AS distinct_id - FROM person_distinct_id_overrides - WHERE equals(person_distinct_id_overrides.team_id, 99999) - GROUP BY person_distinct_id_overrides.distinct_id - HAVING equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) - LEFT JOIN - (SELECT person.id AS id, - replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'email'), ''), 'null'), '^"|"$', '') AS properties___email - FROM person - WHERE and(equals(person.team_id, 99999), in(tuple(person.id, person.version), - (SELECT person.id AS id, max(person.version) AS version - FROM person - WHERE equals(person.team_id, 99999) - GROUP BY person.id - HAVING and(equals(argMax(person.is_deleted, person.version), 0), less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))))))) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) - WHERE and(equals(events.team_id, 99999), equals(events.event, '$ai_generation'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, 'ai_product'), ''), 'null'), '^"|"$', ''), 'posthog_code'), 0), ifNull(equals(events__person.properties___email, 'user1@posthog.com'), 0), and(greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')))) + round(sum(cost_usd), 6) AS cost_usd, + round(avg(input_tokens), 0) AS avg_input_tokens + FROM + (SELECT nullIf(arrayJoin(splitByChar(',', coalesce(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_tools_called'), ''), 'null'), '^"|"$', ''), ''))), '') AS tool, + accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_total_cost_usd'), ''), 'null'), '^"|"$', ''), 'Float64') AS cost_usd, + accurateCastOrNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, '$ai_input_tokens'), ''), 'null'), '^"|"$', ''), 'Float64') AS input_tokens + FROM events + LEFT OUTER JOIN + (SELECT argMax(person_distinct_id_overrides.person_id, person_distinct_id_overrides.version) AS person_id, + person_distinct_id_overrides.distinct_id AS distinct_id + FROM person_distinct_id_overrides + WHERE equals(person_distinct_id_overrides.team_id, 99999) + GROUP BY person_distinct_id_overrides.distinct_id + HAVING equals(argMax(person_distinct_id_overrides.is_deleted, person_distinct_id_overrides.version), 0) SETTINGS optimize_aggregation_in_order=1) AS events__override ON equals(events.distinct_id, events__override.distinct_id) + LEFT JOIN + (SELECT person.id AS id, + replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'email'), ''), 'null'), '^"|"$', '') AS properties___email + FROM person + WHERE and(equals(person.team_id, 99999), in(tuple(person.id, person.version), + (SELECT person.id AS id, max(person.version) AS version + FROM person + WHERE equals(person.team_id, 99999) + GROUP BY person.id + HAVING and(equals(argMax(person.is_deleted, person.version), 0), less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))))))) SETTINGS optimize_aggregation_in_order=1) AS events__person ON equals(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), events__person.id) + WHERE and(equals(events.team_id, 99999), equals(events.event, '$ai_generation'), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(events.properties, 'ai_product'), ''), 'null'), '^"|"$', ''), 'posthog_code'), 0), ifNull(equals(events__person.properties___email, 'user1@posthog.com'), 0), and(greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC'))))) GROUP BY tool ORDER BY cost_usd DESC LIMIT 51 SETTINGS readonly=2,