Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d2e173
feat(cohorts): add used-in references endpoint and UI
ricardothe3rd Apr 15, 2026
b45389a
fix(cohorts): address review feedback on used_in endpoint
ricardothe3rd Apr 15, 2026
49924a6
Merge branch 'upstream/master' into feat/cohort-used-in-references
ricardothe3rd Apr 21, 2026
74b1340
Merge branch 'master' into feat/cohort-used-in-references
dmarticus Apr 22, 2026
09509e6
Merge branch 'master' into feat/cohort-used-in-references
dmarticus Apr 27, 2026
ca1312b
Merge branch 'master' into feat/cohort-used-in-references
dmarticus Apr 28, 2026
1058116
feat(cohorts): address review on used_in endpoint
ricardothe3rd Apr 28, 2026
a16f631
chore(cohorts): regenerate openapi types for used_in response
ricardothe3rd Apr 28, 2026
61e7e04
feat(cohorts): use generated client and group used_in references in b…
ricardothe3rd Apr 28, 2026
7dc8c56
fix(cohorts): satisfy mypy on used_in endpoint and deletion guard
ricardothe3rd Apr 28, 2026
2d77fa9
chore(cohorts): sync mcp yaml definitions with new used_in operation
ricardothe3rd Apr 29, 2026
5b6de09
Merge branch 'master' into feat/cohort-used-in-references
ricardothe3rd Apr 29, 2026
ee9dfe0
chore(messaging): silence mypy unreachable on async-generator marker
ricardothe3rd Apr 29, 2026
61abd52
Merge remote-tracking branch 'upstream/master' into feat/cohort-used-…
ricardothe3rd Apr 30, 2026
ff5e2fe
Merge branch 'master' into feat/cohort-used-in-references
dmarticus Apr 30, 2026
9c60ef8
Merge remote-tracking branch 'origin/master' into feat/cohort-used-in…
dmarticus May 6, 2026
9a07b67
Merge branch 'master' into feat/cohort-used-in-references
dmarticus May 18, 2026
fe77087
Merge branch 'master' into feat/cohort-used-in-references
dmarticus May 18, 2026
f033770
Merge branch 'master' into feat/cohort-used-in-references
haacked Jun 10, 2026
e01db36
fix(cohorts): address review feedback on used-in references endpoint
haacked Jun 10, 2026
c4c9016
fix(cohorts): apply code review fixes to used-in endpoint
haacked Jun 10, 2026
e768c61
fix(cohorts): skip exception capture for 404s when loading used-in re…
haacked Jun 10, 2026
dde067f
Merge branch 'master' into feat/cohort-used-in-references
haacked Jun 11, 2026
7f4d510
Merge branch 'master' into feat/cohort-used-in-references
haacked Jun 11, 2026
cdcd54b
Merge branch 'master' into feat/cohort-used-in-references
haacked Jun 11, 2026
6e3054a
chore(cohorts): remove duplicate cohorts_used_in entry from MCP core.…
haacked Jun 11, 2026
4d68fff
chore(cohorts): align nosemgrep suppressions on used-in queries
haacked Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion frontend/src/scenes/cohorts/CohortEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from '~/layout/scenes/SceneLayout'
import { AndOrFilterSelect } from '~/queries/nodes/InsightViz/PropertyGroupFilters/AndOrFilterSelect'
import { Query } from '~/queries/Query/Query'
import { CohortType, SidePanelTab } from '~/types'
import { CohortType, InsightShortId, SidePanelTab } from '~/types'

import { AddPersonToCohortModal } from './AddPersonToCohortModal'
import { addPersonToCohortModalLogic } from './addPersonToCohortModalLogic'
Expand Down Expand Up @@ -96,6 +96,7 @@ export function CohortEdit({ id, attachTo, tabId }: CohortEditProps): JSX.Elemen
canRemovePersonFromCohort,
isPendingCalculation,
isCalculatingOrPending,
usedIn,
staticCohortMode,
} = useValues(logic)
const { featureFlags } = useValues(featureFlagLogic)
Expand Down Expand Up @@ -564,6 +565,71 @@ export function CohortEdit({ id, attachTo, tabId }: CohortEditProps): JSX.Elemen
</Link>
</LemonBanner>
)}
{!isNewCohort &&
usedIn &&
(usedIn.feature_flags.length > 0 ||
usedIn.insights.results.length > 0 ||
usedIn.cohorts.results.length > 0) && (
<LemonBanner type="info">
<div className="font-semibold mb-1">Used in</div>
<div className="space-y-2">
{usedIn.feature_flags.length > 0 && (
<div>
<div className="text-xs font-semibold uppercase opacity-60">
Feature flags
</div>
<ul className="list-disc pl-4 mb-0 space-y-0.5">
{usedIn.feature_flags.map((flag) => (
<li key={`flag-${flag.id}`}>
<Link to={urls.featureFlag(flag.id)}>
{flag.name || flag.key}
</Link>
</li>
))}
</ul>
</div>
)}
{usedIn.insights.results.length > 0 && (
<div>
<div className="text-xs font-semibold uppercase opacity-60">
Insights
{usedIn.insights.has_more &&
` (${usedIn.insights.results.length} of ${usedIn.insights.total} shown)`}
</div>
<ul className="list-disc pl-4 mb-0 space-y-0.5">
{usedIn.insights.results.map((insight) => (
<li key={`insight-${insight.id}`}>
<Link
to={urls.insightView(
insight.short_id as InsightShortId
)}
>
{insight.name}
</Link>
</li>
))}
</ul>
</div>
)}
{usedIn.cohorts.results.length > 0 && (
<div>
<div className="text-xs font-semibold uppercase opacity-60">
Cohorts
{usedIn.cohorts.has_more &&
` (${usedIn.cohorts.results.length} of ${usedIn.cohorts.total} shown)`}
</div>
<ul className="list-disc pl-4 mb-0 space-y-0.5">
{usedIn.cohorts.results.map((c) => (
<li key={`cohort-${c.id}`}>
<Link to={urls.cohort(c.id)}>{c.name}</Link>
</li>
))}
</ul>
</div>
)}
</div>
</LemonBanner>
)}
<SceneSection
// TODO: @adamleithp Add a number of matching persons to the title "Matching criteria (100)"
title="Matching criteria"
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/scenes/cohorts/cohortEditLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
validateGroup,
} from 'scenes/cohorts/cohortUtils'
import { personsLogic } from 'scenes/persons/personsLogic'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'

import { refreshTreeItem } from '~/layout/panel-layout/ProjectTree/projectTreeLogic'
Expand All @@ -45,6 +46,9 @@ import {
PropertyType,
} from '~/types'

import { cohortsUsedInRetrieve } from 'products/cohorts/frontend/generated/api'
import type { CohortUsedInResponseApi } from 'products/cohorts/frontend/generated/api.schemas'

import type { cohortEditLogicType } from './cohortEditLogicType'

export type CohortLogicProps = {
Expand Down Expand Up @@ -83,6 +87,7 @@ export const cohortEditLogic = kea<cohortEditLogicType>([
}),
path(['scenes', 'cohorts', 'cohortLogicEdit']),
connect(() => ({
values: [teamLogic, ['currentProjectId']],
actions: [eventUsageLogic, ['reportExperimentExposureCohortEdited']],
logic: [cohortsModel],
})),
Expand Down Expand Up @@ -566,6 +571,24 @@ export const cohortEditLogic = kea<cohortEditLogicType>([
},
},
],

usedIn: [
null as CohortUsedInResponseApi | null,
{
loadUsedIn: async () => {
const { id } = values.cohort
Comment thread
haacked marked this conversation as resolved.
Outdated
if (!id || id === 'new') {
return null
}
try {
return await cohortsUsedInRetrieve(String(values.currentProjectId), id)
} catch (error) {
posthog.captureException(error, { feature: 'cohort-used-in' })
return null
}
Comment thread
haacked marked this conversation as resolved.
},
},
],
})),
listeners(({ actions, values }) => ({
setCriteria: ({ newCriteria, groupIndex, criteriaIndex }) => {
Expand Down Expand Up @@ -613,6 +636,9 @@ export const cohortEditLogic = kea<cohortEditLogicType>([
'There was an error submitting this cohort. Make sure the cohort filters are correct.',
})
},
submitCohortSuccess: () => {
actions.loadUsedIn()
},
checkIfFinishedCalculating: async ({ cohort }, breakpoint) => {
const isPendingCalculation = checkIsPendingCalculation(cohort)
const isCalculatingOrPending = cohort.is_calculating || isPendingCalculation
Expand Down Expand Up @@ -658,6 +684,7 @@ export const cohortEditLogic = kea<cohortEditLogicType>([
actions.setCohort(NEW_COHORT)
} else {
actions.fetchCohort(props.id)
actions.loadUsedIn()
Comment thread
haacked marked this conversation as resolved.
}
Comment thread
haacked marked this conversation as resolved.
}),
beforeUnmount(({ values }) => {
Expand Down
180 changes: 148 additions & 32 deletions posthog/api/cohort.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,54 @@ class RemovePersonRequestSerializer(serializers.Serializer):
person_id = serializers.UUIDField(required=True, help_text="Person UUID to remove from the cohort")


class CohortUsedInFlagSerializer(serializers.Serializer):
id = serializers.IntegerField(help_text="Feature flag database ID")
key = serializers.CharField(help_text="Feature flag key (URL slug)")
name = serializers.CharField(allow_null=True, allow_blank=True, help_text="Feature flag display name")


class CohortUsedInInsightSerializer(serializers.Serializer):
id = serializers.IntegerField(help_text="Insight database ID")
short_id = serializers.CharField(help_text="Insight short ID used for routing in the frontend")
name = serializers.CharField(
help_text="Insight display name; falls back to derived name, then to 'Unnamed' when both are empty"
)


class CohortUsedInCohortSerializer(serializers.Serializer):
id = serializers.IntegerField(help_text="Cohort database ID")
name = serializers.CharField(help_text="Cohort display name")


class CohortUsedInInsightsBlockSerializer(serializers.Serializer):
results = CohortUsedInInsightSerializer(
many=True, help_text="Insights referencing this cohort, capped at 100 results"
)
total = serializers.IntegerField(help_text="Total number of insights referencing this cohort, before truncation")
has_more = serializers.BooleanField(help_text="True when more insights exist beyond the truncation cap")


class CohortUsedInCohortsBlockSerializer(serializers.Serializer):
results = CohortUsedInCohortSerializer(
many=True, help_text="Cohorts that include this cohort as a criterion, capped at 100 results"
)
total = serializers.IntegerField(help_text="Total number of cohorts referencing this cohort, before truncation")
has_more = serializers.BooleanField(help_text="True when more cohorts exist beyond the truncation cap")


class CohortUsedInResponseSerializer(serializers.Serializer):
feature_flags = CohortUsedInFlagSerializer(
many=True,
help_text="Feature flags (active and inactive, excluding soft-deleted) that reference this cohort in their targeting conditions",
)
insights = CohortUsedInInsightsBlockSerializer(
help_text="Insights referencing this cohort with truncation metadata"
)
cohorts = CohortUsedInCohortsBlockSerializer(
help_text="Other cohorts that include this cohort as a criterion, with truncation metadata"
)


class CohortCalculationHistorySerializer(serializers.ModelSerializer):
duration_seconds = serializers.ReadOnlyField()
is_completed = serializers.ReadOnlyField()
Expand Down Expand Up @@ -976,8 +1024,7 @@ def update(self, cohort: Cohort, validated_data: dict, *args: Any, **kwargs: Any
is_deletion_change = deleted_state is not None and cohort.deleted != deleted_state
if is_deletion_change:
if deleted_state:
flags_using_cohort = FeatureFlag.objects.filter(team__project_id=cohort.team.project_id, active=True)
flags_with_cohort = [flag for flag in flags_using_cohort if cohort.id in flag.get_cohort_ids()]
flags_with_cohort = get_active_flags_using_cohort(cohort)
if flags_with_cohort:
flag_names = [flag.name or flag.key for flag in flags_with_cohort]
raise ValidationError(
Expand Down Expand Up @@ -1005,21 +1052,7 @@ def update(self, cohort: Cohort, validated_data: dict, *args: Any, **kwargs: Any
)

# Check if cohort is used in insights

# Use PostgreSQL's jsonb_path_exists for recursive JSONB searching
# This finds cohort references at any depth in the JSON structure
# nosemgrep: python.django.security.audit.query-set-extra.avoid-query-set-extra (parameterized via params)
insights_using_cohort = Insight.objects.filter(
team_id=cohort.team_id,
deleted=False,
).extra(
where=[
"""jsonb_path_exists(query, '$.** ? (@.type == "cohort" && @.value == %s)', '{"cohort_id": %s}'::jsonb)
OR (query->'source'->'breakdownFilter'->>'breakdown_type' = 'cohort'
AND query->'source'->'breakdownFilter'->'breakdown' @> '[%s]'::jsonb)"""
],
params=[cohort.id, cohort.id, cohort.id],
)
insights_using_cohort = get_insights_using_cohort(cohort)

if insights_using_cohort.exists():
count = insights_using_cohort.count()
Expand All @@ -1035,24 +1068,11 @@ def update(self, cohort: Cohort, validated_data: dict, *args: Any, **kwargs: Any
)

# Check if cohort is used as criteria in other cohorts
# nosemgrep: python.django.security.audit.query-set-extra.avoid-query-set-extra (parameterized via params)
dependent_cohorts = (
Cohort.objects.filter(
team__project_id=cohort.team.project_id,
deleted=False,
)
.exclude(id=cohort.id)
.extra(
where=[
"""jsonb_path_exists(filters, '$.** ? (@.type == "cohort" && @.value == %s)', '{"cohort_id": %s}'::jsonb)"""
],
params=[cohort.id, cohort.id],
)
)
dependent_cohorts = get_cohorts_using_cohort(cohort)

if dependent_cohorts.exists():
count = dependent_cohorts.count()
cohort_names = [c.name for c in dependent_cohorts[:5]]
cohort_names = [c.name or "Unnamed" for c in dependent_cohorts[:5]]
names_str = ", ".join(cohort_names)
if count > 5:
names_str = f"{names_str}, and {count - 5} more"
Expand Down Expand Up @@ -1126,6 +1146,63 @@ def to_representation(self, instance):
return representation


COHORT_USED_IN_PAGE_SIZE = 100


def get_active_flags_using_cohort(cohort: Cohort) -> list[FeatureFlag]:
"""Return active, non-deleted feature flags that reference this cohort.

Used by deletion protection — only live flags should block cohort deletion.
"""
active_flags = FeatureFlag.objects.filter(team__project_id=cohort.team.project_id, active=True, deleted=False)
return [flag for flag in active_flags if cohort.id in flag.get_cohort_ids()]


def get_flags_using_cohort(cohort: Cohort) -> list[FeatureFlag]:
"""Return all non-deleted feature flags (active or inactive) that reference this cohort.

Used by the informational ``used_in`` endpoint — surfaces inactive flags too so users
are aware before flipping one back on. Excludes soft-deleted flags for consistency
with ``get_insights_using_cohort`` and ``get_cohorts_using_cohort``.
"""
flags = FeatureFlag.objects.filter(team__project_id=cohort.team.project_id, deleted=False)
return [flag for flag in flags if cohort.id in flag.get_cohort_ids()]


def get_insights_using_cohort(cohort: Cohort) -> QuerySet[Insight]:
"""Return insights that reference this cohort in their query filters or breakdown."""
# nosemgrep: python.django.security.audit.query-set-extra.avoid-query-set-extra (parameterized via params)
return Insight.objects.filter(
team_id=cohort.team_id,
deleted=False,
).extra(
where=[
"""jsonb_path_exists(query, '$.** ? (@.type == "cohort" && @.value == %s)', '{"cohort_id": %s}'::jsonb)
OR (query->'source'->'breakdownFilter'->>'breakdown_type' = 'cohort'
AND query->'source'->'breakdownFilter'->'breakdown' @> '[%s]'::jsonb)"""
],
params=[cohort.id, cohort.id, cohort.id],
)


def get_cohorts_using_cohort(cohort: Cohort) -> QuerySet[Cohort]:
"""Return other cohorts that include this cohort as criteria."""
# nosemgrep: python.django.security.audit.query-set-extra.avoid-query-set-extra (parameterized via params)
return (
Cohort.objects.filter(
team__project_id=cohort.team.project_id,
deleted=False,
)
.exclude(id=cohort.id)
.extra(
where=[
"""jsonb_path_exists(filters, '$.** ? (@.type == "cohort" && @.value == %s)', '{"cohort_id": %s}'::jsonb)"""
],
params=[cohort.id, cohort.id],
)
)


@extend_schema(tags=["core"])
class CohortViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet):
queryset = Cohort.objects.all()
Expand Down Expand Up @@ -1489,6 +1566,45 @@ def calculation_history(self, request: request.Request, **kwargs):
}
)

@extend_schema(responses=CohortUsedInResponseSerializer)
@action(methods=["GET"], detail=True, required_scopes=["cohort:read"])
Comment thread
dmarticus marked this conversation as resolved.
def used_in(self, request: request.Request, **kwargs) -> Response:
Comment thread
haacked marked this conversation as resolved.
cohort: Cohort = self.get_object()

flags_with_cohort = get_flags_using_cohort(cohort)
flags_data = [{"id": flag.id, "key": flag.key, "name": flag.name} for flag in flags_with_cohort]
Comment thread
haacked marked this conversation as resolved.
Outdated

insights_qs = get_insights_using_cohort(cohort)
insights_total = insights_qs.count()
Comment thread
haacked marked this conversation as resolved.
Outdated
insights_data = [
{
"id": insight["id"],
"short_id": insight["short_id"],
"name": insight.get("name") or insight.get("derived_name") or "Unnamed",
}
for insight in insights_qs.values("id", "short_id", "name", "derived_name")[:COHORT_USED_IN_PAGE_SIZE]
]

cohorts_qs = get_cohorts_using_cohort(cohort)
cohorts_total = cohorts_qs.count()
cohorts_data = list(cohorts_qs.values("id", "name")[:COHORT_USED_IN_PAGE_SIZE])
Comment thread
haacked marked this conversation as resolved.
Outdated
Comment thread
haacked marked this conversation as resolved.
Outdated

return Response(
{
"feature_flags": flags_data,
"insights": {
"results": insights_data,
"total": insights_total,
"has_more": insights_total > len(insights_data),
},
"cohorts": {
"results": cohorts_data,
"total": cohorts_total,
"has_more": cohorts_total > len(cohorts_data),
},
}
)

def perform_create(self, serializer):
Comment thread
dmarticus marked this conversation as resolved.
serializer.save()
instance = cast(Cohort, serializer.instance)
Expand Down
Loading
Loading