diff --git a/frontend/src/scenes/persons/RelatedFeatureFlags.tsx b/frontend/src/scenes/persons/RelatedFeatureFlags.tsx index d0e9dd0d0377..cda66c22b5f7 100644 --- a/frontend/src/scenes/persons/RelatedFeatureFlags.tsx +++ b/frontend/src/scenes/persons/RelatedFeatureFlags.tsx @@ -1,7 +1,7 @@ import { useActions, useValues } from 'kea' import { IconInfo } from '@posthog/icons' -import { LemonInput, LemonSelect, LemonSnack, LemonTable, LemonTag, Tooltip } from '@posthog/lemon-ui' +import { LemonInput, LemonSelect, LemonSnack, LemonTable, LemonTag, Link, Tooltip } from '@posthog/lemon-ui' import { LemonTableColumns } from 'lib/lemon-ui/LemonTable' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' @@ -102,6 +102,15 @@ const columns: LemonTableColumns = [ properties, groups, or group properties used to evaluate the release conditions of the flag. +
+ Flags that use{' '} + + device bucketing + {' '} + are evaluated with the most recent $device_id on this distinct ID's events. + A distinct ID with no client-side events has no device ID to bucket on, so those flags + show as not matched here. +
} > diff --git a/posthog/api/services/flags_service.py b/posthog/api/services/flags_service.py index 42089832751d..0857486d475b 100644 --- a/posthog/api/services/flags_service.py +++ b/posthog/api/services/flags_service.py @@ -42,6 +42,7 @@ def get_flags_from_service( person_properties: dict[str, Any] | None = None, only_use_override_person_properties: bool = False, flag_keys: list[str] | None = None, + device_id: str | None = None, internal_request_token: str | None = None, override_flags_definitions: dict[str, dict[str, Any]] | None = None, evaluation_runtime: str | None = None, @@ -59,6 +60,10 @@ def get_flags_from_service( person_properties: Optional person properties for evaluation (default: None) only_use_override_person_properties: Whether to ignore database person properties and only use provided ones (default: False) flag_keys: Optional list of specific flag keys to evaluate (default: None, evaluates all flags) + device_id: Optional `$device_id` for flags that bucket by device rather than by user. + Without it the service skips device-bucketed conditions and reports + `out_of_rollout_bound`, so any caller evaluating on behalf of a real identity + should supply one when it can. internal_request_token: Optional token to mark request as internal (non-billable) (default: None) override_flags_definitions: Optional dict of flag key -> flag definition to override database flags (default: None) evaluation_runtime: Optional override for runtime filtering: "all" | "client" | "server". @@ -106,6 +111,11 @@ def get_flags_from_service( if flag_keys: payload["flag_keys"] = flag_keys + # Top-level rather than nested in person_properties: the service reads device_id off the + # request body itself, and a device id buried in person_properties is ignored. + if device_id: + payload["device_id"] = device_id + if override_flags_definitions: payload["override_flags_definitions"] = override_flags_definitions diff --git a/products/feature_flags/backend/api/feature_flag.py b/products/feature_flags/backend/api/feature_flag.py index 5930c1b1fa58..ff5d18717d88 100644 --- a/products/feature_flags/backend/api/feature_flag.py +++ b/products/feature_flags/backend/api/feature_flag.py @@ -86,6 +86,7 @@ from products.dashboards.backend.api.dashboard import Dashboard from products.experiments.backend.models.experiment import Experiment, flag_has_live_experiment from products.feature_flags.backend.api.remote_config_shadow import shadow_compare_remote_config +from products.feature_flags.backend.device_bucketing import has_device_bucketed_flags, resolve_device_id from products.feature_flags.backend.encrypted_flag_payloads import ( REDACTED_PAYLOAD_VALUE, encrypt_flag_payloads, @@ -2358,6 +2359,16 @@ def to_internal_value(self, data): class EvaluationReasonsQuerySerializer(serializers.Serializer): distinct_id = serializers.CharField(required=True, help_text="User distinct ID") + device_id = serializers.CharField( + required=False, + allow_blank=True, + help_text=( + "Optional `$device_id` used to evaluate flags that bucket by device rather than by user. " + "When omitted, the most recent `$device_id` on this distinct ID's events is used, so " + "device-bucketed flags evaluate the same way they do for the SDK. Pass one explicitly to " + "check a specific device." + ), + ) groups = GroupsJSONField() flag_keys = FlagKeysField( help_text=( @@ -3874,6 +3885,14 @@ def evaluation_reasons(self, request: request.Request, **kwargs): flag_keys = request.validated_query_data.get("flag_keys") or None + # A device-bucketed flag hashes on $device_id, and the caller (the person profile + # flags tab) only knows a distinct id. Without a device id the service skips those + # conditions and reports out_of_rollout_bound, so resolve one off the person's + # events to match what the SDK would send. + device_id = request.validated_query_data.get("device_id") or None + if not device_id and has_device_bucketed_flags(self.project_id, flag_keys): + device_id = resolve_device_id(self.team, distinct_id) + # PostHog UI debug endpoint, not customer SDK traffic. Pass the internal # token so the call bypasses per-team billing. Retry the transient # connection blips (the service occasionally times out or refuses the @@ -3886,6 +3905,7 @@ def evaluation_reasons(self, request: request.Request, **kwargs): distinct_id=distinct_id, groups=groups, flag_keys=flag_keys, + device_id=device_id, evaluation_runtime="all", internal_request_token=settings.INTERNAL_REQUEST_TOKEN, max_retries=2, @@ -4241,6 +4261,13 @@ def test_evaluation(self, request: request.Request, **kwargs): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + # This flag hashes on $device_id, which the caller doesn't supply, so read the + # device the person was last seen on. Bounded by the evaluation timestamp when one + # was given, to stay consistent with the point-in-time person properties above. + device_id = None + if feature_flag.bucketing_identifier == "device_id": + device_id = resolve_device_id(self.team, evaluation_distinct_id, before=timestamp) + rust_response = get_flags_from_service( token=team_token, distinct_id=evaluation_distinct_id, @@ -4249,6 +4276,7 @@ def test_evaluation(self, request: request.Request, **kwargs): person_properties=person_properties, only_use_override_person_properties=timestamp is not None, flag_keys=[feature_flag.key], + device_id=device_id, internal_request_token=internal_token, override_flags_definitions=override_definitions, ) 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 53aee37cfaf4..a4c34f9a9b8d 100644 --- a/products/feature_flags/backend/api/test/test_feature_flag.py +++ b/products/feature_flags/backend/api/test/test_feature_flag.py @@ -8,6 +8,7 @@ APIBaseTest, ClickhouseTestMixin, FuzzyInt, + _create_event, _create_person, flush_persons_and_events, snapshot_clickhouse_queries, @@ -53,6 +54,7 @@ from products.early_access_features.backend.models import EarlyAccessFeature from products.experiments.backend.models.experiment import Experiment from products.feature_flags.backend.api.feature_flag import FeatureFlagSerializer, parse_created_by_ids +from products.feature_flags.backend.device_bucketing import resolve_device_id from products.feature_flags.backend.encrypted_flag_payloads import ( REDACTED_PAYLOAD_VALUE, flag_payload_codec, @@ -14071,3 +14073,83 @@ def test_evaluation_reasons_returns_502_for_non_retryable_failures(self, _name, self.assertEqual(response.status_code, status.HTTP_502_BAD_GATEWAY) self.assertIn("error", response.json()) + + @parameterized.expand( + [ + ("resolved_from_events", "device_id", None, "device-from-events"), + ("explicit_param_wins", "device_id", "device-from-caller", "device-from-caller"), + ("skipped_when_no_flag_buckets_by_device", "distinct_id", None, None), + ] + ) + @patch("products.feature_flags.backend.api.feature_flag.get_flags_from_service") + def test_evaluation_reasons_sends_device_id( + self, _name, bucketing_identifier, query_device_id, expected_device_id, mock_get_flags + ): + # A device-bucketed flag hashes on $device_id, and without one the flags service skips + # the condition and reports out_of_rollout_bound, which reads as "not in the rollout" + # even at 100%. The tab only knows a distinct id, so the device id has to come off the + # person's events unless the caller named one. Projects with no device-bucketed flag + # must not pay for that events query at all. + FeatureFlag.objects.create( + team=self.team, + key="example-flag", + bucketing_identifier=bucketing_identifier, + ) + _create_person(team=self.team, distinct_ids=["user-1"]) + _create_event( + team=self.team, + event="$pageview", + distinct_id="user-1", + properties={"$device_id": "device-from-events"}, + ) + flush_persons_and_events() + mock_get_flags.return_value = {"flags": {}} + + query: dict[str, Any] = {"distinct_id": "user-1"} + if query_device_id: + query["device_id"] = query_device_id + + response = self.client.get( + f"/api/projects/{self.team.pk}/feature_flags/evaluation_reasons/", + query, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(mock_get_flags.call_args.kwargs["device_id"], expected_device_id) + + +class TestResolveDeviceId(APIBaseTest, ClickhouseTestMixin): + @parameterized.expand( + [ + ("most_recent", None, "newer-device"), + ("bounded_by_before", datetime(2026, 1, 2, 12, 0, tzinfo=UTC), "older-device"), + ] + ) + def test_resolves_device_id(self, _name, before, expected_device_id): + # Hashing on a stale device id silently returns the wrong variant, so ordering matters, + # and point-in-time evaluation has to see the device used then rather than the latest one. + _create_person(team=self.team, distinct_ids=["user-1"]) + for timestamp, device_id in [ + ("2026-01-01T12:00:00Z", "older-device"), + ("2026-01-03T12:00:00Z", "newer-device"), + ]: + _create_event( + team=self.team, + event="$pageview", + distinct_id="user-1", + timestamp=timestamp, + properties={"$device_id": device_id}, + ) + flush_persons_and_events() + + with freeze_time("2026-01-04T12:00:00Z"): + self.assertEqual(resolve_device_id(self.team, "user-1", before=before), expected_device_id) + + def test_returns_none_when_events_carry_no_device_id(self): + # A server-only identity has no device id to find, and the caller has to be able to tell + # that apart from a resolved one rather than passing an empty string to the flags service. + _create_person(team=self.team, distinct_ids=["user-1"]) + _create_event(team=self.team, event="$pageview", distinct_id="user-1", properties={}) + flush_persons_and_events() + + self.assertIsNone(resolve_device_id(self.team, "user-1")) diff --git a/products/feature_flags/backend/device_bucketing.py b/products/feature_flags/backend/device_bucketing.py new file mode 100644 index 000000000000..8cc582a298a4 --- /dev/null +++ b/products/feature_flags/backend/device_bucketing.py @@ -0,0 +1,112 @@ +"""Resolving the ``$device_id`` that device-bucketed flags hash on. + +A device-bucketed flag hashes on ``$device_id`` instead of ``distinct_id``, so that its +value stays stable across the anonymous-to-identified transition (see +https://posthog.com/docs/feature-flags/device-bucketing). SDKs send ``$device_id`` on +every ``/flags`` call, so live evaluation works. + +PostHog's own debug surfaces don't send one: the person profile flags tab and a flag's +test evaluation tab both know only a distinct id. The evaluation engine skips a +person-aggregated device-bucketed condition when no device id is supplied and reports +``out_of_rollout_bound``, which reads as "this person isn't in the rollout" when the truth +is that we never told the engine which device to hash. At 100% rollout that is actively +misleading, because being out of the rollout bound is impossible there. + +``$device_id`` is not a person property, because one person can have many device ids, so +it has to be read back off the person's events. +""" + +from datetime import datetime, timedelta + +from django.utils import timezone + +import structlog + +from posthog.hogql import ast +from posthog.hogql.query import execute_hogql_query + +from posthog.models.team.team import Team + +from .models.feature_flag import FeatureFlag + +logger = structlog.get_logger(__name__) + +DEVICE_ID_PROPERTY = "$device_id" + +# Bounded because an unbounded events scan on a UI request is expensive, and a device id +# older than this is unlikely to be the one the person buckets on now. +DEVICE_ID_LOOKBACK_DAYS = 90 + + +def has_device_bucketed_flags(project_id: int, flag_keys: list[str] | None = None) -> bool: + """Whether any flag in scope buckets by device, so callers can skip the events query + entirely on the projects that have none.""" + queryset = FeatureFlag.objects.filter( + team__project_id=project_id, + bucketing_identifier="device_id", + active=True, + deleted=False, + ) + if flag_keys: + queryset = queryset.filter(key__in=flag_keys) + return queryset.exists() + + +def resolve_device_id(team: Team, distinct_id: str, before: datetime | None = None) -> str | None: + """The most recent ``$device_id`` seen on this distinct id's events, or None. + + Resolution is per distinct id rather than per person. ``$device_id`` is stable across + the identify boundary for a given browser, so the anonymous and identified distinct ids + of one person normally carry the same device id and the two are equivalent, but scoping + to the distinct id keeps the answer specific to the identity being evaluated and keeps + the query on an indexed column. + + Two cases legitimately return None: a person on several devices still resolves to only + their most recent one, and a distinct id with no client-side events (a server-only + identity, say) has no device id to find. Treat None as "device bucketing can't be + resolved for this identity" rather than as an error. + + ``before`` bounds the lookup for point-in-time evaluation, so a historical + reconstruction hashes on the device the person used then rather than the one they use now. + """ + until = before or timezone.now() + since = until - timedelta(days=DEVICE_ID_LOOKBACK_DAYS) + + # properties[...] rather than properties.$device_id so the property name stays a bound + # constant, and so the printer can use a materialized column where one exists. + query = """ + SELECT properties[{device_id_property}] AS device_id + FROM events + WHERE distinct_id = {distinct_id} + AND timestamp >= {since} + AND timestamp <= {until} + AND properties[{device_id_property}] IS NOT NULL + AND properties[{device_id_property}] != '' + ORDER BY timestamp DESC + LIMIT 1 + """ + + try: + response = execute_hogql_query( + query, + placeholders={ + "device_id_property": ast.Constant(value=DEVICE_ID_PROPERTY), + "distinct_id": ast.Constant(value=distinct_id), + "since": ast.Constant(value=since), + "until": ast.Constant(value=until), + }, + team=team, + ) + except Exception: + # A debug surface that degrades to "no device id" beats one that 500s, so the + # caller still renders, just without device bucketing resolved. + logger.exception( + "Failed to resolve $device_id for device-bucketed flag evaluation", + team_id=team.pk, + ) + return None + + if not response.results: + return None + + return response.results[0][0] or None diff --git a/products/feature_flags/frontend/generated/api.schemas.ts b/products/feature_flags/frontend/generated/api.schemas.ts index f4d09a6ae6e3..5137bcfa7b98 100644 --- a/products/feature_flags/frontend/generated/api.schemas.ts +++ b/products/feature_flags/frontend/generated/api.schemas.ts @@ -1915,6 +1915,10 @@ export type FeatureFlagsAllActivityRetrieveParams = { } export type FeatureFlagsEvaluationReasonsRetrieveParams = { + /** + * Optional `$device_id` used to evaluate flags that bucket by device rather than by user. When omitted, the most recent `$device_id` on this distinct ID's events is used, so device-bucketed flags evaluate the same way they do for the SDK. Pass one explicitly to check a specific device. + */ + device_id?: string /** * User distinct ID * @minLength 1 diff --git a/products/feature_flags/mcp/tools.yaml b/products/feature_flags/mcp/tools.yaml index f65f41c578f2..d18e886ab034 100644 --- a/products/feature_flags/mcp/tools.yaml +++ b/products/feature_flags/mcp/tools.yaml @@ -266,7 +266,9 @@ tools: to see each flag's evaluated value and the reason for that evaluation (e.g. condition_match, no_condition_match, disabled). Pass flag_keys to scope the response to specific flags. This is strongly recommended on projects with many flags, since omitting it returns an entry for every flag and can produce a - very large payload. + very large payload. Flags that bucket by device hash on $device_id rather than distinct_id: the most recent + $device_id on the distinct_id's events is used automatically, and device_id can be passed to check a + specific device. feature-flags-matching-ids-retrieve: operation: feature_flags_matching_ids_retrieve enabled: false diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 808563faff54..a3373cb8ce03 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -4388,7 +4388,7 @@ } }, "feature-flags-evaluation-reasons-retrieve": { - "description": "Debug why feature flags evaluate a certain way for a given user. Provide a distinct_id and optionally groups to see each flag's evaluated value and the reason for that evaluation (e.g. condition_match, no_condition_match, disabled). Pass flag_keys to scope the response to specific flags. This is strongly recommended on projects with many flags, since omitting it returns an entry for every flag and can produce a very large payload.", + "description": "Debug why feature flags evaluate a certain way for a given user. Provide a distinct_id and optionally groups to see each flag's evaluated value and the reason for that evaluation (e.g. condition_match, no_condition_match, disabled). Pass flag_keys to scope the response to specific flags. This is strongly recommended on projects with many flags, since omitting it returns an entry for every flag and can produce a very large payload. Flags that bucket by device hash on $device_id rather than distinct_id: the most recent $device_id on the distinct_id's events is used automatically, and device_id can be passed to check a specific device.", "category": "Feature flags", "feature": "flags", "summary": "Get feature flag evaluation reasons", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index 451d3e18ee57..3f51d607f0c0 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -4543,7 +4543,7 @@ } }, "feature-flags-evaluation-reasons-retrieve": { - "description": "Debug why feature flags evaluate a certain way for a given user. Provide a distinct_id and optionally groups to see each flag's evaluated value and the reason for that evaluation (e.g. condition_match, no_condition_match, disabled). Pass flag_keys to scope the response to specific flags. This is strongly recommended on projects with many flags, since omitting it returns an entry for every flag and can produce a very large payload.", + "description": "Debug why feature flags evaluate a certain way for a given user. Provide a distinct_id and optionally groups to see each flag's evaluated value and the reason for that evaluation (e.g. condition_match, no_condition_match, disabled). Pass flag_keys to scope the response to specific flags. This is strongly recommended on projects with many flags, since omitting it returns an entry for every flag and can produce a very large payload. Flags that bucket by device hash on $device_id rather than distinct_id: the most recent $device_id on the distinct_id's events is used automatically, and device_id can be passed to check a specific device.", "category": "Feature flags", "feature": "flags", "summary": "Get feature flag evaluation reasons", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 38577b06621e..bacb3fdc26a6 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -84476,6 +84476,10 @@ export namespace Schemas { }; export type FeatureFlagsEvaluationReasonsRetrieveParams = { + /** + * Optional `$device_id` used to evaluate flags that bucket by device rather than by user. When omitted, the most recent `$device_id` on this distinct ID's events is used, so device-bucketed flags evaluate the same way they do for the SDK. Pass one explicitly to check a specific device. + */ + device_id?: string; /** * User distinct ID * @minLength 1 diff --git a/services/mcp/src/generated/feature_flags/api.ts b/services/mcp/src/generated/feature_flags/api.ts index 77444f8a0264..0d2877dfd28c 100644 --- a/services/mcp/src/generated/feature_flags/api.ts +++ b/services/mcp/src/generated/feature_flags/api.ts @@ -1081,6 +1081,12 @@ export const FeatureFlagsEvaluationReasonsRetrieveParams = /* @__PURE__ */ zod.o export const featureFlagsEvaluationReasonsRetrieveQueryGroupsDefault = `{}` export const FeatureFlagsEvaluationReasonsRetrieveQueryParams = /* @__PURE__ */ zod.object({ + device_id: zod + .string() + .optional() + .describe( + "Optional `$device_id` used to evaluate flags that bucket by device rather than by user. When omitted, the most recent `$device_id` on this distinct ID's events is used, so device-bucketed flags evaluate the same way they do for the SDK. Pass one explicitly to check a specific device." + ), distinct_id: zod.string().min(1).describe('User distinct ID'), flag_keys: zod .array(zod.string()) diff --git a/services/mcp/src/tools/generated/feature_flags.ts b/services/mcp/src/tools/generated/feature_flags.ts index 836f4ec1345f..e6ba0c7487b2 100644 --- a/services/mcp/src/tools/generated/feature_flags.ts +++ b/services/mcp/src/tools/generated/feature_flags.ts @@ -362,6 +362,7 @@ const featureFlagsEvaluationReasonsRetrieve = (): ToolBase< method: 'GET', path: `/api/projects/${encodeURIComponent(String(projectId))}/feature_flags/evaluation_reasons/`, query: { + device_id: params.device_id, distinct_id: params.distinct_id, flag_keys: params.flag_keys, groups: params.groups, diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flags-evaluation-reasons-retrieve.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flags-evaluation-reasons-retrieve.json index 2cddf38e2d3b..d633ffa2f1b1 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flags-evaluation-reasons-retrieve.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/feature-flags-evaluation-reasons-retrieve.json @@ -1,6 +1,10 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { + "device_id": { + "description": "Optional `$device_id` used to evaluate flags that bucket by device rather than by user. When omitted, the most recent `$device_id` on this distinct ID's events is used, so device-bucketed flags evaluate the same way they do for the SDK. Pass one explicitly to check a specific device.", + "type": "string" + }, "distinct_id": { "description": "User distinct ID", "minLength": 1,