diff --git a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.matchingUsersLink.test.tsx b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.matchingUsersLink.test.tsx
index 8a61d6a3b5da..47f0a887a878 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.matchingUsersLink.test.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.matchingUsersLink.test.tsx
@@ -20,7 +20,10 @@ jest.mock('lib/components/AutoSizer', () => ({
// `aggregation_group_type_index` is set at flag level only, so the condition has to inherit it. That
// resolution happens at the call site and is invisible to `matchingActorsUrl`'s own tests.
-function buildFilters(aggregationGroupTypeIndex?: number): FeatureFlagType['filters'] {
+function buildFilters(
+ aggregationGroupTypeIndex?: number,
+ conditionGroupTypeIndex?: number
+): FeatureFlagType['filters'] {
const group: FeatureFlagGroupType = {
properties: [
{
@@ -33,6 +36,7 @@ function buildFilters(aggregationGroupTypeIndex?: number): FeatureFlagType['filt
rollout_percentage: 100,
variant: null,
sort_key: 'group-1',
+ aggregation_group_type_index: conditionGroupTypeIndex,
}
return {
groups: [group],
@@ -59,7 +63,10 @@ describe('feature flag release conditions matching users link', () => {
},
post: {
'/api/environments/:team/query': { results: [] },
- '/api/projects/:team/feature_flags/user_blast_radius': () => [200, { affected: 0, total: 2 }],
+ '/api/projects/:team/feature_flags/user_blast_radius': () => [
+ 200,
+ { affected: 0, total: 2, activity_window_days: 60 },
+ ],
},
})
})
@@ -105,6 +112,25 @@ describe('feature flag release conditions matching users link', () => {
],
] as const
+ // The guard has to read the resolved index: reading the flag-level field instead puts the
+ // person tooltip beside a group count whenever a condition overrides an unset flag level.
+ const tooltipCases = [
+ ['flag-level person targeting', buildFilters(), true],
+ ['condition overrides an unset flag level with a group', buildFilters(undefined, 0), false],
+ ['flag-level group targeting', buildFilters(0), false],
+ ] as const
+
+ test.each(tooltipCases)('shows the estimate tooltip for %s', async (_name, filters, expectTooltip) => {
+ const { container, getByText } = render(
+
+
+
+ )
+
+ await waitFor(() => expect(getByText(/Filters match:/)).toBeInTheDocument())
+ expect(!!container.querySelector('[data-attr="matching-estimate-info"]')).toBe(expectTooltip)
+ })
+
test.each(cases)(
'%s links a condition to the matching actors once counts load',
async (_name, component, linkText, expectedPath) => {
diff --git a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx
index 5d80c82471de..24773c74cefe 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditions.tsx
@@ -48,7 +48,7 @@ import { FractionalRolloutWarning } from 'products/feature_flags/frontend/Fracti
import { resolveAggregationGroupTypeIndex } from './aggregation'
import { BlastRadiusErrorMessage } from './BlastRadiusErrorMessage'
-import { EARLY_ACCESS_GROUP_TARGETING_DISABLED_REASON, MATCHING_ESTIMATE_TOOLTIP } from './constants'
+import { EARLY_ACCESS_GROUP_TARGETING_DISABLED_REASON, matchingEstimateTooltip } from './constants'
import { featureFlagLogic } from './featureFlagLogic'
import {
FeatureFlagReleaseConditionsLogicProps,
@@ -144,6 +144,7 @@ export function FeatureFlagReleaseConditions({
properties,
filterGroups,
getDistinctIdName,
+ activityWindowDays,
} = useValues(releaseConditionsLogic)
const {
@@ -507,9 +508,15 @@ export function FeatureFlagReleaseConditions({
Filters match: ~{pluralize(affected, singularName, pluralName)}
- {filters.aggregation_group_type_index == null && (
-
-
+ {resolvedGroupTypeIndex == null && (
+
+
)}
diff --git a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsCollapsible.tsx b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsCollapsible.tsx
index b431bfb02653..29fe6914016f 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsCollapsible.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsCollapsible.tsx
@@ -75,7 +75,7 @@ import { FractionalRolloutWarning } from 'products/feature_flags/frontend/Fracti
import { resolveAggregationGroupTypeIndex } from './aggregation'
import { BlastRadiusErrorMessage } from './BlastRadiusErrorMessage'
-import { EARLY_ACCESS_GROUP_TARGETING_DISABLED_REASON, MATCHING_ESTIMATE_TOOLTIP } from './constants'
+import { EARLY_ACCESS_GROUP_TARGETING_DISABLED_REASON, matchingEstimateTooltip } from './constants'
import { EarlyExitIndicator } from './EarlyExitIndicator'
import { FeatureFlagConditionDragHandle } from './FeatureFlagConditionDragHandle'
import { FeatureFlagConditionWarning } from './FeatureFlagConditionWarning'
@@ -348,6 +348,7 @@ interface ConditionProps {
affectedCounts: Record
totalCounts: Record
blastRadiusErrors: Record
+ activityWindowDays: number | null
calculateBlastRadiusForCondition: (
sortKey: string,
properties: AnyPropertyFilter[] | undefined,
@@ -426,6 +427,7 @@ const ConditionContent = ({
affectedCounts,
totalCounts,
blastRadiusErrors,
+ activityWindowDays,
calculateBlastRadiusForCondition,
aggregationTargetName,
getDistinctIdName,
@@ -751,10 +753,15 @@ const ConditionContent = ({
{resolvedGroupTypeIndex == null && (
-
+
)}
@@ -896,6 +903,7 @@ export function FeatureFlagReleaseConditionsCollapsible({
affectedCounts,
totalCounts,
blastRadiusErrors,
+ activityWindowDays,
aggregationTargetName,
getDistinctIdName,
getFlagKey,
@@ -1299,6 +1307,7 @@ export function FeatureFlagReleaseConditionsCollapsible({
affectedCounts={affectedCounts}
totalCounts={totalCounts}
blastRadiusErrors={blastRadiusErrors}
+ activityWindowDays={activityWindowDays}
calculateBlastRadiusForCondition={calculateBlastRadiusForCondition}
aggregationTargetName={aggregationTargetName}
getDistinctIdName={getDistinctIdName}
@@ -1380,6 +1389,7 @@ export function FeatureFlagReleaseConditionsCollapsible({
affectedCounts={affectedCounts}
totalCounts={totalCounts}
blastRadiusErrors={blastRadiusErrors}
+ activityWindowDays={activityWindowDays}
calculateBlastRadiusForCondition={calculateBlastRadiusForCondition}
aggregationTargetName={aggregationTargetName}
getDistinctIdName={getDistinctIdName}
@@ -1435,6 +1445,7 @@ export function FeatureFlagReleaseConditionsCollapsible({
affectedCounts={affectedCounts}
totalCounts={totalCounts}
blastRadiusErrors={blastRadiusErrors}
+ activityWindowDays={activityWindowDays}
calculateBlastRadiusForCondition={calculateBlastRadiusForCondition}
aggregationTargetName={aggregationTargetName}
getDistinctIdName={getDistinctIdName}
@@ -1472,6 +1483,7 @@ export function FeatureFlagReleaseConditionsCollapsible({
affectedCounts={affectedCounts}
totalCounts={totalCounts}
blastRadiusErrors={blastRadiusErrors}
+ activityWindowDays={activityWindowDays}
calculateBlastRadiusForCondition={calculateBlastRadiusForCondition}
aggregationTargetName={aggregationTargetName}
getDistinctIdName={getDistinctIdName}
diff --git a/frontend/src/scenes/feature-flags/constants.tsx b/frontend/src/scenes/feature-flags/constants.tsx
index eb002b3efdec..710e6263ac3f 100644
--- a/frontend/src/scenes/feature-flags/constants.tsx
+++ b/frontend/src/scenes/feature-flags/constants.tsx
@@ -6,7 +6,9 @@ export const COHORT_BEHAVIORAL_LIMITATIONS_URL =
export const EARLY_ACCESS_GROUP_TARGETING_DISABLED_REASON =
'This flag is linked to an early access feature, so it can only target users. Remove the early access feature to target groups.'
-export const MATCHING_ESTIMATE_TOOLTIP = (
+// The activity window is switched on per project, so the copy follows the basis the response
+// reports. A null window means the estimate is all-time.
+export const matchingEstimateTooltip = (activityWindowDays: number | null): JSX.Element => (
<>
A user may have{' '}
@@ -18,8 +20,9 @@ export const MATCHING_ESTIMATE_TOOLTIP = (
Estimated from{' '}
identified users
- {' '}
- only. Anonymous visitors can still match this flag, so the actual number may be higher.
+
+ {activityWindowDays === null ? '.' : ` active in the last ${activityWindowDays} days.`}
+
Anonymous visitors can still match this flag, so the actual number may be higher.
>
)
diff --git a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts
index 9817d49bafb8..7edbcc837d73 100644
--- a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts
@@ -549,12 +549,14 @@ describe('the feature flag release conditions logic', () => {
// Condition A has its own aggregation_group_type_index=1, should override flag-level 0
expect(createSpy).toHaveBeenCalledWith(
expect.stringContaining('user_blast_radius'),
- expect.objectContaining({ group_type_index: 1 })
+ expect.objectContaining({ group_type_index: 1 }),
+ expect.anything()
)
// Condition B has no condition-level override, should fall back to flag-level 0
expect(createSpy).toHaveBeenCalledWith(
expect.stringContaining('user_blast_radius'),
- expect.objectContaining({ group_type_index: 0 })
+ expect.objectContaining({ group_type_index: 0 }),
+ expect.anything()
)
} finally {
createSpy.mockRestore()
@@ -597,7 +599,8 @@ describe('the feature flag release conditions logic', () => {
expect(createSpy).toHaveBeenCalledWith(
expect.stringContaining('user_blast_radius'),
- expect.objectContaining({ group_type_index: null })
+ expect.objectContaining({ group_type_index: null }),
+ expect.anything()
)
expect(createSpy).not.toHaveBeenCalledWith(
expect.stringContaining('user_blast_radius'),
diff --git a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts
index 1c04bbd439a3..d3e90aca67ce 100644
--- a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.ts
@@ -36,9 +36,10 @@ import {
MultivariateFlagVariant,
PropertyFilterType,
PropertyOperator,
- UserBlastRadiusType,
} from '~/types'
+import { featureFlagsUserBlastRadiusCreate } from 'products/feature_flags/frontend/generated/api'
+
import type { SimpleOption } from '../../lib/components/TaxonomicFilter/types'
import type { Noun } from '../../models/groupsModel'
import type { GroupType } from '../../types'
@@ -172,6 +173,7 @@ export interface featureFlagReleaseConditionsLogicValues {
aggregationLabel: (groupTypeIndex: number | null | undefined, deferToUserWording?: boolean) => Noun // groupsModel
groupTypes: Map // groupsModel
currentProjectId: number | null // projectLogic
+ activityWindowDays: number | null
affectedCounts: Record
aggregationTargetName: (conditionGroupTypeIndex?: number | null | undefined) => string
blastRadiusErrors: Record
@@ -252,6 +254,9 @@ export interface featureFlagReleaseConditionsLogicActions {
activeId: string
overId: string
}
+ setActivityWindowDays: (activityWindowDays: number | null) => {
+ activityWindowDays: number | null
+ }
setAffectedCount: (
sortKey: string,
count?: number
@@ -341,7 +346,7 @@ export interface featureFlagReleaseConditionsLogicMeta {
filters: FeatureFlagFilters & {
groups: FeatureFlagGroupTypeWithSortKey[]
},
- aggregationLabel: (groupTypeIndex: number | null | undefined, deferToUserWording?: boolean) => Noun
+ aggregationLabel: (groupTypeIndex: number | null | undefined, deferToUserWording?: boolean) => Noun // groupsModel
) => (conditionGroupTypeIndex?: number | null | undefined) => string
taxonomicGroupTypesForCondition: (
filters: FeatureFlagFilters & {
@@ -430,6 +435,7 @@ export const featureFlagReleaseConditionsLogic = kea ({ sortKey, error }),
+ setActivityWindowDays: (activityWindowDays: number | null) => ({ activityWindowDays }),
calculateBlastRadius: true,
calculateBlastRadiusForCondition: (
sortKey: string,
@@ -659,6 +665,14 @@ export const featureFlagReleaseConditionsLogic = kea activityWindowDays,
+ },
+ ],
// Tracks conditions whose blast-radius estimate failed, keyed by sort_key. Holds the
// caught error so the UI can explain why (and decide whether a retry is worth offering)
// instead of just distinguishing failure from the still-loading (undefined) state.
@@ -855,15 +869,13 @@ export const featureFlagReleaseConditionsLogic = kea
}
-export interface UserBlastRadiusType {
- affected: number
- total: number
-}
-
export enum ScheduledChangeModels {
FeatureFlag = 'FeatureFlag',
}
diff --git a/posthog/rate_limit.py b/posthog/rate_limit.py
index a1ea2f0dee00..e74d0bc8ad25 100644
--- a/posthog/rate_limit.py
+++ b/posthog/rate_limit.py
@@ -638,6 +638,23 @@ class SessionContextsSustainedRateThrottle(_TeamBucketRateThrottle):
rate = "600/hour"
+# Flag sizing scans a 60-day events window once per condition group, and the release-conditions
+# editor fans one request out per group on every mount with no dedup. That editor is the only real
+# caller, and the ClickHouse*RateThrottle pair does not cover it: those derive from
+# PersonalApiKeyRateThrottle, which lets an authenticated request carrying no personal API key
+# through before counting it. A team bucket caps the project's scan spend whatever the auth method.
+# Repeat empty-properties requests are served from a 5-minute cache, so a legitimate session needs
+# one cold scan per distinct condition group.
+class FlagSizingBurstRateThrottle(_TeamBucketRateThrottle):
+ scope = "flag_sizing_burst"
+ rate = "60/minute"
+
+
+class FlagSizingSustainedRateThrottle(_TeamBucketRateThrottle):
+ scope = "flag_sizing_sustained"
+ rate = "600/hour"
+
+
# Feature flag request usage scans up to 31 days of billing events in ClickHouse. Its primary
# caller is the session-authenticated feature flags UI, which the generic ClickHouse throttle
# pair does not cover. Use a team-wide bucket so users and API keys share one query budget.
diff --git a/products/feature_flags/backend/api/feature_flag.py b/products/feature_flags/backend/api/feature_flag.py
index b0429e4c4da0..9673a0bc9b88 100644
--- a/products/feature_flags/backend/api/feature_flag.py
+++ b/products/feature_flags/backend/api/feature_flag.py
@@ -76,6 +76,8 @@
from posthog.rate_limit import (
ClickHouseBurstRateThrottle,
ClickHouseSustainedRateThrottle,
+ FlagSizingBurstRateThrottle,
+ FlagSizingSustainedRateThrottle,
PersonalOrProjectSecretApiKeyRateThrottle,
ProjectSecretApiKeyTeamRateThrottle,
)
@@ -130,7 +132,7 @@
teams_linking_flag_in_project,
)
from products.feature_flags.backend.types import PropertyFilterType
-from products.feature_flags.backend.user_blast_radius import get_user_blast_radius
+from products.feature_flags.backend.user_blast_radius import get_user_blast_radius, recently_active_sizing_enabled
from products.feature_flags.backend.version_history import (
VersionHistoryIncomplete,
VersionNotFound,
@@ -2934,9 +2936,24 @@ class UserBlastRadiusRequestSerializer(serializers.Serializer):
class UserBlastRadiusResponseSerializer(serializers.Serializer):
affected = serializers.IntegerField(
- help_text="Number of entities matching the condition (users or groups depending on group_type_index)"
+ help_text=(
+ "Number of entities matching the condition. Bounded by activity_window_days when that field is set, "
+ "and all-time otherwise."
+ )
+ )
+ total = serializers.IntegerField(
+ help_text=(
+ "Denominator the affected count is shown against: persons for person-based flags, groups of this type "
+ "for group-based ones. Bounded by activity_window_days when that field is set, and all-time otherwise."
+ )
+ )
+ activity_window_days = serializers.IntegerField(
+ allow_null=True,
+ help_text=(
+ "Number of days of recent activity both counts are drawn from. Null means they are all-time, which is "
+ "the case for group-based flags and for projects where the recent-activity basis is switched off."
+ ),
)
- total = serializers.IntegerField(help_text="Total number of entities of this type in the project")
# HYPERCACHE CONTRACT: This serializer defines the JSON schema that the Rust feature-flags
@@ -4664,7 +4681,12 @@ def evaluation_reasons(self, request: request.Request, **kwargs):
request=UserBlastRadiusRequestSerializer,
responses={200: UserBlastRadiusResponseSerializer},
)
- @action(methods=["POST"], detail=False, required_scopes=["feature_flag:read"])
+ @action(
+ methods=["POST"],
+ detail=False,
+ required_scopes=["feature_flag:read"],
+ throttle_classes=[FlagSizingBurstRateThrottle, FlagSizingSustainedRateThrottle],
+ )
def user_blast_radius(self, request: request.Request, **kwargs):
if "condition" not in request.data:
raise exceptions.ValidationError("Missing condition for which to get blast radius")
@@ -4672,9 +4694,16 @@ def user_blast_radius(self, request: request.Request, **kwargs):
condition = request.data.get("condition") or {}
group_type_index = request.data.get("group_type_index", None)
- result = get_user_blast_radius(self.team, condition, group_type_index)
+ result = get_user_blast_radius(
+ self.team,
+ condition,
+ group_type_index,
+ recently_active_only=recently_active_sizing_enabled(self.team),
+ )
- return Response({"affected": result.affected, "total": result.total})
+ # Serialize through the declared response serializer, so the wire shape cannot drift from
+ # the OpenAPI schema that the frontend and MCP types are generated from.
+ return Response(UserBlastRadiusResponseSerializer(result).data)
@action(methods=["POST"], detail=True)
def create_static_cohort_for_flag(self, request: request.Request, **kwargs):
diff --git a/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr b/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr
index 96912db79e72..3717e6a718e9 100644
--- a/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr
+++ b/products/feature_flags/backend/api/test/__snapshots__/test_feature_flag.ambr
@@ -77,45 +77,31 @@
# name: TestBlastRadius.test_user_blast_radius
'''
/* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group,
- argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('0', '1', '2', '3'))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), in(persons.properties___group, tuple('0', '1', '2', '3')))
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius.1
- '''
- /* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group, argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('0', '1', '2', '3'))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), in(persons.properties___group, tuple('0', '1', '2', '3')))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT tupleElement(argMax(tuple(properties.group), raw_persons.version), 1) AS properties___group, argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), in(properties.group, tuple('0', '1', '2', '3'))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), in(properties.group, tuple('0', '1', '2', '3'))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
@@ -341,56 +327,43 @@
# name: TestBlastRadius.test_user_blast_radius_with_multiple_precalculated_cohorts.2
'''
/* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))), in(where_optimization.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))), in(persons.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius_with_multiple_precalculated_cohorts.3
- '''
- /* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))), in(where_optimization.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))), in(persons.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0)))), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0))))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), and(in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0)))), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0)))))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
@@ -416,27 +389,37 @@
# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.1
'''
/* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group,
- argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
- (SELECT person_static_cohort.person_id AS person_id
- FROM person_static_cohort
- WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('1', '2', '4', '5', '6'))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
- (SELECT person_static_cohort.person_id AS person_id
- FROM person_static_cohort
- WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(persons.properties___group, tuple('1', '2', '4', '5', '6'))))
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group, argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
+ (SELECT person_static_cohort.person_id AS person_id
+ FROM person_static_cohort
+ WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('1', '2', '4', '5', '6'))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
+ (SELECT person_static_cohort.person_id AS person_id
+ FROM person_static_cohort
+ WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(persons.properties___group, tuple('1', '2', '4', '5', '6'))))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT tupleElement(argMax(tuple(properties.group), raw_persons.version), 1) AS properties___group, argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), in(id, (SELECT person_id FROM static_cohort_people WHERE equals(cohort_id, 99999))), in(properties.group, tuple('1', '2', '4', '5', '6'))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), and(in(id, (SELECT person_id FROM static_cohort_people WHERE equals(cohort_id, 99999))), in(properties.group, tuple('1', '2', '4', '5', '6')))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
@@ -451,30 +434,6 @@
# ---
# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.2
'''
- /* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.3
- '''
SELECT count(DISTINCT person_id)
FROM cohortpeople
@@ -483,59 +442,46 @@
AND version = 0
'''
# ---
-# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.4
+# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.3
'''
/* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
- (SELECT person_static_cohort.person_id AS person_id
- FROM person_static_cohort
- WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(where_optimization.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
- (SELECT person_static_cohort.person_id AS person_id
- FROM person_static_cohort
- WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(persons.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius_with_multiple_static_cohorts.5
- '''
- /* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
+ (SELECT person_static_cohort.person_id AS person_id
+ FROM person_static_cohort
+ WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(where_optimization.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), and(in(persons.id,
+ (SELECT person_static_cohort.person_id AS person_id
+ FROM person_static_cohort
+ WHERE and(equals(person_static_cohort.team_id, 99999), equals(person_static_cohort.cohort_id, 99999)))), in(persons.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), in(id, (SELECT person_id FROM static_cohort_people WHERE equals(cohort_id, 99999))), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0))))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), and(in(id, (SELECT person_id FROM static_cohort_people WHERE equals(cohort_id, 99999))), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0)))))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
@@ -551,21 +497,31 @@
# name: TestBlastRadius.test_user_blast_radius_with_single_cohort
'''
/* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group,
- argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), or(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), 'none'), 0), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('1', '2', '3')))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), or(ifNull(equals(persons.properties___group, 'none'), 0), in(persons.properties___group, tuple('1', '2', '3'))))
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT tupleElement(argMax(tuple(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(person.properties, 'group'), ''), 'null'), '^"|"$', '')), person.version), 1) AS properties___group, argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), or(ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), 'none'), 0), in(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, 'group'), ''), 'null'), '^"|"$', ''), tuple('1', '2', '3')))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), or(ifNull(equals(persons.properties___group, 'none'), 0), in(persons.properties___group, tuple('1', '2', '3'))))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT tupleElement(argMax(tuple(properties.group), raw_persons.version), 1) AS properties___group, argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), or(equals(properties.group, 'none'), in(properties.group, tuple('1', '2', '3')))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), or(equals(properties.group, 'none'), in(properties.group, tuple('1', '2', '3')))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
@@ -580,30 +536,6 @@
# ---
# name: TestBlastRadius.test_user_blast_radius_with_single_cohort.1
'''
- /* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius_with_single_cohort.2
- '''
SELECT count(DISTINCT person_id)
FROM cohortpeople
@@ -612,53 +544,40 @@
AND version = 0
'''
# ---
-# name: TestBlastRadius.test_user_blast_radius_with_single_cohort.3
- '''
- /* user_id:0 request:_snapshot_ */
- SELECT count(DISTINCT persons.id) AS `count(DISTINCT persons.id)`
- FROM
- (SELECT argMax(person.team_id, person.version) AS team_id,
- person.id AS id
- FROM person
- WHERE and(equals(person.team_id, 99999), in(id,
- (SELECT where_optimization.id AS id
- FROM person AS where_optimization
- WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
- 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))))) AS persons
- WHERE and(equals(persons.team_id, 99999), in(persons.id,
- (SELECT cohortpeople.person_id AS person_id
- FROM cohortpeople
- WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))))
- LIMIT 100 SETTINGS readonly=2,
- max_execution_time=60,
- allow_experimental_object_type=1,
- max_ast_elements=4000000,
- max_expanded_ast_elements=4000000,
- max_bytes_before_external_group_by=0,
- transform_null_in=1,
- optimize_min_equality_disjunction_chain_length=4294967295,
- optimize_rewrite_aggregate_function_with_if=0,
- optimize_min_inequality_conjunction_chain_length=4294967295,
- allow_experimental_join_condition=1,
- use_hive_partitioning=0
- '''
-# ---
-# name: TestBlastRadius.test_user_blast_radius_with_single_cohort.4
+# name: TestBlastRadius.test_user_blast_radius_with_single_cohort.2
'''
/* user_id:0 request:_snapshot_ */
- SELECT count() AS `count()`
- FROM
- (SELECT person.id AS id
- 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 persons
+ SELECT uniq(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id)) AS `uniq(if(not(empty(override.distinct_id)), override.person_id, event_person_id))`,
+ uniqIf(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id), in(if(not(empty(events__override.distinct_id)), events__override.person_id, events.person_id),
+ (SELECT persons.id AS id
+ FROM
+ (SELECT argMax(person.team_id, person.version) AS team_id, person.id AS id
+ FROM person
+ WHERE and(equals(person.team_id, 99999), in(id,
+ (SELECT where_optimization.id AS id
+ FROM person AS where_optimization
+ WHERE and(equals(where_optimization.team_id, 99999), equals(where_optimization.team_id, 99999), in(where_optimization.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0))))))))
+ 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))))) AS persons
+ WHERE and(equals(persons.team_id, 99999), in(persons.id,
+ (SELECT cohortpeople.person_id AS person_id
+ FROM cohortpeople
+ WHERE and(equals(cohortpeople.team_id, 99999), equals(cohortpeople.cohort_id, 99999), equals(cohortpeople.version, 0)))))))) AS `uniqIf(if(not(empty(override.distinct_id)), override.person_id, event_person_id), in(if(not(empty(override.distinct_id)), override.person_id, event_person_id), (SELECT persons.id FROM (SELECT argMax(raw_persons.team_id, raw_persons.version) AS team_id, raw_persons.id AS id FROM raw_persons WHERE in(id, (SELECT id FROM raw_persons AS where_optimization WHERE and(equals(team_id, 99999), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0))))))) GROUP BY raw_persons.id HAVING and(equals(argMax(raw_persons.is_deleted, raw_persons.version), 0), less(argMax(toTimeZone(raw_persons.created_at, 'UTC'), raw_persons.version), plus(now(), toIntervalDay(1))))) AS persons WHERE and(equals(persons.team_id, 99999), in(id, (SELECT person_id FROM raw_cohort_people WHERE and(equals(cohort_id, 99999), equals(version, 0))))))))`
+ 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)
+ WHERE and(equals(events.team_id, 99999), greaterOrEquals(events.timestamp, toDateTime64('explicit_redacted_timestamp', 6, 'UTC')), less(events.timestamp, toDateTime64('today', 6, 'UTC')), notEquals(events.person_mode, 'propertyless'))
LIMIT 100 SETTINGS readonly=2,
max_execution_time=60,
+ timeout_overflow_mode='throw',
allow_experimental_object_type=1,
max_ast_elements=4000000,
max_expanded_ast_elements=4000000,
diff --git a/products/feature_flags/backend/api/test/test_feature_flag.py b/products/feature_flags/backend/api/test/test_feature_flag.py
index 13729df4235f..6dc4e47883ce 100644
--- a/products/feature_flags/backend/api/test/test_feature_flag.py
+++ b/products/feature_flags/backend/api/test/test_feature_flag.py
@@ -1,4 +1,5 @@
import json
+import uuid
from datetime import UTC, datetime, timedelta
from typing import Any, Optional, cast
@@ -8,6 +9,7 @@
APIBaseTest,
ClickhouseTestMixin,
FuzzyInt,
+ _create_event,
_create_person,
flush_persons_and_events,
snapshot_clickhouse_queries,
@@ -75,7 +77,12 @@
from products.feature_flags.backend.flag_status import FeatureFlagStatus
from products.feature_flags.backend.models.feature_flag import FeatureFlag, FeatureFlagDashboards
from products.feature_flags.backend.models.team_feature_flags_config import TeamFeatureFlagsConfig
-from products.feature_flags.backend.user_blast_radius import get_user_blast_radius, get_user_blast_radius_persons
+from products.feature_flags.backend.user_blast_radius import (
+ RECENTLY_ACTIVE_DAYS,
+ get_user_blast_radius,
+ get_user_blast_radius_persons,
+ recently_active_sizing_enabled,
+)
from products.product_analytics.backend.facade.models import Insight
from products.product_tours.backend.models import ProductTour
from products.surveys.backend.models import Survey
@@ -9569,11 +9576,30 @@ def test_insert_failure_is_recorded_as_failure_not_success(self, mock_batch_eval
self.assertEqual(history.error_code, CohortErrorCode.UNKNOWN)
+def _create_active_person(*, team_id: int, distinct_ids: list[str], **kwargs):
+ # Blast radius counts persons active in the recent window, so every person a blast-radius test
+ # relies on needs a recent event. Wrap person creation to emit one $pageview per person.
+ person = _create_person(team_id=team_id, distinct_ids=distinct_ids, **kwargs)
+ _create_event(team_id=team_id, event="$pageview", distinct_id=distinct_ids[0])
+ return person
+
+
class TestBlastRadius(ClickhouseTestMixin, APIBaseTest):
+ def setUp(self):
+ super().setUp()
+ # The activity window sits behind a kill switch that evaluates to False without a flag
+ # client, so the endpoint tests below would silently exercise the all-time path instead.
+ gate = patch(
+ "products.feature_flags.backend.api.feature_flag.recently_active_sizing_enabled",
+ return_value=True,
+ )
+ gate.start()
+ self.addCleanup(gate.stop)
+
@snapshot_clickhouse_queries
def test_user_blast_radius(self):
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -9633,6 +9659,171 @@ def test_persons_seen_so_far_ignores_team_v2_argmax_modifier(self):
for query in denominator_queries:
assert "in(tuple(person.id, person.version)" not in query
+ def test_user_blast_radius_excludes_inactive_matching_persons(self):
+ # A person matching the filter but with no recent activity is not reachable audience, so it
+ # must not count toward "affected" — otherwise inactive matches inflate the sized audience,
+ # which is the bug this fix guards against.
+ _create_active_person(team_id=self.team.pk, distinct_ids=["active-match"], properties={"group": "match"})
+ # The two window edges use fixed day offsets rather than RECENTLY_ACTIVE_DAYS +/- 1, so a
+ # typo in the constant moves the boundary past a fixture instead of moving both together.
+ _create_person(team_id=self.team.pk, distinct_ids=["edge-match"], properties={"group": "match"})
+ _create_event(
+ team_id=self.team.pk,
+ event="$pageview",
+ distinct_id="edge-match",
+ timestamp=now() - timedelta(days=59),
+ )
+ _create_person(team_id=self.team.pk, distinct_ids=["stale-match"], properties={"group": "match"})
+ _create_event(
+ team_id=self.team.pk,
+ event="$pageview",
+ distinct_id="stale-match",
+ timestamp=now() - timedelta(days=61),
+ )
+ # Matches the filter but emits no event at all, so it drops out of the count.
+ _create_person(team_id=self.team.pk, distinct_ids=["inactive-match"], properties={"group": "match"})
+ # Active but does not match, so it keeps the total above the matched count.
+ _create_active_person(team_id=self.team.pk, distinct_ids=["active-nonmatch"], properties={"group": "other"})
+ # A corrupt far-future timestamp must not keep a person "active" past the window's end.
+ _create_person(team_id=self.team.pk, distinct_ids=["future-match"], properties={"group": "match"})
+ _create_event(
+ team_id=self.team.pk,
+ event="$pageview",
+ distinct_id="future-match",
+ timestamp=now() + timedelta(days=365),
+ )
+ flush_persons_and_events()
+
+ condition = {
+ "properties": [{"key": "group", "type": "person", "value": ["match"], "operator": "exact"}],
+ "rollout_percentage": 100,
+ }
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius",
+ {"condition": condition},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ # Only the two in-window matches count; the stale, inactive, and far-future matches are
+ # excluded, and the active non-match keeps the total at three.
+ self.assertLessEqual({"affected": 2, "total": 3}.items(), response.json().items())
+
+ # The window is an opt-in for the flags endpoint only. The workflows audience preview calls
+ # this function without it and must keep counting every matching person, because the batch
+ # send it previews enumerates persons with no activity window.
+ unwindowed = get_user_blast_radius(self.team, condition)
+ self.assertEqual((unwindowed.affected, unwindowed.total), (5, 6))
+
+ def test_user_blast_radius_falls_back_to_the_all_time_count_when_gated_off(self):
+ # The kill switch has to restore the count the window replaced, so a disable gives back the
+ # inactive matches rather than leaving the window half applied.
+ _create_active_person(team_id=self.team.pk, distinct_ids=["active-match"], properties={"group": "match"})
+ _create_person(team_id=self.team.pk, distinct_ids=["inactive-match"], properties={"group": "match"})
+ flush_persons_and_events()
+
+ condition = {
+ "properties": [{"key": "group", "type": "person", "value": ["match"], "operator": "exact"}],
+ "rollout_percentage": 100,
+ }
+ with patch(
+ "products.feature_flags.backend.api.feature_flag.recently_active_sizing_enabled",
+ return_value=False,
+ ):
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius",
+ {"condition": condition},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertLessEqual({"affected": 2, "total": 2}.items(), response.json().items())
+
+ def test_user_blast_radius_excludes_personless_events(self):
+ # An event captured with $process_person_profile: false carries a synthetic person_id and no
+ # persons row, so counting it would put anonymous traffic in a denominator the matched
+ # subquery can never return.
+ _create_active_person(team_id=self.team.pk, distinct_ids=["identified"], properties={"group": "match"})
+ _create_event(
+ team_id=self.team.pk,
+ event="$pageview",
+ distinct_id="anonymous-visitor",
+ person_id=uuid.uuid4(),
+ person_mode="propertyless",
+ )
+ flush_persons_and_events()
+
+ for condition in (
+ {"properties": [], "rollout_percentage": 100},
+ {
+ "properties": [{"key": "group", "type": "person", "value": ["match"], "operator": "exact"}],
+ "rollout_percentage": 100,
+ },
+ ):
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius",
+ {"condition": condition},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertLessEqual({"affected": 1, "total": 1}.items(), response.json().items())
+
+ def test_user_blast_radius_caches_the_unfiltered_active_count(self):
+ # The flag editor sizes one condition group per mount, so repeat requests inside the TTL
+ # must not re-scan events each time. A second project asserts the key is scoped per team:
+ # an unscoped key would serve this project's count as the other one's.
+ other_team = Team.objects.create(organization=self.organization)
+ _create_active_person(team_id=self.team.pk, distinct_ids=["active"], properties={"group": "match"})
+ for i in range(2):
+ _create_active_person(team_id=other_team.pk, distinct_ids=[f"other-active-{i}"])
+ flush_persons_and_events()
+
+ condition = {"properties": [], "rollout_percentage": 50}
+ with self.capture_select_queries() as queries:
+ responses = [
+ self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius",
+ {"condition": condition},
+ )
+ for _ in range(2)
+ ]
+ other_response = self.client.post(
+ f"/api/projects/{other_team.id}/feature_flags/user_blast_radius",
+ {"condition": condition},
+ )
+
+ for response in responses:
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertLessEqual({"affected": 1, "total": 1}.items(), response.json().items())
+
+ self.assertEqual(other_response.status_code, status.HTTP_200_OK)
+ self.assertLessEqual({"affected": 2, "total": 2}.items(), other_response.json().items())
+
+ events_scans = [query for query in queries if "uniq(" in query]
+ self.assertEqual(len(events_scans), 2)
+
+ def test_user_blast_radius_reports_the_basis_that_produced_the_counts(self):
+ # The window is gated per project, so the response has to say which basis ran: the tooltip
+ # beside the number reads from this field rather than asserting the window unconditionally.
+ _create_active_person(team_id=self.team.pk, distinct_ids=["active"], properties={"group": "match"})
+ flush_persons_and_events()
+
+ condition = {
+ "properties": [{"key": "group", "type": "person", "value": ["match"], "operator": "exact"}],
+ "rollout_percentage": 100,
+ }
+ windowed = self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius", {"condition": condition}
+ )
+ with patch(
+ "products.feature_flags.backend.api.feature_flag.recently_active_sizing_enabled",
+ return_value=False,
+ ):
+ all_time = self.client.post(
+ f"/api/projects/{self.team.id}/feature_flags/user_blast_radius", {"condition": condition}
+ )
+
+ self.assertEqual(windowed.json()["activity_window_days"], RECENTLY_ACTIVE_DAYS)
+ self.assertIsNone(all_time.json()["activity_window_days"])
+
@parameterized.expand(
[
(
@@ -9687,7 +9878,7 @@ def test_user_blast_radius_endpoint_returns_400_for_unevaluable_filters(self):
def test_user_blast_radius_with_flag_dependency(self):
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -9723,7 +9914,7 @@ def test_user_blast_radius_with_flag_dependency(self):
def test_user_blast_radius_with_flag_dependency_and_person_property(self):
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -9869,7 +10060,7 @@ def test_user_blast_radius_persons_with_groups_and_flag_dependency(self):
@time_machine.travel("2024-01-11", tick=False)
def test_user_blast_radius_with_relative_date_filters(self):
for i in range(8):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}", "created_at": f"2023-0{i + 1}-04"},
@@ -9922,7 +10113,7 @@ def test_user_blast_radius_with_zero_users(self):
def test_user_blast_radius_with_zero_selected_users(self):
for i in range(5):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -9952,7 +10143,7 @@ def test_user_blast_radius_with_zero_selected_users(self):
def test_user_blast_radius_with_all_selected_users(self):
for i in range(5):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -9973,7 +10164,7 @@ def test_user_blast_radius_with_distinct_id_filter(self):
# person_distinct_id2 table and must be joined via pdi. Filtering by distinct_id
# in a release condition should match the persons that own that distinct_id.
for i in range(5):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -10004,12 +10195,12 @@ def test_user_blast_radius_with_distinct_id_filter(self):
def test_user_blast_radius_with_distinct_id_filter_multiple_distinct_ids_per_person(self):
# A single person can own multiple distinct_ids; filtering by any one should still
# count that person exactly once.
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=["alias-a", "alias-b"],
properties={"group": "0"},
)
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=["other"],
properties={"group": "1"},
@@ -10041,7 +10232,7 @@ def test_user_blast_radius_with_distinct_id_filter_multiple_distinct_ids_per_per
def test_user_blast_radius_with_single_cohort(self):
# Just to shake things up, we're using integers for the group property
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": i},
@@ -10107,7 +10298,7 @@ def test_user_blast_radius_with_single_cohort(self):
@snapshot_clickhouse_queries
def test_user_blast_radius_with_multiple_precalculated_cohorts(self):
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -10184,7 +10375,7 @@ def test_user_blast_radius_with_multiple_precalculated_cohorts(self):
@snapshot_clickhouse_queries
def test_user_blast_radius_with_multiple_static_cohorts(self):
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"group": f"{i}"},
@@ -10510,17 +10701,17 @@ def test_user_blast_radius_with_group_key_property(self):
def test_user_blast_radius_with_integer_property_values(self):
"""Test that integer property values are correctly normalized to strings for matching"""
- _create_person(
+ _create_active_person(
distinct_ids=["p1"],
team_id=self.team.pk,
properties={"age": 25, "score": 100},
)
- _create_person(
+ _create_active_person(
distinct_ids=["p2"],
team_id=self.team.pk,
properties={"age": "25", "score": "100"},
)
- _create_person(
+ _create_active_person(
distinct_ids=["p3"],
team_id=self.team.pk,
properties={"age": 30, "score": 200},
@@ -10694,12 +10885,12 @@ def test_user_blast_radius_with_dynamic_cohort(self):
},
)
- _create_person(
+ _create_active_person(
distinct_ids=["p1"],
team_id=self.team.pk,
properties={"email": "user@posthog.com"},
)
- _create_person(
+ _create_active_person(
distinct_ids=["p2"],
team_id=self.team.pk,
properties={"email": "user@example.com"},
@@ -11027,7 +11218,7 @@ def test_user_blast_radius_with_semver_operators(self):
"2.1.0",
]
for version in versions:
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person_{version}"],
properties={"app_version": version},
@@ -11245,7 +11436,7 @@ def test_user_blast_radius_with_semver_caret_0x_versions(self):
"1.0.0",
]
for version in versions:
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person_{version}"],
properties={"app_version": version},
@@ -11315,7 +11506,7 @@ def test_user_blast_radius_with_semver_operators_on_groups(self):
# Create persons in organizations
for i, version in enumerate(versions):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person_{i}"],
properties={"$group_0": f"org-{version}"},
@@ -11354,7 +11545,7 @@ def test_user_blast_radius_person_condition_separate_from_group_condition(self):
)
for i in range(10):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"plan": "pro" if i < 6 else "free"},
@@ -11415,7 +11606,7 @@ def test_user_blast_radius_person_condition_separate_from_group_condition(self):
def test_user_blast_radius_pure_person_condition_has_no_group_counts(self):
for i in range(5):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"plan": "pro" if i < 3 else "free"},
@@ -11495,7 +11686,7 @@ def test_user_blast_radius_cohort_condition_and_group_condition_separate(self):
)
for i in range(8):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"plan": "pro" if i < 5 else "free"},
@@ -11572,7 +11763,7 @@ def test_user_blast_radius_cohort_condition_and_group_condition_separate(self):
def test_user_blast_radius_no_error_fields_for_successful_queries(self):
for i in range(3):
- _create_person(
+ _create_active_person(
team_id=self.team.pk,
distinct_ids=[f"person{i}"],
properties={"plan": "pro"},
@@ -14988,3 +15179,19 @@ def test_freeing_a_tombstoned_key_relinks_teams_to_the_tombstone(self, mode: str
assert old_flag.key == f"replay-gate:deleted:{old_flag.id}"
self.team.refresh_from_db()
assert self.team.session_recording_linked_flag == {"id": old_flag.id, "key": old_flag.key}
+
+
+class TestRecentlyActiveSizingFlag(APIBaseTest):
+ @parameterized.expand(
+ [
+ ("enabled", True, True),
+ ("disabled", False, False),
+ ("client_raises", Exception("flag client down"), False),
+ ]
+ )
+ def test_kill_switch_never_propagates_a_flag_client_failure(self, _name, flag_result, expected):
+ # A broken flag client must read as "window off", not as a 500 on the sizing panel. Both
+ # sides of the endpoint patch this symbol, so nothing else runs its body.
+ kwargs = {"side_effect": flag_result} if isinstance(flag_result, Exception) else {"return_value": flag_result}
+ with patch("products.feature_flags.backend.user_blast_radius.feature_enabled_or_false", **kwargs):
+ self.assertEqual(recently_active_sizing_enabled(self.team), expected)
diff --git a/products/feature_flags/backend/user_blast_radius.py b/products/feature_flags/backend/user_blast_radius.py
index 5dccd047f48c..6b2ba9480126 100644
--- a/products/feature_flags/backend/user_blast_radius.py
+++ b/products/feature_flags/backend/user_blast_radius.py
@@ -1,20 +1,26 @@
from collections.abc import Iterator
from contextlib import contextmanager
+from datetime import timedelta
from typing import Optional
+from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
+from django.utils import timezone
+import structlog
from rest_framework.exceptions import ValidationError
from posthog.schema import PropertyOperator
from posthog.hogql import ast
+from posthog.hogql.constants import HogQLGlobalSettings
from posthog.hogql.context import HogQLContext
from posthog.hogql.database.database import Database
from posthog.hogql.errors import (
ExposedHogQLError,
NotImplementedError as HogQLNotImplementedError,
)
+from posthog.hogql.parser import parse_select
from posthog.hogql.property import property_to_expr
from posthog.hogql.query import execute_hogql_query
@@ -26,14 +32,49 @@
from posthog.models.property import GroupTypeIndex, Property, PropertyGroup, PropertyValidationError
from posthog.models.property.relative_date import relative_date_parse_for_feature_flag_matching
from posthog.models.team.team import Team
+from posthog.ph_client import feature_enabled_or_false
from products.cohorts.backend.models.cohort import Cohort
+logger = structlog.get_logger(__name__)
+
@frozen
class BlastRadiusResult:
affected: int
total: int
+ # Days of activity both counts are drawn from, or None when they are all-time. The kill switch
+ # decides which basis ran, so the copy shown beside the number has to follow this rather than
+ # assume a window.
+ activity_window_days: Optional[int] = None
+
+
+# Window for the blast-radius denominator. An all-time person count is inflated by anonymous,
+# one-shot persons that never return; a recent-activity window drops them and is defensible.
+RECENTLY_ACTIVE_DAYS = 60
+
+RECENTLY_ACTIVE_SIZING_FLAG = "flags-size-by-active-persons"
+
+# The recently-active count depends on the team alone, and the flag editor asks for it once per
+# empty-properties condition group on every mount, so a short TTL collapses those into one scan.
+_RECENTLY_ACTIVE_COUNT_CACHE_TTL = 300
+
+# A personless event carries a synthetic person_id derived from its distinct id, and no persons row
+# is ever written for it. Counting it would put anonymous traffic in a denominator that the
+# matched-persons subquery can never return, and would raise the total for projects on posthog-js's
+# identified_only default, which is the opposite of what the window is for.
+_IDENTIFIED_PERSONS_ONLY = "person_mode != 'propertyless'"
+
+# Both activity-window queries select on this events predicate. They can answer one panel in a
+# single request cycle, so two copies could drift into reporting two different totals for one team.
+# The query text is fixed at import time and the bounds arrive as placeholders, so no caller-supplied
+# value ever reaches the string.
+_RECENTLY_ACTIVE_EVENTS_WHERE = f"timestamp >= {{cutoff}} AND timestamp < {{upper}} AND {_IDENTIFIED_PERSONS_ONLY}"
+_RECENTLY_ACTIVE_COUNT_QUERY = f"SELECT uniq(person_id) FROM events WHERE {_RECENTLY_ACTIVE_EVENTS_WHERE}"
+_RECENTLY_ACTIVE_MATCHED_QUERY = (
+ f"SELECT uniq(person_id), uniqIf(person_id, person_id IN {{matched}}) "
+ f"FROM events WHERE {_RECENTLY_ACTIVE_EVENTS_WHERE}"
+)
# ClickHouse codes for "this literal can't be parsed as the column's type": 6 CANNOT_PARSE_TEXT,
@@ -115,19 +156,52 @@ def replace_proxy_properties(team: Team, feature_flag_condition: dict):
raise ValidationError({"filters": str(e) or "These filters cannot be evaluated."}) from e
+def recently_active_sizing_enabled(team: Team) -> bool:
+ """Kill switch for the activity window on the flags sizing endpoint.
+
+ False restores the all-time count the window replaced, which is still the behavior the
+ workflows audience preview depends on, so a disable costs accuracy and never correctness.
+ Evaluation is local-only, so a sizing request never waits on a flag fetch.
+ """
+ try:
+ return feature_enabled_or_false(
+ RECENTLY_ACTIVE_SIZING_FLAG,
+ f"team-{team.pk}",
+ groups={"project": str(team.pk)},
+ group_properties={"project": {"id": str(team.pk)}},
+ only_evaluate_locally=True,
+ send_feature_flag_events=False,
+ )
+ except Exception:
+ # Log so a fleet-wide silent disable shows up in Sentry rather than as a number that
+ # quietly went back to its old value.
+ logger.warning(
+ "flag_blast_radius_sizing_flag_evaluation_failed",
+ team_id=team.pk,
+ flag=RECENTLY_ACTIVE_SIZING_FLAG,
+ exc_info=True,
+ )
+ return False
+
+
def get_user_blast_radius(
team: Team,
feature_flag_condition: dict,
group_type_index: Optional[GroupTypeIndex] = None,
+ *,
+ recently_active_only: bool = False,
) -> BlastRadiusResult:
# No rollout % calculations here, since it makes more sense to compute that on the frontend
+ # recently_active_only stays off by default: the workflows audience preview shares this
+ # function, and its count must match the unwindowed send enumeration in batch_audience.py.
with unevaluable_filters_as_validation_errors():
cleaned_filter = replace_proxy_properties(team, feature_flag_condition)
if group_type_index is not None:
return _get_group_blast_radius(team, cleaned_filter, group_type_index)
- else:
- return _get_person_blast_radius(team, cleaned_filter)
+ if recently_active_only:
+ return _get_person_blast_radius_recently_active(team, cleaned_filter)
+ return _get_person_blast_radius(team, cleaned_filter)
def get_user_blast_radius_persons(
@@ -146,8 +220,117 @@ def get_user_blast_radius_persons(
return _get_person_blast_radius_persons(team, cleaned_filter, cursor=cursor)
+def _recently_active_window_placeholders() -> dict[str, ast.Expr]:
+ """Bind the activity window bounds for the query text above.
+
+ Python computes them, so they respect freeze_time in tests and do not depend on ClickHouse
+ server time. The upper bound allows a day of clock skew, but stops a far-future event timestamp
+ from keeping a person "active" for years.
+ """
+ now = timezone.now()
+ return {
+ "cutoff": ast.Constant(value=now - timedelta(days=RECENTLY_ACTIVE_DAYS)),
+ "upper": ast.Constant(value=now + timedelta(days=1)),
+ }
+
+
+def _recently_active_persons_count(team: Team) -> int:
+ """Count distinct persons active in the last RECENTLY_ACTIVE_DAYS days.
+
+ This is the blast-radius denominator. It replaces an all-time person count so anonymous churn
+ does not inflate the audience shown to a flag author (see RECENTLY_ACTIVE_DAYS). `uniq` is an
+ estimate, which matches the "~" the release conditions panel already renders.
+ """
+ cache_key = f"flag_blast_radius:recently_active_persons:{team.pk}"
+ cached = cache.get(cache_key)
+ if cached is not None:
+ return cached
+
+ query = parse_select(_RECENTLY_ACTIVE_COUNT_QUERY, placeholders=_recently_active_window_placeholders())
+
+ tag_queries(product=Product.FEATURE_FLAGS, feature=Feature.QUERY)
+ response = execute_hogql_query(
+ query=query,
+ team=team,
+ workload=Workload.OFFLINE,
+ settings=HogQLGlobalSettings(timeout_overflow_mode="throw"),
+ )
+
+ count = response.results[0][0] if response.results else 0
+ cache.set(cache_key, count, timeout=_RECENTLY_ACTIVE_COUNT_CACHE_TTL)
+ return count
+
+
+def _matching_persons_exprs(team: Team, filter: Filter) -> list[ast.Expr]:
+ """Team scope plus the condition's property filters, in person scope.
+
+ Sizing and the workflows preview both decide who matches a condition from this predicate, so a
+ change to one copy alone would make the previewed audience differ from the sized one.
+ property_to_expr resolves cohorts, static cohorts, and group properties.
+ """
+ return [
+ ast.CompareOperation(
+ op=ast.CompareOperationOp.Eq,
+ left=ast.Field(chain=["persons", "team_id"]),
+ right=ast.Constant(value=team.pk),
+ ),
+ property_to_expr(filter.property_groups, team, scope="person"),
+ ]
+
+
+def _matched_persons_query(team: Team, filter: Filter) -> ast.SelectQuery:
+ """Subquery of the person ids matching the condition."""
+ return ast.SelectQuery(
+ select=[ast.Field(chain=["persons", "id"])],
+ select_from=ast.JoinExpr(table=ast.Field(chain=["persons"])),
+ where=ast.And(exprs=_matching_persons_exprs(team, filter)),
+ )
+
+
+def _get_person_blast_radius_recently_active(team: Team, filter: Filter) -> BlastRadiusResult:
+ """Calculate blast radius for person-based feature flags over the recent activity window.
+
+ Both counts are of persons active in the same recent window, so the matched count is never a
+ different population from the total it is shown against.
+ """
+ properties = filter.property_groups.flat
+
+ if len(properties) == 0:
+ # No filters means every recently active person is affected.
+ total_users = _recently_active_persons_count(team)
+ return BlastRadiusResult(affected=total_users, total=total_users, activity_window_days=RECENTLY_ACTIVE_DAYS)
+
+ # One pass over the recent event window yields both the active total and the matched subset, so
+ # the denominator is never a second scan and both numbers come from the same rows.
+ query = parse_select(
+ _RECENTLY_ACTIVE_MATCHED_QUERY,
+ placeholders={
+ "matched": _matched_persons_query(team, filter),
+ **_recently_active_window_placeholders(),
+ },
+ )
+
+ tag_queries(product=Product.FEATURE_FLAGS, feature=Feature.QUERY)
+ # OFFLINE for the same reason as _get_group_blast_radius below: a 60-day events scan is too
+ # heavy for the pool that serves interactive analytics. A partial result on timeout would be a
+ # confidently wrong audience number, so overflow throws instead.
+ response = execute_hogql_query(
+ query=query,
+ team=team,
+ workload=Workload.OFFLINE,
+ settings=HogQLGlobalSettings(timeout_overflow_mode="throw"),
+ )
+
+ total_users, affected = (response.results[0][0], response.results[0][1]) if response.results else (0, 0)
+ # affected is a strict subset of total_users, but both are uniq() estimates, so clamp to keep
+ # the frontend percentage coherent.
+ return BlastRadiusResult(
+ affected=min(affected, total_users), total=total_users, activity_window_days=RECENTLY_ACTIVE_DAYS
+ )
+
+
def _get_person_blast_radius(team: Team, filter: Filter) -> BlastRadiusResult:
- """Calculate blast radius for person-based feature flags using HogQL."""
+ """Calculate all-time blast radius for person-based feature flags using HogQL."""
properties = filter.property_groups.flat
@@ -194,19 +377,7 @@ def _build_person_query(team: Team, filter: Filter, return_count: bool = True, c
distinct=True,
)
- # Build WHERE clause with team_id and property filters
- # property_to_expr handles all property types including cohorts
- where_exprs: list[ast.Expr] = [
- ast.CompareOperation(
- op=ast.CompareOperationOp.Eq,
- left=ast.Field(chain=["persons", "team_id"]),
- right=ast.Constant(value=team.pk),
- )
- ]
-
- # Add all property filters (including cohorts) via property_to_expr
- property_expr = property_to_expr(filter.property_groups, team, scope="person")
- where_exprs.append(property_expr)
+ where_exprs: list[ast.Expr] = _matching_persons_exprs(team, filter)
# Add cursor-based pagination when returning IDs
if not return_count and cursor is not None:
diff --git a/products/feature_flags/frontend/generated/api.schemas.ts b/products/feature_flags/frontend/generated/api.schemas.ts
index 0a61157dd8fe..33b7c552aecb 100644
--- a/products/feature_flags/frontend/generated/api.schemas.ts
+++ b/products/feature_flags/frontend/generated/api.schemas.ts
@@ -1637,10 +1637,15 @@ export interface UserBlastRadiusRequestApi {
}
export interface UserBlastRadiusResponseApi {
- /** Number of entities matching the condition (users or groups depending on group_type_index) */
+ /** Number of entities matching the condition. Bounded by activity_window_days when that field is set, and all-time otherwise. */
affected: number
- /** Total number of entities of this type in the project */
+ /** Denominator the affected count is shown against: persons for person-based flags, groups of this type for group-based ones. Bounded by activity_window_days when that field is set, and all-time otherwise. */
total: number
+ /**
+ * Number of days of recent activity both counts are drawn from. Null means they are all-time, which is the case for group-based flags and for projects where the recent-activity basis is switched off.
+ * @nullable
+ */
+ activity_window_days: number | null
}
export interface FlagValueItemApi {
diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts
index 73c1870ef04d..70f3d6233176 100644
--- a/services/mcp/src/api/generated.ts
+++ b/services/mcp/src/api/generated.ts
@@ -89062,10 +89062,15 @@ export namespace Schemas {
}
export interface UserBlastRadiusResponse {
- /** Number of entities matching the condition (users or groups depending on group_type_index) */
+ /** Number of entities matching the condition. Bounded by activity_window_days when that field is set, and all-time otherwise. */
affected: number;
- /** Total number of entities of this type in the project */
+ /** Denominator the affected count is shown against: persons for person-based flags, groups of this type for group-based ones. Bounded by activity_window_days when that field is set, and all-time otherwise. */
total: number;
+ /**
+ * Number of days of recent activity both counts are drawn from. Null means they are all-time, which is the case for group-based flags and for projects where the recent-activity basis is switched off.
+ * @nullable
+ */
+ activity_window_days: number | null;
}
export interface UserCustomerAnalyticsConfig {