Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion frontend/src/scenes/persons/RelatedFeatureFlags.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -102,6 +102,15 @@ const columns: LemonTableColumns<RelatedFeatureFlag> = [
properties, groups, or group properties used to evaluate the release conditions of the
flag.
</div>
<div>
Flags that use{' '}
<Link to="https://posthog.com/docs/feature-flags/device-bucketing">
device bucketing
</Link>{' '}
are evaluated with the most recent <code>$device_id</code> 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.
</div>
</div>
}
>
Expand Down
10 changes: 10 additions & 0 deletions posthog/api/services/flags_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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".
Expand Down Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions products/feature_flags/backend/api/feature_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
82 changes: 82 additions & 0 deletions products/feature_flags/backend/api/test/test_feature_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
APIBaseTest,
ClickhouseTestMixin,
FuzzyInt,
_create_event,
_create_person,
flush_persons_and_events,
snapshot_clickhouse_queries,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
112 changes: 112 additions & 0 deletions products/feature_flags/backend/device_bucketing.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions products/feature_flags/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion products/feature_flags/mcp/tools.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/mcp/schema/generated-tool-definitions.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading