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 {