diff --git a/common/ingestion/acceptance_tests/requirements.txt b/common/ingestion/acceptance_tests/requirements.txt index a91f25a9528a..1e0145defc9d 100644 --- a/common/ingestion/acceptance_tests/requirements.txt +++ b/common/ingestion/acceptance_tests/requirements.txt @@ -5,4 +5,4 @@ requests-toolbelt==1.0.0 boto3==1.29.7 docker==6.1.3 python-multipart==0.0.6 -posthog==6.7.6 +posthog==7.20.4 diff --git a/ee/clickhouse/views/groups.py b/ee/clickhouse/views/groups.py index 7ef4ee19578e..de1016256813 100644 --- a/ee/clickhouse/views/groups.py +++ b/ee/clickhouse/views/groups.py @@ -8,7 +8,6 @@ from django.utils import timezone import structlog -import posthoganalytics from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter from loginas.utils import is_impersonated_session @@ -44,6 +43,7 @@ ) from posthog.models.user import User from posthog.personhog_client.converters import GroupTypeMappingResult +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.user_access_control import UserAccessControlSerializerMixin from posthog.utils import str_to_bool @@ -871,7 +871,7 @@ def property_values(self, request: request.Request, **kw): ) def _is_crm_enabled(self, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "crm-iteration-one", str(user.distinct_id), groups={"organization": str(self.team.organization.id)}, diff --git a/ee/clickhouse/views/test/test_clickhouse_groups.py b/ee/clickhouse/views/test/test_clickhouse_groups.py index c5d9021acd73..fa51cd7175ac 100644 --- a/ee/clickhouse/views/test/test_clickhouse_groups.py +++ b/ee/clickhouse/views/test/test_clickhouse_groups.py @@ -179,7 +179,7 @@ def test_find_missing_group_key(self): self.assertEqual(response.status_code, 400) @freeze_time("2021-05-02") - @patch(f"{PATH}.posthoganalytics.feature_enabled", return_value=False) + @patch(f"{PATH}.feature_enabled_or_false", return_value=False) def test_retrieve_group_crm_disabled(self, _): index: GroupTypeIndex = 0 key = "key" @@ -207,7 +207,7 @@ def test_retrieve_group_crm_disabled(self, _): self.assertEqual(0, Notebook.objects.filter(team=self.team).count()) @freeze_time("2021-05-02") - @patch(f"{PATH}.posthoganalytics.feature_enabled", return_value=True) + @patch(f"{PATH}.feature_enabled_or_false", return_value=True) def test_retrieve_group_crm_enabled(self, _): index: GroupTypeIndex = 0 key = "key" @@ -246,7 +246,7 @@ def test_retrieve_group_crm_enabled(self, _): self.assertEqual(notebook.content[1]["type"], "text") @freeze_time("2021-05-02") - @patch(f"{PATH}.posthoganalytics.feature_enabled", return_value=True) + @patch(f"{PATH}.feature_enabled_or_false", return_value=True) def test_find_with_skip_create_notebook_does_not_create_notebook(self, _): index: GroupTypeIndex = 0 key = "key" @@ -295,7 +295,7 @@ def test_retrieve_group_with_notebook(self): @freeze_time("2021-05-02") @patch("products.notebooks.backend.logic.ResourceNotebook.objects.create", side_effect=IntegrityError) - @patch(f"{PATH}.posthoganalytics.feature_enabled", return_value=True) + @patch(f"{PATH}.feature_enabled_or_false", return_value=True) def test_retrieve_group_notebook_transaction_rollback(self, _, mock_relationship_create): index: GroupTypeIndex = 0 key = "key" diff --git a/ee/hogai/utils/feature_flags.py b/ee/hogai/utils/feature_flags.py index 3382bfdd032f..3087069460f3 100644 --- a/ee/hogai/utils/feature_flags.py +++ b/ee/hogai/utils/feature_flags.py @@ -3,6 +3,7 @@ import posthoganalytics from posthog.models import Team, User +from posthog.ph_client import feature_enabled_or_false from products.business_knowledge.backend.logic import has_feature_flag as bk_has_feature_flag @@ -14,7 +15,7 @@ def is_privacy_mode_enabled(team: Team) -> bool: """ Check if privacy mode is enabled for a team's organization. """ - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-privacy-mode", str(team.organization_id), groups={"organization": str(team.organization_id)}, @@ -24,7 +25,7 @@ def is_privacy_mode_enabled(team: Team) -> bool: def has_phai_tasks_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-tasks", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -34,7 +35,7 @@ def has_phai_tasks_feature_flag(team: Team, user: User) -> bool: def has_task_tool_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-task-tool", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -44,7 +45,7 @@ def has_task_tool_feature_flag(team: Team, user: User) -> bool: def has_conversation_topic_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "posthog-ai-web-analytics-nudge", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -54,7 +55,7 @@ def has_conversation_topic_feature_flag(team: Team, user: User) -> bool: def has_memory_tool_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-memory-tool", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -64,7 +65,7 @@ def has_memory_tool_feature_flag(team: Team, user: User) -> bool: def has_plan_mode_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-plan-mode", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -74,7 +75,7 @@ def has_plan_mode_feature_flag(team: Team, user: User) -> bool: def has_experiment_summary_tool_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "experiment-ai-summary", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -84,7 +85,7 @@ def has_experiment_summary_tool_feature_flag(team: Team, user: User) -> bool: def is_core_memory_disabled(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-core-mem-disabled", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -94,7 +95,7 @@ def is_core_memory_disabled(team: Team, user: User) -> bool: def has_mcp_servers_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "mcp-servers", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -104,7 +105,7 @@ def has_mcp_servers_feature_flag(team: Team, user: User) -> bool: def has_sandbox_mode_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "phai-sandbox-mode", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -114,7 +115,7 @@ def has_sandbox_mode_feature_flag(team: Team, user: User) -> bool: def has_user_interview_mode_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "user-interviews", str(user.distinct_id), groups={"organization": str(team.organization_id)}, @@ -124,7 +125,7 @@ def has_user_interview_mode_feature_flag(team: Team, user: User) -> bool: def has_customer_analytics_mode_feature_flag(team: Team, user: User) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "customer-analytics-csp", str(user.distinct_id), groups={"organization": str(team.organization_id)}, diff --git a/posthog/api/email_verification.py b/posthog/api/email_verification.py index 5057c4c288c2..9d4c7ffcf02e 100644 --- a/posthog/api/email_verification.py +++ b/posthog/api/email_verification.py @@ -1,11 +1,11 @@ from django.contrib.auth.models import AbstractBaseUser from django.contrib.auth.tokens import PasswordResetTokenGenerator -import posthoganalytics from rest_framework import exceptions from posthog.exceptions_capture import capture_exception from posthog.models.user import User +from posthog.ph_client import feature_enabled_or_false from posthog.tasks.email import send_email_verification VERIFICATION_DISABLED_FLAG = "email-verification-disabled" @@ -13,7 +13,7 @@ def is_email_verification_disabled(user: User) -> bool: # using disabled here so that the default state (if no flag exists) is that verification defaults to ON. - return user.organization is not None and posthoganalytics.feature_enabled( + return user.organization is not None and feature_enabled_or_false( VERIFICATION_DISABLED_FLAG, str(user.organization.id), groups={"organization": str(user.organization.id)}, diff --git a/posthog/hogql/database/database.py b/posthog/hogql/database/database.py index 416b9977b543..f7cad6bcfb92 100644 --- a/posthog/hogql/database/database.py +++ b/posthog/hogql/database/database.py @@ -14,7 +14,6 @@ from django.db.models import Prefetch, Q import structlog -import posthoganalytics from opentelemetry import trace from pydantic import BaseModel, ConfigDict @@ -143,6 +142,7 @@ from posthog.models.group_type_mapping import get_group_types_for_project from posthog.models.organization import OrganizationMembership from posthog.models.team.team import Team, WeekStartDay +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.user_access_control import NO_ACCESS_LEVEL, UserAccessControl from posthog.schema_enums import DatabaseSerializedFieldType, PersonsOnEventsMode, SessionTableVersion from posthog.synthetic_user import SyntheticUser @@ -1089,7 +1089,7 @@ def _fetch_sources( is_direct_query = connection_id is not None with timings.measure("feature_flags", emit_span=True): - is_managed_viewset_enabled = posthoganalytics.feature_enabled( + is_managed_viewset_enabled = feature_enabled_or_false( "managed-viewsets", str(team.uuid), groups={ @@ -1131,7 +1131,7 @@ def _fetch_sources( team, user, user_access_control ) - is_hogql_warehouse_access_control_enabled = posthoganalytics.feature_enabled( + is_hogql_warehouse_access_control_enabled = feature_enabled_or_false( "hogql-warehouse-access-control", str(team.uuid), groups={"organization": str(team.organization_id), "project": str(team.id)}, diff --git a/posthog/hogql_queries/ai/ai_table_resolver.py b/posthog/hogql_queries/ai/ai_table_resolver.py index d98971398632..61b9a3b64427 100644 --- a/posthog/hogql_queries/ai/ai_table_resolver.py +++ b/posthog/hogql_queries/ai/ai_table_resolver.py @@ -10,6 +10,7 @@ from posthog.clickhouse.query_tagging import Product, tag_queries, tags_context from posthog.hogql_queries.ai.ai_column_rewriter import rewrite_expr_for_events_table, rewrite_query_for_events_table from posthog.hogql_queries.ai.ai_property_rewriter import rewrite_expr_for_ai_events_table +from posthog.ph_client import feature_enabled_or_false AI_EVENTS_QUERY_TOTAL = Counter( "posthog_ai_events_query_total", @@ -38,6 +39,21 @@ class AIEventsUnavailableError(Exception): and the caller opted out of the events fallback (``fall_back_to_events=False``).""" +def is_ai_events_enabled(team: Team) -> bool: + """Kill switch for ai_events table reads. + + When disabled, all single-trace runners skip the ai_events attempt + and query the events table directly. + """ + return feature_enabled_or_false( + "ai-events-table-rollout", + str(team.id), + groups={"organization": str(team.organization_id)}, + group_properties={"organization": {"id": str(team.organization_id)}}, + send_feature_flag_events=False, + ) + + class AIEventsExpiredError(AIEventsUnavailableError): """The requested AI events exist in the shared events table but have aged out of ai_events (past its retention TTL).""" diff --git a/posthog/hogql_queries/ai/test/test_ai_table_resolver.py b/posthog/hogql_queries/ai/test/test_ai_table_resolver.py index 75ff9b44f452..8d783e2c92ad 100644 --- a/posthog/hogql_queries/ai/test/test_ai_table_resolver.py +++ b/posthog/hogql_queries/ai/test/test_ai_table_resolver.py @@ -3,7 +3,38 @@ from posthog.hogql import ast -from posthog.hogql_queries.ai.ai_table_resolver import AIEventsExpiredError, AIEventsNotFoundError, query_ai_events +from posthog.hogql_queries.ai.ai_table_resolver import ( + AIEventsExpiredError, + AIEventsNotFoundError, + is_ai_events_enabled, + query_ai_events, +) + + +class TestIsAiEventsEnabled: + @patch("posthog.hogql_queries.ai.ai_table_resolver.feature_enabled_or_false", return_value=True) + def test_returns_true_when_flag_enabled(self, mock_flag): + team = Mock(id=123, organization_id="org_abc") + assert is_ai_events_enabled(team) is True + mock_flag.assert_called_once_with( + "ai-events-table-rollout", + "123", + groups={"organization": "org_abc"}, + group_properties={"organization": {"id": "org_abc"}}, + send_feature_flag_events=False, + ) + + @patch("posthog.hogql_queries.ai.ai_table_resolver.feature_enabled_or_false", return_value=False) + def test_returns_false_when_flag_disabled(self, mock_flag): + team = Mock(id=456, organization_id="org_xyz") + assert is_ai_events_enabled(team) is False + mock_flag.assert_called_once_with( + "ai-events-table-rollout", + "456", + groups={"organization": "org_xyz"}, + group_properties={"organization": {"id": "org_xyz"}}, + send_feature_flag_events=False, + ) class TestQueryAiEvents: diff --git a/posthog/hogql_queries/hogql_cohort_query.py b/posthog/hogql_queries/hogql_cohort_query.py index 986e1b471b95..90904d3334c6 100644 --- a/posthog/hogql_queries/hogql_cohort_query.py +++ b/posthog/hogql_queries/hogql_cohort_query.py @@ -4,7 +4,6 @@ from numbers import Number from typing import Any, Literal, Optional, Union, cast -import posthoganalytics from rest_framework.exceptions import ValidationError from posthog.schema import ( @@ -49,6 +48,7 @@ from posthog.hogql_queries.utils.query_date_range import QueryDateRange from posthog.models import Filter, Property, Team from posthog.models.property import OperatorInterval, PropertyGroup +from posthog.ph_client import feature_enabled_or_false from posthog.types import AnyPropertyFilter from products.cohorts.backend.models.cohort import Cohort @@ -589,7 +589,7 @@ def _get_condition_for_property(self, prop: Property) -> ast.SelectQuery | ast.S raise ValueError(f"Invalid property type for Cohort queries: {prop.type}") def _should_combine_person_properties_and(self) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "hogql-cohort-combine-person-properties", str(self.team.uuid), groups={ @@ -609,7 +609,7 @@ def _should_combine_person_properties_and(self) -> bool: ) def _should_combine_person_properties_or(self) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "hogql-cohort-combine-person-properties-or", str(self.team.uuid), groups={ diff --git a/posthog/hogql_queries/insights/funnels/funnels_query_runner.py b/posthog/hogql_queries/insights/funnels/funnels_query_runner.py index 1c3af9300779..ea736ba5b510 100644 --- a/posthog/hogql_queries/insights/funnels/funnels_query_runner.py +++ b/posthog/hogql_queries/insights/funnels/funnels_query_runner.py @@ -7,7 +7,6 @@ from django.conf import settings import structlog -import posthoganalytics from posthog.schema import ( CachedFunnelsQueryResponse, @@ -52,6 +51,7 @@ from posthog.models import Team from posthog.models.filters.mixins.utils import cached_property from posthog.models.user import User +from posthog.ph_client import feature_enabled_or_false logger = structlog.get_logger(__name__) @@ -494,7 +494,7 @@ def _is_compare_active(self) -> bool: return self._team_flag_funnels_compare() def _team_flag_funnels_compare(self) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "product-analytics-funnels-compare", str(self.team.uuid), groups={ diff --git a/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py b/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py index 549cf37a56ba..4906dca96991 100644 --- a/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py +++ b/posthog/hogql_queries/insights/trends/test/test_trends_query_runner.py @@ -3091,7 +3091,7 @@ def test_cohort_modifier_with_too_few_cohorts(self, patch_create_default_modifie ), ] ) - @patch("posthog.hogql_queries.insights.trends.trends_query_runner.posthoganalytics.feature_enabled") + @patch("posthog.hogql_queries.insights.trends.trends_query_runner.feature_enabled_or_false") def test_session_property_pre_aggregation_modifier_gate( self, _name: str, @@ -3107,7 +3107,7 @@ def test_session_property_pre_aggregation_modifier_gate( ) assert runner.modifiers.sessionPropertyPreAggregation is expected - @patch("posthog.hogql_queries.insights.trends.trends_query_runner.posthoganalytics.feature_enabled") + @patch("posthog.hogql_queries.insights.trends.trends_query_runner.feature_enabled_or_false") def test_session_property_pre_aggregation_modifier_clears_on_dashboard_reapply(self, patch_feature_enabled): # apply_dashboard_filters re-runs __post_init__. The modifier must reflect the *current* # query state, not the initial one — so a session-breakdown query that gets overridden diff --git a/posthog/hogql_queries/insights/trends/trends_query_builder.py b/posthog/hogql_queries/insights/trends/trends_query_builder.py index 6f12e9abb312..207a52aba116 100644 --- a/posthog/hogql_queries/insights/trends/trends_query_builder.py +++ b/posthog/hogql_queries/insights/trends/trends_query_builder.py @@ -1,7 +1,5 @@ from typing import cast -import posthoganalytics - from posthog.schema import ( ActionsNode, Breakdown as BreakdownSchema, @@ -28,6 +26,7 @@ from posthog.hogql_queries.utils.query_date_range import QueryDateRange from posthog.models.filters.mixins.utils import cached_property from posthog.models.team.team import Team +from posthog.ph_client import feature_enabled_or_false from products.actions.backend.models.action import Action @@ -1012,7 +1011,7 @@ def _breakdown_outer_query_filter(self, breakdown: Breakdown): ) def _team_flag_fewer_array_ops(self) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "trends-breakdown-fewer-array-ops", str(self.team.uuid), groups={ diff --git a/posthog/hogql_queries/insights/trends/trends_query_runner.py b/posthog/hogql_queries/insights/trends/trends_query_runner.py index f17291e027ab..249a93c0df99 100644 --- a/posthog/hogql_queries/insights/trends/trends_query_runner.py +++ b/posthog/hogql_queries/insights/trends/trends_query_runner.py @@ -10,7 +10,6 @@ from django.db import models from django.db.models.functions import Coalesce -import posthoganalytics from natsort import natsorted, ns from posthog.schema import ( @@ -81,6 +80,7 @@ from posthog.models import Team from posthog.models.filters.mixins.utils import cached_property from posthog.models.user import User +from posthog.ph_client import feature_enabled_or_false from posthog.queries.util import correct_result_for_sampling from posthog.utils import multisort @@ -884,7 +884,7 @@ def _has_session_breakdown(self) -> bool: return any(breakdown.type == "session" for breakdown in (filter.breakdowns or [])) def _team_flag_session_property_pre_aggregation(self) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "trends-session-property-pre-aggregation", str(self.team.uuid), groups={ diff --git a/posthog/hogql_queries/legacy_compatibility/feature_flag.py b/posthog/hogql_queries/legacy_compatibility/feature_flag.py index 4a069b6336ee..65c18f588246 100644 --- a/posthog/hogql_queries/legacy_compatibility/feature_flag.py +++ b/posthog/hogql_queries/legacy_compatibility/feature_flag.py @@ -1,16 +1,16 @@ from typing import Literal -import posthoganalytics from rest_framework.request import Request from posthog.models import Team +from posthog.ph_client import feature_enabled_or_false def insight_api_use_legacy_queries(team: Team) -> bool: """ Use the legacy implementation of insight api calculation endpoints. """ - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "insight-api-use-legacy-queries", str(team.uuid), groups={ diff --git a/posthog/models/team/team.py b/posthog/models/team/team.py index 220434180c0b..22b740e19098 100644 --- a/posthog/models/team/team.py +++ b/posthog/models/team/team.py @@ -14,7 +14,6 @@ import pytz import pydantic -import posthoganalytics from posthog.clickhouse.query_tagging import Feature, Product, tag_queries, tags_context from posthog.cloud_utils import is_cloud @@ -33,6 +32,7 @@ sane_repr, validate_rate_limit, ) +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.decorators import field_access_control from posthog.session_recordings.models.session_recording_playlist import SessionRecordingPlaylist from posthog.settings.utils import get_list @@ -796,7 +796,7 @@ def _person_on_events_person_id_no_override_properties_on_events(self) -> bool: # on PostHog Cloud, use the feature flag if is_cloud(): - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "persons-on-events-person-id-no-override-properties-on-events", str(self.uuid), groups={"project": str(self.id)}, @@ -822,7 +822,7 @@ def _person_on_events_person_id_override_properties_on_events(self) -> bool: # on PostHog Cloud, use the feature flag if is_cloud(): - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "persons-on-events-v2-reads-enabled", str(self.uuid), groups={"organization": str(self.organization_id)}, diff --git a/posthog/ph_client.py b/posthog/ph_client.py index ca0e768cdced..814d41173ccb 100644 --- a/posthog/ph_client.py +++ b/posthog/ph_client.py @@ -1,7 +1,11 @@ +from collections.abc import Mapping from contextlib import contextmanager +from numbers import Number from typing import Any +from uuid import UUID import structlog +import posthoganalytics from posthog.cloud_utils import is_cloud from posthog.utils import get_instance_region @@ -15,6 +19,33 @@ logger = structlog.get_logger(__name__) +def feature_enabled_or_false( + key: str, + distinct_id: Number | str | UUID | int, + groups: Mapping[str, str | int] | None = None, + person_properties: dict[str, Any] | None = None, + group_properties: dict[str, dict[str, Any]] | None = None, + only_evaluate_locally: bool = False, + send_feature_flag_events: bool = True, + disable_geoip: bool | None = None, + device_id: str | None = None, +) -> bool: + return ( + posthoganalytics.feature_enabled( + key, + distinct_id, + groups=groups, + person_properties=person_properties, + group_properties=group_properties, + only_evaluate_locally=only_evaluate_locally, + send_feature_flag_events=send_feature_flag_events, + disable_geoip=disable_geoip, + device_id=device_id, + ) + is True + ) + + def get_regional_ph_client(**kwargs: Any): if not is_cloud(): return diff --git a/posthog/session_recordings/queries/sub_queries/events_subquery.py b/posthog/session_recordings/queries/sub_queries/events_subquery.py index 72e9ae6b68e6..1227a987d8b9 100644 --- a/posthog/session_recordings/queries/sub_queries/events_subquery.py +++ b/posthog/session_recordings/queries/sub_queries/events_subquery.py @@ -21,6 +21,7 @@ from posthog.clickhouse.query_tagging import Feature, Product, tag_queries from posthog.hogql_queries.legacy_compatibility.filter_to_query import MathAvailability, legacy_entity_to_node from posthog.models import Entity, EventProperty, Team +from posthog.ph_client import feature_enabled_or_false from posthog.session_recordings.queries.sub_queries.base_query import SessionRecordingsListingBaseQuery from posthog.session_recordings.queries.utils import ( INVERSE_OPERATOR_FOR, @@ -120,7 +121,7 @@ def _select_from_events( # with a bad experience filtering out 60% of results incorrectly # We use a feature flag to control this behavior for safe rollout. - remove_order_by = posthoganalytics.feature_enabled( + remove_order_by = feature_enabled_or_false( "remove-order-by-for-event-subquery", str(self._team.organization.id), send_feature_flag_events=False, @@ -151,7 +152,7 @@ def _is_hybrid_query_mode_enabled(self) -> bool: This solves the "late identification problem" where filtering by person properties in standard PoE mode only finds sessions where those properties existed at event time. """ - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "enable-hybrid-poe-replay-filtering", str(self._team.id), send_feature_flag_events=False, diff --git a/posthog/session_recordings/test/test_session_recording_playlist.py b/posthog/session_recordings/test/test_session_recording_playlist.py index 093698870d5d..aa75e9745d3c 100644 --- a/posthog/session_recordings/test/test_session_recording_playlist.py +++ b/posthog/session_recordings/test/test_session_recording_playlist.py @@ -903,7 +903,7 @@ def test_add_remove_static_playlist_items(self): ) @patch( - "posthog.hogql.database.database.posthoganalytics.feature_enabled", + "posthog.hogql.database.database.feature_enabled_or_false", new=MagicMock(return_value=False), ) @snapshot_postgres_queries diff --git a/posthog/session_recordings/test/test_session_recordings.py b/posthog/session_recordings/test/test_session_recordings.py index a11be9e71153..823b708d69a0 100644 --- a/posthog/session_recordings/test/test_session_recordings.py +++ b/posthog/session_recordings/test/test_session_recordings.py @@ -105,7 +105,7 @@ def produce_replay_summary( ] ) @patch( - "posthog.hogql.database.database.posthoganalytics.feature_enabled", + "posthog.hogql.database.database.feature_enabled_or_false", new=MagicMock(return_value=False), ) @snapshot_postgres_queries @@ -155,7 +155,7 @@ def test_get_session_recordings_scenarios( assert len(results[0]["person"]["distinct_ids"]) == expected_distinct_ids_count @patch( - "posthog.hogql.database.database.posthoganalytics.feature_enabled", + "posthog.hogql.database.database.feature_enabled_or_false", new=MagicMock(return_value=False), ) @snapshot_postgres_queries @@ -222,7 +222,7 @@ def test_get_session_recordings_includes_person_data(self) -> None: ] ) @patch( - "posthog.hogql.database.database.posthoganalytics.feature_enabled", + "posthog.hogql.database.database.feature_enabled_or_false", new=MagicMock(return_value=False), ) @snapshot_postgres_queries @@ -352,7 +352,7 @@ def test_listing_recordings_is_not_nplus1_for_persons(self): with ( freeze_time("2022-06-03T12:00:00.000Z"), patch( - "posthog.hogql.database.database.posthoganalytics.feature_enabled", + "posthog.hogql.database.database.feature_enabled_or_false", return_value=False, ), snapshot_postgres_queries_context(self), diff --git a/posthog/tasks/email.py b/posthog/tasks/email.py index e71129f131fe..19a421150a2c 100644 --- a/posthog/tasks/email.py +++ b/posthog/tasks/email.py @@ -27,7 +27,7 @@ from posthog.models.comment.utils import build_comment_item_url from posthog.models.messaging import MessagingRecord, get_email_hashes from posthog.models.utils import UUIDT -from posthog.ph_client import get_client +from posthog.ph_client import feature_enabled_or_false, get_client from posthog.scoping_audit import skip_team_scope_audit from posthog.user_permissions import UserPermissions @@ -1107,7 +1107,7 @@ def login_from_new_device_notification( elif user.current_organization is None: enabled = False else: - enabled = posthoganalytics.feature_enabled( + enabled = feature_enabled_or_false( key="login-from-new-device-notification", distinct_id=str(user.distinct_id), groups={"organization": str(user.current_organization.id)}, diff --git a/posthog/temporal/common/test_scoped.py b/posthog/temporal/common/test_scoped.py index 00a78a8bb871..cbe4c6e7450a 100644 --- a/posthog/temporal/common/test_scoped.py +++ b/posthog/temporal/common/test_scoped.py @@ -76,15 +76,16 @@ async def my_activity(x: int) -> int: def test_upstream_scoped_breaks_iscoroutinefunction_on_async_fn() -> None: - """Documents the upstream bug: posthoganalytics.scoped() wraps an async function - in a synchronous wrapper, defeating the iscoroutinefunction check Temporal uses - to dispatch activities.""" + """Documents the upstream bug in older SDKs while tolerating fixed SDKs.""" @posthoganalytics.scoped() async def my_activity(x: int) -> int: return x + 1 - assert not inspect.iscoroutinefunction(my_activity) + if inspect.iscoroutinefunction(my_activity): + assert asyncio.run(my_activity(1)) == 2 + return + coro = my_activity(1) try: assert inspect.iscoroutine(coro) @@ -93,15 +94,21 @@ async def my_activity(x: int) -> int: def test_upstream_scoped_result_fails_json_encoding() -> None: - """Reproduce the exact production failure: an upstream-scoped async activity - returns an unawaited coroutine, and json.dumps raises the same TypeError that - appears in Temporal's payload-encoder traceback.""" + """Reproduce the old SDK failure while tolerating async-aware scoped().""" @posthoganalytics.scoped() async def my_activity(x: int) -> int: return x + 1 result = my_activity(1) + if inspect.iscoroutinefunction(my_activity): + try: + assert asyncio.run(result) == 2 + finally: + if inspect.iscoroutine(result): + result.close() + return + try: with pytest.raises(TypeError, match="coroutine"): json.dumps(result) diff --git a/posthog/temporal/data_modeling/activities/materialize_view_duckgres.py b/posthog/temporal/data_modeling/activities/materialize_view_duckgres.py index 9dd8d32d3931..802ff59f771f 100644 --- a/posthog/temporal/data_modeling/activities/materialize_view_duckgres.py +++ b/posthog/temporal/data_modeling/activities/materialize_view_duckgres.py @@ -3,13 +3,13 @@ import datetime as dt import dataclasses -import posthoganalytics from structlog.contextvars import bind_contextvars from temporalio import activity from posthog.ducklake.common import get_duckgres_server_for_organization, is_dev_mode from posthog.exceptions_capture import capture_exception from posthog.models import Team +from posthog.ph_client import feature_enabled_or_false from posthog.sync import database_sync_to_async_pool from posthog.temporal.common.logger import get_logger @@ -63,7 +63,7 @@ def _is_duckgres_shadow_enabled(team: Team) -> bool: return False try: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( FEATURE_FLAG, str(team.pk), groups={ diff --git a/posthog/temporal/ducklake/ducklake_copy_data_imports_workflow.py b/posthog/temporal/ducklake/ducklake_copy_data_imports_workflow.py index b74ca100552d..3a0a2b561a0c 100644 --- a/posthog/temporal/ducklake/ducklake_copy_data_imports_workflow.py +++ b/posthog/temporal/ducklake/ducklake_copy_data_imports_workflow.py @@ -8,7 +8,6 @@ import duckdb import deltalake -import posthoganalytics from structlog.contextvars import bind_contextvars from temporalio import activity, workflow from temporalio.common import RetryPolicy @@ -42,6 +41,7 @@ ) from posthog.exceptions_capture import capture_exception from posthog.models import Team +from posthog.ph_client import feature_enabled_or_false from posthog.sync import database_sync_to_async from posthog.temporal.common.base import PostHogWorkflow from posthog.temporal.common.heartbeat_sync import HeartbeaterSync @@ -163,7 +163,7 @@ async def ducklake_copy_data_imports_gate_activity(inputs: DuckLakeCopyWorkflowG # same posthog_data_imports_team_{id} tables with zero coordination, so a # team must never have both enabled. The sink wins. try: - if posthoganalytics.feature_enabled( + if feature_enabled_or_false( DUCKGRES_BATCH_SINK_FLAG, str(team.uuid), groups={ @@ -182,7 +182,7 @@ async def ducklake_copy_data_imports_gate_activity(inputs: DuckLakeCopyWorkflowG capture_exception(error) try: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "ducklake-data-imports-copy-workflow", str(team.uuid), groups={ diff --git a/posthog/temporal/ducklake/ducklake_copy_data_modeling_workflow.py b/posthog/temporal/ducklake/ducklake_copy_data_modeling_workflow.py index c9c9a6b97c58..e8d091bbb022 100644 --- a/posthog/temporal/ducklake/ducklake_copy_data_modeling_workflow.py +++ b/posthog/temporal/ducklake/ducklake_copy_data_modeling_workflow.py @@ -4,7 +4,6 @@ import duckdb import deltalake -import posthoganalytics from structlog.contextvars import bind_contextvars from temporalio import activity, workflow from temporalio.common import RetryPolicy @@ -37,6 +36,7 @@ ) from posthog.exceptions_capture import capture_exception from posthog.models import Team +from posthog.ph_client import feature_enabled_or_false from posthog.sync import database_sync_to_async from posthog.temporal.common.base import PostHogWorkflow from posthog.temporal.common.heartbeat_sync import HeartbeaterSync @@ -104,7 +104,7 @@ async def ducklake_copy_workflow_gate_activity(inputs: DuckLakeCopyWorkflowGateI return False try: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "ducklake-data-modeling-copy-workflow", str(team.uuid), groups={ diff --git a/posthog/temporal/tests/ducklake/test_ducklake_copy_data_imports_workflow.py b/posthog/temporal/tests/ducklake/test_ducklake_copy_data_imports_workflow.py index 7018567d1a26..ee576ac8e461 100644 --- a/posthog/temporal/tests/ducklake/test_ducklake_copy_data_imports_workflow.py +++ b/posthog/temporal/tests/ducklake/test_ducklake_copy_data_imports_workflow.py @@ -101,7 +101,7 @@ def fake_feature_enabled( return flag_enabled monkeypatch.setattr( - "posthog.temporal.ducklake.ducklake_copy_data_imports_workflow.posthoganalytics.feature_enabled", + "posthog.temporal.ducklake.ducklake_copy_data_imports_workflow.feature_enabled_or_false", fake_feature_enabled, ) @@ -125,7 +125,7 @@ def fake_feature_enabled(key, distinct_id, **kwargs): return True monkeypatch.setattr( - "posthog.temporal.ducklake.ducklake_copy_data_imports_workflow.posthoganalytics.feature_enabled", + "posthog.temporal.ducklake.ducklake_copy_data_imports_workflow.feature_enabled_or_false", fake_feature_enabled, ) @@ -1039,8 +1039,8 @@ async def copy_stub(inputs: DuckLakeCopyDataImportsActivityInputs): call_counts["copy"] += 1 monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", lambda *args, **kwargs: False, ) monkeypatch.setattr(ducklake_module, "prepare_data_imports_ducklake_metadata_activity", metadata_stub) @@ -1106,8 +1106,8 @@ async def verify_stub(inputs: DuckLakeCopyDataImportsActivityInputs): return [] monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", # Key-aware: the gate checks the duckgres-batch-sink exclusion first, # and a catch-all True would wrongly trip it. lambda key, *args, **kwargs: key == "ducklake-data-imports-copy-workflow", @@ -1198,8 +1198,8 @@ async def cleanup_stub(inputs: DuckLakeDataImportsStagingCleanupInputs): call_counts["cleanup"] += 1 monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", # Key-aware: the gate checks the duckgres-batch-sink exclusion first, # and a catch-all True would wrongly trip it. lambda key, *args, **kwargs: key == "ducklake-data-imports-copy-workflow", diff --git a/posthog/temporal/tests/ducklake/test_ducklake_copy_data_modeling_workflow.py b/posthog/temporal/tests/ducklake/test_ducklake_copy_data_modeling_workflow.py index 5535c246ec49..2d3d167a40d0 100644 --- a/posthog/temporal/tests/ducklake/test_ducklake_copy_data_modeling_workflow.py +++ b/posthog/temporal/tests/ducklake/test_ducklake_copy_data_modeling_workflow.py @@ -361,8 +361,8 @@ async def copy_stub(inputs: DuckLakeCopyActivityInputs): call_counts["copy"] += 1 monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", lambda *args, **kwargs: False, ) monkeypatch.setattr(ducklake_module, "prepare_data_modeling_ducklake_metadata_activity", metadata_stub) @@ -847,8 +847,8 @@ async def verify_stub(inputs: DuckLakeCopyActivityInputs): return [] monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", lambda *args, **kwargs: True, ) @@ -930,8 +930,8 @@ async def cleanup_stub(inputs: DuckLakeDataModelingStagingCleanupInputs): call_counts["cleanup"] += 1 monkeypatch.setattr( - ducklake_module.posthoganalytics, - "feature_enabled", + ducklake_module, + "feature_enabled_or_false", lambda *args, **kwargs: True, ) monkeypatch.setattr(ducklake_module, "prepare_data_modeling_ducklake_metadata_activity", metadata_stub) diff --git a/posthog/test/test_team.py b/posthog/test/test_team.py index 1b2ad7fad648..18dcccf937dd 100644 --- a/posthog/test/test_team.py +++ b/posthog/test/test_team.py @@ -211,6 +211,7 @@ def test_team_on_cloud_uses_feature_flag_to_determine_person_on_events(self, moc "persons-on-events-v2-reads-enabled", str(team.uuid), groups={"organization": str(self.organization.id)}, + person_properties=None, group_properties={ "organization": { "id": str(self.organization.id), @@ -219,6 +220,8 @@ def test_team_on_cloud_uses_feature_flag_to_determine_person_on_events(self, moc }, only_evaluate_locally=True, send_feature_flag_events=False, + disable_geoip=None, + device_id=None, ) @mock.patch("posthoganalytics.feature_enabled", return_value=False) diff --git a/products/ai_observability/backend/api/score_definitions.py b/products/ai_observability/backend/api/score_definitions.py index a470df58ed6c..7078d8d6a0fe 100644 --- a/products/ai_observability/backend/api/score_definitions.py +++ b/products/ai_observability/backend/api/score_definitions.py @@ -11,6 +11,7 @@ from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action +from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.response import Response @@ -19,13 +20,36 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.event_usage import report_user_action -from posthog.models import User +from posthog.models import Team, User from posthog.permissions import AccessControlPermission +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin from products.ai_observability.backend.models.score_definitions import ScoreDefinition, StaleScoreDefinitionVersion from products.ai_observability.backend.score_definition_configs import ScoreDefinitionConfigField +HUMAN_REVIEWS_FEATURE_FLAG = "llma-trace-review" + + +def is_human_reviews_feature_enabled(user: User, team: Team) -> bool: + distinct_id = user.distinct_id or str(user.uuid) + organization_id = str(team.organization_id) + project_id = str(team.id) + + return feature_enabled_or_false( + HUMAN_REVIEWS_FEATURE_FLAG, + distinct_id, + groups={"organization": organization_id, "project": project_id}, + group_properties={"organization": {"id": organization_id}, "project": {"id": project_id}}, + only_evaluate_locally=False, + send_feature_flag_events=False, + ) + + +class HumanReviewsFeatureFlagPermission(BasePermission): + def has_permission(self, request, view) -> bool: + return is_human_reviews_feature_enabled(cast(User, request.user), view.team) + class ScoreDefinitionSerializer(serializers.ModelSerializer): created_by = UserBasicSerializer( @@ -177,7 +201,7 @@ class ScoreDefinitionViewSet( viewsets.GenericViewSet, ): scope_object = "llm_analytics" - permission_classes = [AccessControlPermission] + permission_classes = [HumanReviewsFeatureFlagPermission, AccessControlPermission] serializer_class = ScoreDefinitionSerializer queryset = ScoreDefinition.objects.all() filter_backends = [DjangoFilterBackend] diff --git a/products/ai_observability/backend/api/test/test_ai_observability_access_control.py b/products/ai_observability/backend/api/test/test_ai_observability_access_control.py index 2f9186dc2720..71ede47b46ac 100644 --- a/products/ai_observability/backend/api/test/test_ai_observability_access_control.py +++ b/products/ai_observability/backend/api/test/test_ai_observability_access_control.py @@ -37,6 +37,17 @@ def setUp(self): ] self.organization.save() + self.score_definitions_flag_patcher = patch( + "products.ai_observability.backend.api.score_definitions.feature_enabled_or_false", return_value=True + ) + self.trace_reviews_flag_patcher = patch( + "products.ai_observability.backend.api.trace_reviews.feature_enabled_or_false", return_value=True + ) + self.score_definitions_flag_patcher.start() + self.trace_reviews_flag_patcher.start() + self.addCleanup(self.score_definitions_flag_patcher.stop) + self.addCleanup(self.trace_reviews_flag_patcher.stop) + AccessControl.objects.create( team=self.team, resource="project", diff --git a/products/ai_observability/backend/api/test/test_evaluations.py b/products/ai_observability/backend/api/test/test_evaluations.py index 48b7abd9d275..701d7f072193 100644 --- a/products/ai_observability/backend/api/test/test_evaluations.py +++ b/products/ai_observability/backend/api/test/test_evaluations.py @@ -114,9 +114,7 @@ def test_evaluation_rollback_when_auto_report_fails(self): self.assertEqual(EvaluationReport.objects.count(), 0) def test_can_create_sentiment_evaluation_without_default_report(self): - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=True - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=True): response = self.client.post( f"/api/environments/{self.team.id}/evaluations/", { @@ -139,9 +137,7 @@ def test_can_create_sentiment_evaluation_without_default_report(self): self.assertEqual(EvaluationReport.objects.filter(evaluation=evaluation).count(), 0) def test_create_sentiment_evaluation_requires_feature_flag(self): - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=False - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=False): response = self.client.post( f"/api/environments/{self.team.id}/evaluations/", { @@ -172,9 +168,7 @@ def test_re_enable_sentiment_evaluation_requires_feature_flag(self): created_by=self.user, ) - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=False - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=False): response = self.client.patch( f"/api/environments/{self.team.id}/evaluations/{evaluation.id}/", {"enabled": True}, @@ -198,9 +192,7 @@ def test_update_existing_sentiment_evaluation_allows_unchanged_type_when_feature created_by=self.user, ) - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=False - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=False): response = self.client.patch( f"/api/environments/{self.team.id}/evaluations/{evaluation.id}/", { @@ -218,9 +210,7 @@ def test_update_existing_sentiment_evaluation_allows_unchanged_type_when_feature self.assertEqual(evaluation.name, "Updated Sentiment Evaluation") def test_sentiment_evaluation_rejects_model_configuration(self): - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=True - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=True): response = self.client.post( f"/api/environments/{self.team.id}/evaluations/", { @@ -252,9 +242,7 @@ def test_sentiment_evaluation_rejects_model_configuration(self): def test_rejects_unsupported_evaluation_output_type_combinations( self, _name, evaluation_type, output_type, evaluation_config, output_config ): - with patch( - "products.ai_observability.backend.feature_flags.posthoganalytics.feature_enabled", return_value=True - ): + with patch("products.ai_observability.backend.feature_flags.feature_enabled_or_false", return_value=True): response = self.client.post( f"/api/environments/{self.team.id}/evaluations/", { diff --git a/products/ai_observability/backend/api/test/test_score_definitions.py b/products/ai_observability/backend/api/test/test_score_definitions.py index 6a680cf4e06b..beca27d01814 100644 --- a/products/ai_observability/backend/api/test/test_score_definitions.py +++ b/products/ai_observability/backend/api/test/test_score_definitions.py @@ -12,6 +12,14 @@ class TestScoreDefinitionsApi(APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.feature_flag_patcher = patch( + "products.ai_observability.backend.api.score_definitions.feature_enabled_or_false", return_value=True + ) + self.feature_flag_patcher.start() + self.addCleanup(self.feature_flag_patcher.stop) + def _endpoint(self) -> str: return f"/api/environments/{self.team.id}/llm_analytics/score_definitions/" diff --git a/products/ai_observability/backend/api/test/test_trace_reviews.py b/products/ai_observability/backend/api/test/test_trace_reviews.py index 4f4aa2463baa..be79375820d6 100644 --- a/products/ai_observability/backend/api/test/test_trace_reviews.py +++ b/products/ai_observability/backend/api/test/test_trace_reviews.py @@ -1,4 +1,5 @@ from posthog.test.base import APIBaseTest +from unittest.mock import patch from django.test import override_settings @@ -11,6 +12,14 @@ class TestTraceReviewsApi(APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.feature_flag_patcher = patch( + "products.ai_observability.backend.api.trace_reviews.feature_enabled_or_false", return_value=True + ) + self.feature_flag_patcher.start() + self.addCleanup(self.feature_flag_patcher.stop) + def _endpoint(self) -> str: return f"/api/environments/{self.team.id}/llm_analytics/trace_reviews/" diff --git a/products/ai_observability/backend/api/trace_reviews.py b/products/ai_observability/backend/api/trace_reviews.py index e13c0dbe6b37..57498e34983c 100644 --- a/products/ai_observability/backend/api/trace_reviews.py +++ b/products/ai_observability/backend/api/trace_reviews.py @@ -13,6 +13,7 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema_field from rest_framework import serializers, status +from rest_framework.permissions import BasePermission from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet @@ -24,6 +25,7 @@ from posthog.event_usage import report_user_action from posthog.models import Team, User from posthog.permissions import AccessControlPermission +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin from products.ai_observability.backend.api.metrics import llma_track_latency @@ -35,9 +37,30 @@ normalize_score_definition_key, ) +TRACE_REVIEW_FEATURE_FLAG = "llma-trace-review" TRACE_REVIEW_SCORE_VALUE_FIELDS = ("categorical_values", "numeric_value", "boolean_value") +def is_trace_review_feature_enabled(user: User, team: Team) -> bool: + distinct_id = user.distinct_id or str(user.uuid) + organization_id = str(team.organization_id) + project_id = str(team.id) + + return feature_enabled_or_false( + TRACE_REVIEW_FEATURE_FLAG, + distinct_id, + groups={"organization": organization_id, "project": project_id}, + group_properties={"organization": {"id": organization_id}, "project": {"id": project_id}}, + only_evaluate_locally=False, + send_feature_flag_events=False, + ) + + +class TraceReviewFeatureFlagPermission(BasePermission): + def has_permission(self, request, view) -> bool: + return is_trace_review_feature_enabled(cast(User, request.user), view.team) + + class TraceReviewScoreSerializer(serializers.ModelSerializer): categorical_values = serializers.ListField( read_only=True, @@ -566,7 +589,7 @@ def filter_search(self, queryset: QuerySet, _name: str, value: str) -> QuerySet: class TraceReviewViewSet(TeamAndOrgViewSetMixin, AccessControlViewSetMixin, ModelViewSet): scope_object = "llm_analytics" - permission_classes = [AccessControlPermission] + permission_classes = [TraceReviewFeatureFlagPermission, AccessControlPermission] serializer_class = TraceReviewSerializer queryset = TraceReview.objects.all() filter_backends = [DjangoFilterBackend] diff --git a/products/ai_observability/backend/feature_flags.py b/products/ai_observability/backend/feature_flags.py index 396c93555881..591cacd5b4f9 100644 --- a/products/ai_observability/backend/feature_flags.py +++ b/products/ai_observability/backend/feature_flags.py @@ -1,8 +1,7 @@ from __future__ import annotations -import posthoganalytics - from posthog.models import Team, User +from posthog.ph_client import feature_enabled_or_false SENTIMENT_EVALUATIONS_FEATURE_FLAG = "llm-analytics-sentiment-evaluations" @@ -12,7 +11,7 @@ def is_sentiment_evaluations_enabled(user: User, team: Team) -> bool: organization_id = str(team.organization_id) project_id = str(team.id) - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( SENTIMENT_EVALUATIONS_FEATURE_FLAG, distinct_id, groups={"organization": organization_id, "project": project_id}, diff --git a/products/business_knowledge/backend/logic.py b/products/business_knowledge/backend/logic.py index 1cbdae1cee20..aba8e6e40dca 100644 --- a/products/business_knowledge/backend/logic.py +++ b/products/business_knowledge/backend/logic.py @@ -25,7 +25,6 @@ from django.utils import timezone import structlog -import posthoganalytics from langchain_core.messages import HumanMessage, SystemMessage from posthog.api.embedding_worker import generate_embedding @@ -34,6 +33,7 @@ from posthog.models.scoping import with_team_scope from posthog.models.team.team import Team from posthog.models.user import User +from posthog.ph_client import feature_enabled_or_false from posthog.security.url_validation import is_url_allowed from ee.hogai.llm import MaxChatAnthropic @@ -1625,7 +1625,7 @@ def has_feature_flag(team: Team) -> bool: check — `ee/hogai/utils/feature_flags.py` delegates here.""" if settings.DEBUG: return True - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "product-business-knowledge", str(team.organization_id), groups={"organization": str(team.organization_id)}, diff --git a/products/data_modeling/backend/api/node.py b/products/data_modeling/backend/api/node.py index 3ab2eb16b32f..a4a85d1da826 100644 --- a/products/data_modeling/backend/api/node.py +++ b/products/data_modeling/backend/api/node.py @@ -8,7 +8,6 @@ from django.db import models from django.db.models import OuterRef, Subquery -import posthoganalytics from rest_framework import filters, request, response, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.pagination import PageNumberPagination @@ -17,6 +16,7 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.scoped_related_fields import TeamScopedPrimaryKeyRelatedField from posthog.models import Team, User +from posthog.ph_client import feature_enabled_or_false from posthog.temporal.common.client import sync_connect from posthog.temporal.data_modeling.run_workflow import RunWorkflowInputs, Selector from posthog.temporal.data_modeling.workflows.execute_dag import ExecuteDAGInputs @@ -108,7 +108,7 @@ class NodePagination(PageNumberPagination): def _is_v2_backend_enabled(user: User, team: Team) -> bool: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "data-modeling-backend-v2", str(user.distinct_id), groups={ diff --git a/products/data_modeling/backend/api/test/test_node_api.py b/products/data_modeling/backend/api/test/test_node_api.py index 7a59efab9148..40bb3119f0f4 100644 --- a/products/data_modeling/backend/api/test/test_node_api.py +++ b/products/data_modeling/backend/api/test/test_node_api.py @@ -205,7 +205,7 @@ def test_materialize_starts_workflow(self, mock_sync_connect): self.assertEqual(response.status_code, status.HTTP_200_OK) mock_client.start_workflow.assert_called_once() - @patch("products.data_modeling.backend.api.node.posthoganalytics.feature_enabled", return_value=True) + @patch("products.data_modeling.backend.api.node.feature_enabled_or_false", return_value=True) @patch("products.data_modeling.backend.api.node.sync_connect") def test_run_uses_execute_dag_when_v2_enabled(self, mock_sync_connect, mock_feature_flag): mock_client = AsyncMock() @@ -220,7 +220,7 @@ def test_run_uses_execute_dag_when_v2_enabled(self, mock_sync_connect, mock_feat call_args = mock_client.start_workflow.call_args self.assertEqual(call_args[0][0], "data-modeling-execute-dag") - @patch("products.data_modeling.backend.api.node.posthoganalytics.feature_enabled", return_value=False) + @patch("products.data_modeling.backend.api.node.feature_enabled_or_false", return_value=False) @patch("products.data_modeling.backend.api.node.sync_connect") def test_run_uses_run_workflow_when_v2_disabled(self, mock_sync_connect, mock_feature_flag): mock_client = AsyncMock() @@ -235,7 +235,7 @@ def test_run_uses_run_workflow_when_v2_disabled(self, mock_sync_connect, mock_fe call_args = mock_client.start_workflow.call_args self.assertEqual(call_args[0][0], "data-modeling-run") - @patch("products.data_modeling.backend.api.node.posthoganalytics.feature_enabled", return_value=True) + @patch("products.data_modeling.backend.api.node.feature_enabled_or_false", return_value=True) @patch("products.data_modeling.backend.api.node.sync_connect") def test_materialize_uses_materialize_view_when_v2_enabled(self, mock_sync_connect, mock_feature_flag): mock_client = AsyncMock() @@ -249,7 +249,7 @@ def test_materialize_uses_materialize_view_when_v2_enabled(self, mock_sync_conne call_args = mock_client.start_workflow.call_args self.assertEqual(call_args[0][0], "data-modeling-materialize-view") - @patch("products.data_modeling.backend.api.node.posthoganalytics.feature_enabled", return_value=False) + @patch("products.data_modeling.backend.api.node.feature_enabled_or_false", return_value=False) @patch("products.data_modeling.backend.api.node.sync_connect") def test_materialize_uses_run_workflow_when_v2_disabled(self, mock_sync_connect, mock_feature_flag): mock_client = AsyncMock() diff --git a/products/data_warehouse/backend/api/data_modeling_job.py b/products/data_warehouse/backend/api/data_modeling_job.py index dda59dd73c53..086b1012d855 100644 --- a/products/data_warehouse/backend/api/data_modeling_job.py +++ b/products/data_warehouse/backend/api/data_modeling_job.py @@ -1,10 +1,10 @@ -import posthoganalytics from django_filters.rest_framework import DjangoFilterBackend from rest_framework import pagination, serializers, viewsets from rest_framework.decorators import action from rest_framework.response import Response from posthog.api.routing import TeamAndOrgViewSetMixin +from posthog.ph_client import feature_enabled_or_false from products.data_modeling.backend.models.data_modeling_job import DataModelingJob, DataModelingJobEngine @@ -51,7 +51,7 @@ class DataModelingJobViewSet(TeamAndOrgViewSetMixin, viewsets.ReadOnlyModelViewS def _is_duckgres_shadow_enabled(self) -> bool: try: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( DUCKGRES_SHADOW_FLAG, str(self.team.pk), groups={ diff --git a/products/data_warehouse/backend/data_load/service.py b/products/data_warehouse/backend/data_load/service.py index 6878163ec666..c37735f58de7 100644 --- a/products/data_warehouse/backend/data_load/service.py +++ b/products/data_warehouse/backend/data_load/service.py @@ -25,6 +25,7 @@ ) from temporalio.common import RetryPolicy +from posthog.ph_client import feature_enabled_or_false from posthog.temporal.common.client import async_connect, sync_connect from posthog.temporal.common.schedule import ( a_create_schedule, @@ -390,9 +391,7 @@ def is_any_external_data_schema_paused(team_id: int) -> bool: def is_cdc_enabled_for_team(team: Team) -> bool: - import posthoganalytics - - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "dwh-postgres-cdc", str(team.organization_id), groups={"organization": str(team.organization_id)}, @@ -401,9 +400,7 @@ def is_cdc_enabled_for_team(team: Team) -> bool: def is_xmin_enabled_for_team(team: Team) -> bool: - import posthoganalytics - - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "dwh-postgres-xmin", str(team.organization_id), groups={"organization": str(team.organization_id)}, diff --git a/products/experiments/backend/presentation/views.py b/products/experiments/backend/presentation/views.py index 254c4bdb1884..4f946e96a4ae 100644 --- a/products/experiments/backend/presentation/views.py +++ b/products/experiments/backend/presentation/views.py @@ -16,7 +16,6 @@ from django.db.models import BooleanField, Case, Exists, OuterRef, Prefetch, Q, QuerySet, Value, When from django.utils.text import slugify -import posthoganalytics from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view from opentelemetry import trace from rest_framework import serializers, viewsets @@ -35,6 +34,7 @@ from posthog.models.organization import OrganizationMembership from posthog.models.team.team import Team from posthog.models.user import User +from posthog.ph_client import feature_enabled_or_false from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin from posthog.temporal.common.client import sync_connect from posthog.temporal.experiments.models import ExperimentTimeseriesRecalculationWorkflowInputs @@ -154,7 +154,7 @@ def _is_prompt_experiments_feature_enabled(user: User, team: Team) -> bool: distinct_id = user.distinct_id or str(user.uuid) organization_id = str(team.organization_id) project_id = str(team.id) - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( PROMPT_EXPERIMENTS_FEATURE_FLAG, distinct_id, groups={"organization": organization_id, "project": project_id}, diff --git a/products/experiments/backend/test/test_create_from_prompt.py b/products/experiments/backend/test/test_create_from_prompt.py index 3866b9432756..b44e097d2d62 100644 --- a/products/experiments/backend/test/test_create_from_prompt.py +++ b/products/experiments/backend/test/test_create_from_prompt.py @@ -29,7 +29,7 @@ class TestExperimentsCreateFromPrompt(APILicensedTest): def setUp(self) -> None: super().setUp() self.feature_flag_patcher = patch( - "products.experiments.backend.presentation.views.posthoganalytics.feature_enabled", + "products.experiments.backend.presentation.views.feature_enabled_or_false", return_value=True, ) self.mock_feature_enabled = self.feature_flag_patcher.start() diff --git a/products/feature_flags/backend/api/feature_flag.py b/products/feature_flags/backend/api/feature_flag.py index a4bcd985351c..75ad6f19c118 100644 --- a/products/feature_flags/backend/api/feature_flag.py +++ b/products/feature_flags/backend/api/feature_flag.py @@ -16,7 +16,6 @@ from django.db.models import Count, Prefetch, Q, QuerySet, deletion import structlog -import posthoganalytics from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema_field from rest_framework import exceptions, request, serializers, status, viewsets @@ -61,6 +60,7 @@ ) from posthog.models.property import Property from posthog.permissions import TeamSecretTokenPermission, get_authenticator_scopes +from posthog.ph_client import feature_enabled_or_false from posthog.queries.base import determine_parsed_date_for_property_matching from posthog.rate_limit import BurstRateThrottle, ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle from posthog.rbac.access_control_api_mixin import AccessControlViewSetMixin @@ -187,7 +187,7 @@ def _is_enforce_feature_flag_write_scope_enabled(request, *, team_id: int | None return False try: organization_id = str(Team.objects.values_list("organization_id", flat=True).get(pk=team_id)) - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( ENFORCE_FEATURE_FLAG_WRITE_SCOPE_FLAG, user.distinct_id, groups={"organization": organization_id}, @@ -265,7 +265,7 @@ def _is_realtime_cohort_flag_targeting_enabled(request) -> bool: user = getattr(request, "user", None) if user is None or user.is_anonymous: return False - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( REALTIME_COHORT_FLAG_TARGETING_FLAG, user.distinct_id, groups={"organization": str(user.organization.id)}, @@ -585,7 +585,7 @@ def is_enabled(request) -> bool: # Check FLAG_EVALUATION_TAGS feature flag try: - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( "flag-evaluation-tags", request.user.distinct_id, groups={"organization": str(request.user.organization.id)}, @@ -1563,7 +1563,7 @@ def _is_early_exit_enabled(self) -> bool: user = getattr(request, "user", None) if user is None or user.is_anonymous: return False - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( EARLY_EXIT_FLAG, user.distinct_id, groups={"organization": str(user.organization.id)}, 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 cbf4d1c367e4..eaecb859119e 100644 --- a/products/feature_flags/backend/api/test/test_feature_flag.py +++ b/products/feature_flags/backend/api/test/test_feature_flag.py @@ -632,7 +632,7 @@ def test_string_group_variant_preserved(self): ("false", False), ] ) - @patch("products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled") + @patch("products.feature_flags.backend.api.feature_flag.feature_enabled_or_false") def test_boolean_early_exit_accepted(self, _name, value, mock_feature_enabled): mock_feature_enabled.return_value = True response = self.client.post( @@ -651,7 +651,7 @@ def test_boolean_early_exit_accepted(self, _name, value, mock_feature_enabled): flag = FeatureFlag.objects.get(key=f"early-exit-{_name}", team=self.team) self.assertEqual(flag.filters["early_exit"], value) - @patch("products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled") + @patch("products.feature_flags.backend.api.feature_flag.feature_enabled_or_false") def test_early_exit_rejected_without_feature_flag(self, mock_feature_enabled): mock_feature_enabled.return_value = False response = self.client.post( @@ -669,7 +669,7 @@ def test_early_exit_rejected_without_feature_flag(self, mock_feature_enabled): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("early_exit is not available", response.json()["detail"]) - @patch("products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled") + @patch("products.feature_flags.backend.api.feature_flag.feature_enabled_or_false") def test_early_exit_false_accepted_without_feature_flag(self, mock_feature_enabled): mock_feature_enabled.return_value = False response = self.client.post( @@ -686,7 +686,7 @@ def test_early_exit_false_accepted_without_feature_flag(self, mock_feature_enabl ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - @patch("products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled") + @patch("products.feature_flags.backend.api.feature_flag.feature_enabled_or_false") def test_early_exit_unchanged_truthy_allowed_when_flag_disabled(self, mock_feature_enabled): # A flag created while the feature was enabled keeps working if access is later revoked, # as long as the PATCH doesn't newly turn early_exit on. @@ -5154,7 +5154,7 @@ def test_creating_feature_flag_with_nested_behavioral_cohort(self): ), ] ) - @patch("products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled") + @patch("products.feature_flags.backend.api.feature_flag.feature_enabled_or_false") def test_behavioral_cohort_flag_validation( self, _name, diff --git a/products/feature_flags/backend/api/test/test_scope_enforcement.py b/products/feature_flags/backend/api/test/test_scope_enforcement.py index c1719267827c..a7128719e474 100644 --- a/products/feature_flags/backend/api/test/test_scope_enforcement.py +++ b/products/feature_flags/backend/api/test/test_scope_enforcement.py @@ -83,7 +83,7 @@ def test_gate_evaluates_against_target_team_org_not_actor_org(self): request = SimpleNamespace(user=self.user) with patch( - "products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled", + "products.feature_flags.backend.api.feature_flag.feature_enabled_or_false", return_value=True, ) as mock_feature_enabled: result = _is_enforce_feature_flag_write_scope_enabled(request, team_id=other_team.id) @@ -112,7 +112,7 @@ def test_gate_fails_closed_and_logs_on_error(self): def test_gate_returns_feature_enabled_result(self): request = SimpleNamespace(user=self.user) with patch( - "products.feature_flags.backend.api.feature_flag.posthoganalytics.feature_enabled", + "products.feature_flags.backend.api.feature_flag.feature_enabled_or_false", return_value=False, ): assert _is_enforce_feature_flag_write_scope_enabled(request, team_id=self.team.id) is False diff --git a/products/marketing_analytics/backend/hogql_queries/marketing_analytics_config.py b/products/marketing_analytics/backend/hogql_queries/marketing_analytics_config.py index 2774894fba4e..fe4553e147fa 100644 --- a/products/marketing_analytics/backend/hogql_queries/marketing_analytics_config.py +++ b/products/marketing_analytics/backend/hogql_queries/marketing_analytics_config.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING import structlog -import posthoganalytics from posthog.schema import ( AttributionMode, @@ -12,6 +11,8 @@ MarketingAnalyticsDrillDownLevel, ) +from posthog.ph_client import feature_enabled_or_false + if TYPE_CHECKING: from posthog.models.team import Team @@ -116,7 +117,7 @@ def from_team(cls, team: "Team") -> "MarketingAnalyticsConfig": config.attribution_mode = AttributionMode(ma_config.attribution_mode) # Gate precomputation behind feature flag - config.conversion_goal_precomputation_enabled = posthoganalytics.feature_enabled( + config.conversion_goal_precomputation_enabled = feature_enabled_or_false( "marketing-analytics-precomputation", str(team.uuid), groups={"organization": str(team.organization.id)}, @@ -125,7 +126,7 @@ def from_team(cls, team: "Team") -> "MarketingAnalyticsConfig": # Gate cost precomputation (reads native CH instead of the S3 cost tables) behind its own flag, # so it rolls out independently of the conversion/touchpoint precompute. - config.costs_precomputation_enabled = posthoganalytics.feature_enabled( + config.costs_precomputation_enabled = feature_enabled_or_false( "marketing-analytics-costs-precomputation", str(team.uuid), groups={"organization": str(team.organization.id)}, @@ -134,7 +135,7 @@ def from_team(cls, team: "Team") -> "MarketingAnalyticsConfig": # Gate multi-touch attribution behind feature flag if config.attribution_mode in MULTI_TOUCH_MODES: - has_multi_touch = posthoganalytics.feature_enabled( + has_multi_touch = feature_enabled_or_false( "marketing-analytics-multi-touch-attribution", str(team.uuid), groups={"organization": str(team.organization.id)}, diff --git a/products/marketing_analytics/backend/hogql_queries/test_marketing_analytics_config.py b/products/marketing_analytics/backend/hogql_queries/test_marketing_analytics_config.py index 450b63fec22a..67b8dc0e2c1b 100644 --- a/products/marketing_analytics/backend/hogql_queries/test_marketing_analytics_config.py +++ b/products/marketing_analytics/backend/hogql_queries/test_marketing_analytics_config.py @@ -38,7 +38,7 @@ def test_from_team_defaults_to_last_touch(self): assert not config.is_multi_touch @patch( - "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.posthoganalytics.feature_enabled", + "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.feature_enabled_or_false", return_value=True, ) def test_from_team_multi_touch_flag_enabled_keeps_mode(self, mock_ff): @@ -50,7 +50,7 @@ def test_from_team_multi_touch_flag_enabled_keeps_mode(self, mock_ff): assert len(self._multi_touch_flag_calls(mock_ff)) == 1 @patch( - "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.posthoganalytics.feature_enabled", + "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.feature_enabled_or_false", return_value=False, ) def test_from_team_multi_touch_flag_disabled_falls_back_to_last_touch(self, mock_ff): @@ -61,7 +61,7 @@ def test_from_team_multi_touch_flag_disabled_falls_back_to_last_touch(self, mock assert not config.is_multi_touch @patch( - "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.posthoganalytics.feature_enabled", + "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.feature_enabled_or_false", return_value=None, ) def test_from_team_multi_touch_flag_returns_none_falls_back(self, mock_ff): @@ -77,7 +77,7 @@ def test_from_team_single_touch_modes_skip_flag_check(self): self._set_attribution_mode(AttributionMode.FIRST_TOUCH) with patch( - "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.posthoganalytics.feature_enabled", + "products.marketing_analytics.backend.hogql_queries.marketing_analytics_config.feature_enabled_or_false", ) as mock_ff: config = MarketingAnalyticsConfig.from_team(self.team) diff --git a/products/product_analytics/backend/api/insight.py b/products/product_analytics/backend/api/insight.py index d4839b5fa7af..a3c24751b418 100644 --- a/products/product_analytics/backend/api/insight.py +++ b/products/product_analytics/backend/api/insight.py @@ -13,7 +13,6 @@ from django.utils.timezone import now import structlog -import posthoganalytics from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema_view @@ -99,6 +98,7 @@ from posthog.models.organization import Organization from posthog.models.team.team import Team from posthog.models.utils import UUIDT +from posthog.ph_client import feature_enabled_or_false from posthog.rate_limit import ( AIObservabilitySummarizationBurstThrottle, AIObservabilitySummarizationDailyThrottle, @@ -209,7 +209,7 @@ def is_legacy_insight_endpoint_blocked(user: Any, team: Team) -> bool: if not distinct_id: return False - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( LEGACY_INSIGHT_ENDPOINTS_BLOCKED_FLAG, str(distinct_id), groups={ @@ -229,7 +229,7 @@ def is_legacy_insight_filters_blocked(user: Any, team: Team) -> bool: if not distinct_id: return False - return posthoganalytics.feature_enabled( + return feature_enabled_or_false( LEGACY_INSIGHT_FILTERS_BLOCKED_FLAG, str(distinct_id), groups={ diff --git a/products/product_analytics/backend/api/test/test_insight.py b/products/product_analytics/backend/api/test/test_insight.py index aef86fcf52bd..a3cd74208f07 100644 --- a/products/product_analytics/backend/api/test/test_insight.py +++ b/products/product_analytics/backend/api/test/test_insight.py @@ -81,7 +81,7 @@ def setUp(self) -> None: ) def test_legacy_insight_endpoints_blocked_with_feature_flag(self, _name: str, path: str) -> None: with patch( - "products.product_analytics.backend.api.insight.posthoganalytics.feature_enabled", return_value=True + "products.product_analytics.backend.api.insight.feature_enabled_or_false", return_value=True ) as mock_feature_enabled: response = self.client.get(path.format(team_id=self.team.id)) @@ -94,7 +94,7 @@ def test_legacy_insight_endpoints_blocked_with_feature_flag(self, _name: str, pa def test_creating_legacy_filter_insight_blocked_with_feature_flag(self) -> None: with patch( - "products.product_analytics.backend.api.insight.posthoganalytics.feature_enabled", return_value=True + "products.product_analytics.backend.api.insight.feature_enabled_or_false", return_value=True ) as mock_feature_enabled: response = self.client.post( f"/api/projects/{self.team.id}/insights/", @@ -113,7 +113,7 @@ def test_creating_legacy_filter_insight_blocked_with_feature_flag(self) -> None: def test_creating_query_insight_not_blocked_by_legacy_filter_flag(self) -> None: with patch( - "products.product_analytics.backend.api.insight.posthoganalytics.feature_enabled", return_value=True + "products.product_analytics.backend.api.insight.feature_enabled_or_false", return_value=True ) as mock_feature_enabled: response = self.client.post( f"/api/projects/{self.team.id}/insights/", diff --git a/products/tracing/backend/logic.py b/products/tracing/backend/logic.py index 2cd80a517f83..676c9d9e5841 100644 --- a/products/tracing/backend/logic.py +++ b/products/tracing/backend/logic.py @@ -1071,8 +1071,7 @@ def run_aggregation_query( service_names: list[str] | None = None, ) -> TraceSpansAggregationQueryResponse | CachedTraceSpansAggregationQueryResponse: """Facade-friendly entry point for running a flat span aggregation query.""" - # noqa-justified: the runners import `translate_span_filter` from this module, so a - # module-level import here is circular and only resolves when this module loads first. + # The runners import `translate_span_filter` from this module, so a module-level import here is circular. from .aggregation_query_runner import TraceSpansAggregationQueryRunner # noqa: PLC0415 query = TraceSpansAggregationQuery( @@ -1098,7 +1097,7 @@ def run_tree_query( service_names: list[str] | None = None, ) -> TraceSpansTreeQueryResponse | CachedTraceSpansTreeQueryResponse: """Facade-friendly entry point for running a span call-tree aggregation query.""" - # noqa-justified: same circular import as run_aggregation_query above. + # Same circular import as run_aggregation_query above. from .aggregation_query_runner import TraceSpansTreeQueryRunner # noqa: PLC0415 query = TraceSpansTreeQuery( diff --git a/pyproject.toml b/pyproject.toml index 492b53ddc6fc..993c42ad9628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ dependencies = [ "paramiko~=3.5.0", "pillow==12.2.0", "protobuf~=5.29.6", - "posthoganalytics==7.13.0", + "posthoganalytics==7.20.4", "polars==1.37.1", "psycopg2-binary==2.9.10", "psycopg[binary]==3.2.4", diff --git a/services/llm-gateway/src/llm_gateway/callbacks/__init__.py b/services/llm-gateway/src/llm_gateway/callbacks/__init__.py index 4a23b2f06f81..679415e2db7f 100644 --- a/services/llm-gateway/src/llm_gateway/callbacks/__init__.py +++ b/services/llm-gateway/src/llm_gateway/callbacks/__init__.py @@ -26,4 +26,4 @@ def init_callbacks() -> None: callbacks.append(RateLimitCallback()) callbacks.append(PrometheusCallback()) - litellm.callbacks = callbacks # ty: ignore[invalid-assignment] + litellm.callbacks = callbacks diff --git a/uv.lock b/uv.lock index 857dd7adf780..47d0f7aaae09 100644 --- a/uv.lock +++ b/uv.lock @@ -5913,7 +5913,7 @@ requires-dist = [ { name = "pixelhog", specifier = "~=1.2.0" }, { name = "playwright", specifier = "~=1.60.0" }, { name = "polars", specifier = "==1.37.1" }, - { name = "posthoganalytics", specifier = "==7.13.0" }, + { name = "posthoganalytics", specifier = "==7.20.4" }, { name = "protobuf", specifier = "~=5.29.6" }, { name = "psycopg", extras = ["binary"], specifier = "==3.2.4" }, { name = "psycopg2-binary", specifier = "==2.9.10" }, @@ -6052,18 +6052,17 @@ sentiment = [ [[package]] name = "posthoganalytics" -version = "7.13.0" +version = "7.20.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, { name = "distro" }, - { name = "python-dateutil" }, { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/27/34aa6241cdc0145d6a7ceedfd590425d9f35ca17983f6f812f83ffce5e08/posthoganalytics-7.13.0.tar.gz", hash = "sha256:a1dc24da926f016df3c50cd6846eb899326bf83eedaa2486d1c6956a41250f16", size = 193851, upload-time = "2026-04-21T09:12:15.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/ee/a2e18df3178f3cd05512bb65b87b3037b145c2889477820b1a5b243b1bce/posthoganalytics-7.20.4.tar.gz", hash = "sha256:89f364d1796ca4e414977e67f6dae94136fb41385f68f37d4c3c85bd4278fb74", size = 262180, upload-time = "2026-06-24T15:48:16.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/28/703fc06f56231f81c522cb895ebdcd9d14bfbe38f1b8d4f07a358b6696ab/posthoganalytics-7.13.0-py3-none-any.whl", hash = "sha256:87d8f04f9255645efd058caf027b7f6a68642186d91de5f174f369c8e1819f1d", size = 229183, upload-time = "2026-04-21T09:12:13.981Z" }, + { url = "https://files.pythonhosted.org/packages/d1/de/81f6ffaae7ab5e95121b960bb98a2958b5ab7c36c10e1933da49d791b239/posthoganalytics-7.20.4-py3-none-any.whl", hash = "sha256:fcbfc2f9db527abeea8ba06caede720ee5fa5115f89b48827527314418c6a33c", size = 305163, upload-time = "2026-06-24T15:48:14.397Z" }, ] [[package]]