diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index c4e672c560c86..01bb0c7c22626 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -134,7 +134,7 @@ jobs: run: | echo "changed=$((git diff --name-only HEAD^ HEAD | grep -qE '^posthog/temporal/common|^posthog/temporal/batch_exports|^posthog/batch_exports/|^posthog/management/commands/start_temporal_worker.py$|^requirements.txt$' && echo true) || echo false)" >> $GITHUB_OUTPUT - - name: Trigger Batch Exports Temporal Worker Cloud deployment + - name: Trigger Batch Exports Sync Temporal Worker Cloud deployment if: steps.check_changes_batch_exports_temporal_worker.outputs.changed == 'true' uses: peter-evans/repository-dispatch@v3 with: @@ -155,6 +155,27 @@ jobs: "timestamp": "${{ github.event.head_commit.timestamp }}" } + - name: Trigger Batch Exports Temporal Worker Cloud deployment + if: steps.check_changes_batch_exports_temporal_worker.outputs.changed == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ steps.deployer.outputs.token }} + repository: PostHog/charts + event-type: commit_state_update + client-payload: | + { + "values": { + "image": { + "sha": "${{ steps.build.outputs.digest }}" + } + }, + "release": "temporal-worker-batch-exports", + "commit": ${{ toJson(github.event.head_commit) }}, + "repository": ${{ toJson(github.repository) }}, + "labels": ${{ steps.labels.outputs.labels }}, + "timestamp": "${{ github.event.head_commit.timestamp }}" + } + - name: Check for changes that affect general purpose temporal worker id: check_changes_general_purpose_temporal_worker run: | diff --git a/.gitignore b/.gitignore index 6f0c1be90cbae..a41dd0980a217 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,10 @@ plugin-transpiler/dist *.log # pyright config (keep this until we have a standardized one) pyrightconfig.json -.temporal-worker-settings \ No newline at end of file +# Assistant Evaluation with Deepeval +.deepeval +.deepeval-cache.json +.deepeval_telemtry.txt +.temporal-worker-settings +temp_test_run_data.json +.temp-deepeval-cache.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 98d4f1cdae0cb..93a654422dbe0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -97,7 +97,7 @@ "console": "integratedTerminal", "cwd": "${workspaceFolder}", "env": { - "SKIP_ASYNC_MIGRATIONS_SETUP": "0", + "SKIP_ASYNC_MIGRATIONS_SETUP": "1", "DEBUG": "1", "BILLING_SERVICE_URL": "https://billing.dev.posthog.dev", "SKIP_SERVICE_VERSION_REQUIREMENTS": "1" diff --git a/bin/hoge b/bin/hoge index 897de9b985623..26e1043b6f95e 100755 --- a/bin/hoge +++ b/bin/hoge @@ -3,10 +3,13 @@ set -e if [[ "$@" == *".hog"* ]]; then exec python3 -m posthog.hogql.cli --compile "$@" +elif [[ "$@" == *".js"* ]]; then + exec python3 -m posthog.hogql.cli --compile "$@" else echo "$0 - the Hog compilër! 🦔+🕶️= Hoge" echo "" echo "Usage: bin/hoge [output.hoge] compile .hog into .hoge" + echo " bin/hoge compile .hog into .js" echo " bin/hog run .hog source code" echo " bin/hog run compiled .hoge bytecode" exit 1 diff --git a/cypress/e2e/experiments.cy.ts b/cypress/e2e/experiments.cy.ts index 9e661be34591e..5a7d92c3f49c1 100644 --- a/cypress/e2e/experiments.cy.ts +++ b/cypress/e2e/experiments.cy.ts @@ -88,9 +88,7 @@ describe('Experiments', () => { // Wait for the goal modal to open and click the confirmation button cy.get('.LemonModal__layout').should('be.visible') cy.contains('Change experiment goal').should('be.visible') - cy.get('.LemonModal__footer').contains('button', 'Save').should('have.attr', 'aria-disabled', 'true') cy.get('.LemonModal__content').contains('button', 'Add funnel step').click() - cy.get('.LemonModal__footer').contains('button', 'Save').should('not.have.attr', 'aria-disabled', 'true') cy.get('.LemonModal__footer').contains('button', 'Save').click() } diff --git a/cypress/e2e/featureFlags.cy.ts b/cypress/e2e/featureFlags.cy.ts index abb1c40e52229..2dceb97af6b21 100644 --- a/cypress/e2e/featureFlags.cy.ts +++ b/cypress/e2e/featureFlags.cy.ts @@ -17,10 +17,8 @@ describe('Feature Flags', () => { }) it('Display product introduction when no feature flags exist', () => { - // ensure unique names to avoid clashes cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags') - cy.get('[data-attr=new-feature-flag]').click() - cy.contains('Create your first feature flag').should('exist') + cy.contains('Welcome to Feature flags!').should('exist') }) it('Create feature flag', () => { @@ -104,6 +102,7 @@ describe('Feature Flags', () => { cy.get('[data-attr=feature-flag-key]').focus().type(name).should('have.value', name) cy.get('[data-attr=rollout-percentage]').type('{selectall}50').should('have.value', '50') cy.get('[data-attr=save-feature-flag]').first().click() + cy.get('[data-attr=toast-close-button]').click() // after save there should be a delete button cy.get('[data-attr="more-button"]').click() @@ -308,7 +307,8 @@ describe('Feature Flags', () => { cy.get('.operator-value-option').contains('> after').should('not.exist') }) - it('Allow setting multivariant rollout percentage to zero', () => { + it('Allows setting multivariant rollout percentage to zero', () => { + cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags') // Start creating a multivariant flag cy.get('[data-attr=new-feature-flag]').click() cy.get('[data-attr=feature-flag-served-value-segmented-button]') @@ -328,6 +328,18 @@ describe('Feature Flags', () => { cy.get('[data-attr=feature-flag-variant-rollout-percentage-input]').click().type(`4.5`).should('have.value', 4) }) + it('Sets URL properly when switching between tabs', () => { + cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags') + cy.get('[data-attr=feature-flags-tab-navigation]').contains('History').click() + cy.url().should('include', `tab=history`) + + cy.get('[data-attr=feature-flags-tab-navigation]').contains('Overview').click() + cy.url().should('include', `tab=overview`) + + cy.get('[data-attr=feature-flags-tab-navigation]').contains('History').click() + cy.url().should('include', `tab=history`) + }) + it('Renders flags in FlagSelector', () => { // Create flag name cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags') diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts index 28082edb3de47..1cccfb545fc57 100644 --- a/cypress/e2e/surveys.cy.ts +++ b/cypress/e2e/surveys.cy.ts @@ -269,6 +269,7 @@ describe('Surveys', () => { // Set responses limit cy.get('.LemonCollapsePanel').contains('Completion conditions').click() + cy.get('[data-attr=survey-collection-until-limit]').first().click() cy.get('[data-attr=survey-responses-limit-input]').focus().type('228').click() // Save the survey @@ -276,7 +277,7 @@ describe('Surveys', () => { cy.get('button[data-attr="launch-survey"]').should('have.text', 'Launch') cy.reload() - cy.contains('The survey will be stopped once 228 responses are received.').should('be.visible') + cy.contains('The survey will be stopped once 100228 responses are received.').should('be.visible') }) it('creates a new survey with branching logic', () => { diff --git a/ee/clickhouse/models/test/test_action.py b/ee/clickhouse/models/test/test_action.py index d4b3a32311a93..b9aaf44a4c65b 100644 --- a/ee/clickhouse/models/test/test_action.py +++ b/ee/clickhouse/models/test/test_action.py @@ -1,7 +1,7 @@ import dataclasses from posthog.client import sync_execute -from posthog.hogql.bytecode import create_bytecode +from posthog.hogql.compiler.bytecode import create_bytecode from posthog.hogql.hogql import HogQLContext from posthog.hogql.property import action_to_expr from posthog.models.action import Action diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py index 644445067c4d2..d3a9f2afea364 100644 --- a/ee/clickhouse/views/experiments.py +++ b/ee/clickhouse/views/experiments.py @@ -31,7 +31,6 @@ from posthog.constants import INSIGHT_TRENDS from posthog.models.experiment import Experiment, ExperimentHoldout, ExperimentSavedMetric from posthog.models.filters.filter import Filter -from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery from posthog.utils import generate_cache_key, get_safe_cache EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 60 # 1 hour @@ -194,6 +193,7 @@ class Meta: "updated_at", "type", "metrics", + "metrics_secondary", ] read_only_fields = [ "id", @@ -235,36 +235,7 @@ def validate_saved_metrics_ids(self, value): return value def validate_metrics(self, value): - # TODO: This isn't correct most probably, we wouldn't have experiment_id inside ExperimentTrendsQuery - # on creation. Not sure how this is supposed to work yet. - if not value: - return value - - if not isinstance(value, list): - raise ValidationError("Metrics must be a list") - - if len(value) > 10: - raise ValidationError("Experiments can have a maximum of 10 metrics") - - for metric in value: - if not isinstance(metric, dict): - raise ValidationError("Metrics must be objects") - if not metric.get("query"): - raise ValidationError("Metric query is required") - - if metric.get("type") not in ["primary", "secondary"]: - raise ValidationError("Metric type must be 'primary' or 'secondary'") - - metric_query = metric["query"] - - if metric_query.get("kind") not in ["ExperimentTrendsQuery", "ExperimentFunnelsQuery"]: - raise ValidationError("Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'") - - # pydantic models are used to validate the query - if metric_query["kind"] == "ExperimentTrendsQuery": - ExperimentTrendsQuery(**metric_query) - else: - ExperimentFunnelsQuery(**metric_query) + # TODO 2024-11-15: commented code will be addressed when persistent metrics are implemented. return value @@ -285,8 +256,8 @@ def validate_parameters(self, value): def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: is_draft = "start_date" not in validated_data or validated_data["start_date"] is None - if not validated_data.get("filters") and not is_draft: - raise ValidationError("Filters are required when creating a launched experiment") + # if not validated_data.get("filters") and not is_draft: + # raise ValidationError("Filters are required when creating a launched experiment") saved_metrics_data = validated_data.pop("saved_metrics_ids", []) @@ -301,11 +272,6 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: feature_flag_key = validated_data.pop("get_feature_flag_key") - properties = validated_data["filters"].get("properties", []) - - if properties: - raise ValidationError("Experiments do not support global filter properties") - holdout_groups = None if validated_data.get("holdout"): holdout_groups = validated_data["holdout"].filters @@ -315,8 +281,8 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: {"key": "test", "name": "Test Variant", "rollout_percentage": 50}, ] - filters = { - "groups": [{"properties": properties, "rollout_percentage": 100}], + feature_flag_filters = { + "groups": [{"properties": [], "rollout_percentage": 100}], "multivariate": {"variants": variants or default_variants}, "aggregation_group_type_index": aggregation_group_type_index, "holdout_groups": holdout_groups, @@ -326,7 +292,7 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: data={ "key": feature_flag_key, "name": f'Feature Flag for Experiment {validated_data["name"]}', - "filters": filters, + "filters": feature_flag_filters, "active": not is_draft, "creation_context": "experiments", }, @@ -370,13 +336,13 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: return experiment def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: - if ( - not instance.filters.get("events") - and not instance.filters.get("actions") - and validated_data.get("start_date") - and not validated_data.get("filters") - ): - raise ValidationError("Filters are required when launching an experiment") + # if ( + # not instance.filters.get("events") + # and not instance.filters.get("actions") + # and validated_data.get("start_date") + # and not validated_data.get("filters") + # ): + # raise ValidationError("Filters are required when launching an experiment") update_saved_metrics = "saved_metrics_ids" in validated_data saved_metrics_data = validated_data.pop("saved_metrics_ids", []) or [] @@ -409,6 +375,8 @@ def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwarg "archived", "secondary_metrics", "holdout", + "metrics", + "metrics_secondary", } given_keys = set(validated_data.keys()) extra_keys = given_keys - expected_keys diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py index 1b22bbaa13c04..751b65ed082b9 100644 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py @@ -1235,42 +1235,6 @@ def test_soft_deleting_feature_flag_does_not_delete_experiment(self): self.assertIsNotNone(Experiment.objects.get(pk=id)) - def test_cant_add_global_properties_to_new_experiment(self): - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [ - { - "key": "industry", - "type": "group", - "value": ["technology"], - "operator": "exact", - "group_type_index": 1, - } - ], - "aggregation_group_type_index": 1, - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.json()["detail"], - "Experiments do not support global filter properties", - ) - def test_creating_updating_experiment_with_group_aggregation(self): ff_key = "a-b-tests" response = self.client.post( @@ -1789,79 +1753,6 @@ def test_create_draft_experiment_without_filters(self) -> None: self.assertEqual(response.json()["name"], "Test Experiment") self.assertEqual(response.json()["feature_flag_key"], ff_key) - def test_create_launched_experiment_without_filters(self) -> None: - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": {}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Filters are required when creating a launched experiment") - - def test_launch_draft_experiment_without_filters(self) -> None: - ff_key = "a-b-tests" - response = self.client.post( - f"/api/projects/{self.team.id}/experiments/", - { - "name": "Test Experiment", - "description": "", - "start_date": None, - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": {}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - draft_exp = response.json() - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{draft_exp['id']}", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": {}, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Filters are required when launching an experiment") - - response = self.client.patch( - f"/api/projects/{self.team.id}/experiments/{draft_exp['id']}", - { - "name": "Test Experiment", - "description": "", - "start_date": "2021-12-01T10:23", - "end_date": None, - "feature_flag_key": ff_key, - "parameters": None, - "filters": { - "events": [ - {"order": 0, "id": "$pageview"}, - {"order": 1, "id": "$pageleave"}, - ], - "properties": [], - }, - }, - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - class TestExperimentAuxiliaryEndpoints(ClickhouseTestMixin, APILicensedTest): def _generate_experiment(self, start_date="2024-01-01T10:23", extra_parameters=None): diff --git a/ee/hogai/assistant.py b/ee/hogai/assistant.py index 37a171bf03686..57e6a6f13cb86 100644 --- a/ee/hogai/assistant.py +++ b/ee/hogai/assistant.py @@ -1,10 +1,11 @@ -from collections.abc import Generator -from typing import Any, Literal, TypedDict, TypeGuard, Union +from collections.abc import Generator, Hashable, Iterator +from typing import Any, Literal, Optional, TypedDict, TypeGuard, Union, cast from langchain_core.messages import AIMessageChunk from langfuse.callback import CallbackHandler -from langgraph.graph.state import StateGraph +from langgraph.graph.state import CompiledStateGraph, StateGraph from pydantic import BaseModel +from sentry_sdk import capture_exception from ee import settings from ee.hogai.funnels.nodes import ( @@ -15,6 +16,7 @@ ) from ee.hogai.router.nodes import RouterNode from ee.hogai.schema_generator.nodes import SchemaGeneratorNode +from ee.hogai.summarizer.nodes import SummarizerNode from ee.hogai.trends.nodes import ( TrendsGeneratorNode, TrendsGeneratorToolsNode, @@ -26,6 +28,8 @@ from posthog.schema import ( AssistantGenerationStatusEvent, AssistantGenerationStatusType, + AssistantMessage, + FailureMessage, VisualizationMessage, ) @@ -70,25 +74,49 @@ def is_state_update(update: list[Any]) -> TypeGuard[tuple[Literal["updates"], As } -class Assistant: +class AssistantGraph: _team: Team _graph: StateGraph def __init__(self, team: Team): self._team = team self._graph = StateGraph(AssistantState) - - def _compile_graph(self): + self._has_start_node = False + + def add_edge(self, from_node: AssistantNodeName, to_node: AssistantNodeName): + if from_node == AssistantNodeName.START: + self._has_start_node = True + self._graph.add_edge(from_node, to_node) + return self + + def compile(self): + if not self._has_start_node: + raise ValueError("Start node not added to the graph") + return self._graph.compile() + + def add_start(self): + return self.add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) + + def add_router( + self, + path_map: Optional[dict[Hashable, AssistantNodeName]] = None, + ): builder = self._graph - + path_map = path_map or { + "trends": AssistantNodeName.TRENDS_PLANNER, + "funnel": AssistantNodeName.FUNNEL_PLANNER, + } router_node = RouterNode(self._team) builder.add_node(AssistantNodeName.ROUTER, router_node.run) - builder.add_edge(AssistantNodeName.START, AssistantNodeName.ROUTER) builder.add_conditional_edges( AssistantNodeName.ROUTER, router_node.router, - path_map={"trends": AssistantNodeName.TRENDS_PLANNER, "funnel": AssistantNodeName.FUNNEL_PLANNER}, + path_map=cast(dict[Hashable, str], path_map), ) + return self + + def add_trends_planner(self, next_node: AssistantNodeName = AssistantNodeName.TRENDS_GENERATOR): + builder = self._graph create_trends_plan_node = TrendsPlannerNode(self._team) builder.add_node(AssistantNodeName.TRENDS_PLANNER, create_trends_plan_node.run) @@ -107,26 +135,36 @@ def _compile_graph(self): create_trends_plan_tools_node.router, path_map={ "continue": AssistantNodeName.TRENDS_PLANNER, - "plan_found": AssistantNodeName.TRENDS_GENERATOR, + "plan_found": next_node, }, ) - generate_trends_node = TrendsGeneratorNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_GENERATOR, generate_trends_node.run) + return self + + def add_trends_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): + builder = self._graph + + trends_generator = TrendsGeneratorNode(self._team) + builder.add_node(AssistantNodeName.TRENDS_GENERATOR, trends_generator.run) - generate_trends_tools_node = TrendsGeneratorToolsNode(self._team) - builder.add_node(AssistantNodeName.TRENDS_GENERATOR_TOOLS, generate_trends_tools_node.run) + trends_generator_tools = TrendsGeneratorToolsNode(self._team) + builder.add_node(AssistantNodeName.TRENDS_GENERATOR_TOOLS, trends_generator_tools.run) builder.add_edge(AssistantNodeName.TRENDS_GENERATOR_TOOLS, AssistantNodeName.TRENDS_GENERATOR) builder.add_conditional_edges( AssistantNodeName.TRENDS_GENERATOR, - generate_trends_node.router, + trends_generator.router, path_map={ "tools": AssistantNodeName.TRENDS_GENERATOR_TOOLS, - "next": AssistantNodeName.END, + "next": next_node, }, ) + return self + + def add_funnel_planner(self, next_node: AssistantNodeName = AssistantNodeName.FUNNEL_GENERATOR): + builder = self._graph + funnel_planner = FunnelPlannerNode(self._team) builder.add_node(AssistantNodeName.FUNNEL_PLANNER, funnel_planner.run) builder.add_conditional_edges( @@ -144,37 +182,69 @@ def _compile_graph(self): funnel_planner_tools.router, path_map={ "continue": AssistantNodeName.FUNNEL_PLANNER, - "plan_found": AssistantNodeName.FUNNEL_GENERATOR, + "plan_found": next_node, }, ) + return self + + def add_funnel_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): + builder = self._graph + funnel_generator = FunnelGeneratorNode(self._team) builder.add_node(AssistantNodeName.FUNNEL_GENERATOR, funnel_generator.run) - funnel_generator_tools_node = FunnelGeneratorToolsNode(self._team) - builder.add_node(AssistantNodeName.FUNNEL_GENERATOR_TOOLS, funnel_generator_tools_node.run) + funnel_generator_tools = FunnelGeneratorToolsNode(self._team) + builder.add_node(AssistantNodeName.FUNNEL_GENERATOR_TOOLS, funnel_generator_tools.run) builder.add_edge(AssistantNodeName.FUNNEL_GENERATOR_TOOLS, AssistantNodeName.FUNNEL_GENERATOR) builder.add_conditional_edges( AssistantNodeName.FUNNEL_GENERATOR, - generate_trends_node.router, + funnel_generator.router, path_map={ "tools": AssistantNodeName.FUNNEL_GENERATOR_TOOLS, - "next": AssistantNodeName.END, + "next": next_node, }, ) - return builder.compile() + return self + + def add_summarizer(self, next_node: AssistantNodeName = AssistantNodeName.END): + builder = self._graph + summarizer_node = SummarizerNode(self._team) + builder.add_node(AssistantNodeName.SUMMARIZER, summarizer_node.run) + builder.add_edge(AssistantNodeName.SUMMARIZER, next_node) + return self + + def compile_full_graph(self): + return ( + self.add_start() + .add_router() + .add_trends_planner() + .add_trends_generator() + .add_funnel_planner() + .add_funnel_generator() + .add_summarizer() + .compile() + ) + + +class Assistant: + _team: Team + _graph: CompiledStateGraph + + def __init__(self, team: Team): + self._team = team + self._graph = AssistantGraph(team).compile_full_graph() def stream(self, conversation: Conversation) -> Generator[BaseModel, None, None]: - assistant_graph = self._compile_graph() callbacks = [langfuse_handler] if langfuse_handler else [] messages = [message.root for message in conversation.messages] chunks = AIMessageChunk(content="") state: AssistantState = {"messages": messages, "intermediate_steps": None, "plan": None} - generator = assistant_graph.stream( + generator: Iterator[Any] = self._graph.stream( state, config={"recursion_limit": 24, "callbacks": callbacks}, stream_mode=["messages", "values", "updates"], @@ -185,33 +255,47 @@ def stream(self, conversation: Conversation) -> Generator[BaseModel, None, None] # Send a chunk to establish the connection avoiding the worker's timeout. yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.ACK) - for update in generator: - if is_state_update(update): - _, new_state = update - state = new_state - - elif is_value_update(update): - _, state_update = update - - if AssistantNodeName.ROUTER in state_update and "messages" in state_update[AssistantNodeName.ROUTER]: - yield state_update[AssistantNodeName.ROUTER]["messages"][0] - elif intersected_nodes := state_update.keys() & VISUALIZATION_NODES.keys(): - # Reset chunks when schema validation fails. - chunks = AIMessageChunk(content="") - - node_name = intersected_nodes.pop() - if "messages" in state_update[node_name]: - yield state_update[node_name]["messages"][0] - elif state_update[node_name].get("intermediate_steps", []): - yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.GENERATION_ERROR) - - elif is_message_update(update): - langchain_message, langgraph_state = update[1] - for node_name, viz_node in VISUALIZATION_NODES.items(): - if langgraph_state["langgraph_node"] == node_name and isinstance(langchain_message, AIMessageChunk): - chunks += langchain_message # type: ignore - parsed_message = viz_node.parse_output(chunks.tool_calls[0]["args"]) - if parsed_message: - yield VisualizationMessage( - reasoning_steps=parsed_message.reasoning_steps, answer=parsed_message.answer + try: + for update in generator: + if is_state_update(update): + _, new_state = update + state = new_state + + elif is_value_update(update): + _, state_update = update + + if ( + AssistantNodeName.ROUTER in state_update + and "messages" in state_update[AssistantNodeName.ROUTER] + ): + yield state_update[AssistantNodeName.ROUTER]["messages"][0] + elif intersected_nodes := state_update.keys() & VISUALIZATION_NODES.keys(): + # Reset chunks when schema validation fails. + chunks = AIMessageChunk(content="") + + node_name = intersected_nodes.pop() + if "messages" in state_update[node_name]: + yield state_update[node_name]["messages"][0] + elif state_update[node_name].get("intermediate_steps", []): + yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.GENERATION_ERROR) + elif AssistantNodeName.SUMMARIZER in state_update: + chunks = AIMessageChunk(content="") + yield state_update[AssistantNodeName.SUMMARIZER]["messages"][0] + elif is_message_update(update): + langchain_message, langgraph_state = update[1] + if isinstance(langchain_message, AIMessageChunk): + if langgraph_state["langgraph_node"] in VISUALIZATION_NODES.keys(): + chunks += langchain_message # type: ignore + parsed_message = VISUALIZATION_NODES[langgraph_state["langgraph_node"]].parse_output( + chunks.tool_calls[0]["args"] ) + if parsed_message: + yield VisualizationMessage( + reasoning_steps=parsed_message.reasoning_steps, answer=parsed_message.answer + ) + elif langgraph_state["langgraph_node"] == AssistantNodeName.SUMMARIZER: + chunks += langchain_message # type: ignore + yield AssistantMessage(content=chunks.content) + except Exception as e: + capture_exception(e) + yield FailureMessage() # This is an unhandled error, so we just stop further generation at this point diff --git a/ee/hogai/eval/__init__.py b/ee/hogai/eval/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ee/hogai/eval/test_eval_funnel_planner.py b/ee/hogai/eval/test_eval_funnel_planner.py new file mode 100644 index 0000000000000..e7370e6915f21 --- /dev/null +++ b/ee/hogai/eval/test_eval_funnel_planner.py @@ -0,0 +1,179 @@ +from deepeval import assert_test +from deepeval.metrics import GEval +from deepeval.test_case import LLMTestCase, LLMTestCaseParams +from langgraph.graph.state import CompiledStateGraph + +from ee.hogai.assistant import AssistantGraph +from ee.hogai.eval.utils import EvalBaseTest +from ee.hogai.utils import AssistantNodeName +from posthog.schema import HumanMessage + + +class TestEvalFunnelPlanner(EvalBaseTest): + def _get_plan_correctness_metric(self): + return GEval( + name="Funnel Plan Correctness", + criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a funnel insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about funnel insights.", + evaluation_steps=[ + "A plan must define at least two series in the sequence, but it is not required to define any filters, exclusion steps, or a breakdown.", + "Compare events, properties, math types, and property values of 'expected output' and 'actual output'.", + "Check if the combination of events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", + # The criteria for aggregations must be more specific because there isn't a way to bypass them. + "Check if the math types in 'actual output' match those in 'expected output.' If the aggregation type is specified by a property, user, or group in 'expected output', the same property, user, or group must be used in 'actual output'.", + "If 'expected output' contains exclusion steps, check if 'actual output' contains those, and heavily penalize if the exclusion steps are not present or different.", + "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different. Plans may only have one breakdown.", + # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. + "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", + ], + evaluation_params=[ + LLMTestCaseParams.INPUT, + LLMTestCaseParams.EXPECTED_OUTPUT, + LLMTestCaseParams.ACTUAL_OUTPUT, + ], + threshold=0.7, + ) + + def _call_node(self, query): + graph: CompiledStateGraph = ( + AssistantGraph(self.team) + .add_edge(AssistantNodeName.START, AssistantNodeName.FUNNEL_PLANNER) + .add_funnel_planner(AssistantNodeName.END) + .compile() + ) + state = graph.invoke({"messages": [HumanMessage(content=query)]}) + return state["plan"] + + def test_basic_funnel(self): + query = "what was the conversion from a page view to sign up?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. $pageview + 2. signed_up + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_outputs_at_least_two_events(self): + """ + Ambigious query. The funnel must return at least two events. + """ + query = "how many users paid a bill?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. any event + 2. upgrade_plan + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_no_excessive_property_filters(self): + query = "Show the user conversion from a sign up to a file download" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. signed_up + 2. downloaded_file + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_basic_filtering(self): + query = ( + "What was the conversion from uploading a file to downloading it from Chrome and Safari in the last 30d?" + ) + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. uploaded_file + - property filter 1: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Chrome + - property filter 2: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Safari + 2. downloaded_file + - property filter 1: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Chrome + - property filter 2: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Safari + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_exclusion_steps(self): + query = "What was the conversion from uploading a file to downloading it in the last 30d excluding users that deleted a file?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. uploaded_file + 2. downloaded_file + + Exclusions: + - deleted_file + - start index: 0 + - end index: 1 + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_breakdown(self): + query = "Show a conversion from uploading a file to downloading it segmented by a user's email" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. uploaded_file + 2. downloaded_file + + Breakdown by: + - entity: person + - property name: email + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_needle_in_a_haystack(self): + query = "What was the conversion from a sign up to a paying customer on the personal-pro plan?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Sequence: + 1. signed_up + 2. paid_bill + - property filter 1: + - entity: event + - property name: plan + - property type: String + - operator: equals + - property value: personal/pro + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) diff --git a/ee/hogai/eval/test_eval_router.py b/ee/hogai/eval/test_eval_router.py new file mode 100644 index 0000000000000..73f916245ae7a --- /dev/null +++ b/ee/hogai/eval/test_eval_router.py @@ -0,0 +1,59 @@ +from langgraph.graph.state import CompiledStateGraph + +from ee.hogai.assistant import AssistantGraph +from ee.hogai.eval.utils import EvalBaseTest +from ee.hogai.utils import AssistantNodeName +from posthog.schema import HumanMessage, RouterMessage + + +class TestEvalRouter(EvalBaseTest): + def _call_node(self, query: str | list): + graph: CompiledStateGraph = ( + AssistantGraph(self.team) + .add_start() + .add_router(path_map={"trends": AssistantNodeName.END, "funnel": AssistantNodeName.END}) + .compile() + ) + messages = [HumanMessage(content=query)] if isinstance(query, str) else query + state = graph.invoke({"messages": messages}) + return state["messages"][-1].content + + def test_outputs_basic_trends_insight(self): + query = "Show the $pageview trend" + res = self._call_node(query) + self.assertEqual(res, "trends") + + def test_outputs_basic_funnel_insight(self): + query = "What is the conversion rate of users who uploaded a file to users who paid for a plan?" + res = self._call_node(query) + self.assertEqual(res, "funnel") + + def test_converts_trends_to_funnel(self): + conversation = [ + HumanMessage(content="Show trends of $pageview and $identify"), + RouterMessage(content="trends"), + HumanMessage(content="Convert this insight to a funnel"), + ] + res = self._call_node(conversation[:1]) + self.assertEqual(res, "trends") + res = self._call_node(conversation) + self.assertEqual(res, "funnel") + + def test_converts_funnel_to_trends(self): + conversation = [ + HumanMessage(content="What is the conversion from a page view to a sign up?"), + RouterMessage(content="funnel"), + HumanMessage(content="Convert this insight to a trends"), + ] + res = self._call_node(conversation[:1]) + self.assertEqual(res, "funnel") + res = self._call_node(conversation) + self.assertEqual(res, "trends") + + def test_outputs_single_trends_insight(self): + """ + Must display a trends insight because it's not possible to build a funnel with a single series. + """ + query = "how many users upgraded their plan to personal pro?" + res = self._call_node(query) + self.assertEqual(res, "trends") diff --git a/ee/hogai/eval/test_eval_trends_planner.py b/ee/hogai/eval/test_eval_trends_planner.py new file mode 100644 index 0000000000000..fa12df10ae9d2 --- /dev/null +++ b/ee/hogai/eval/test_eval_trends_planner.py @@ -0,0 +1,163 @@ +from deepeval import assert_test +from deepeval.metrics import GEval +from deepeval.test_case import LLMTestCase, LLMTestCaseParams +from langgraph.graph.state import CompiledStateGraph + +from ee.hogai.assistant import AssistantGraph +from ee.hogai.eval.utils import EvalBaseTest +from ee.hogai.utils import AssistantNodeName +from posthog.schema import HumanMessage + + +class TestEvalTrendsPlanner(EvalBaseTest): + def _get_plan_correctness_metric(self): + return GEval( + name="Trends Plan Correctness", + criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a trends insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about trends insights.", + evaluation_steps=[ + "A plan must define at least one event and a math type, but it is not required to define any filters, breakdowns, or formulas.", + "Compare events, properties, math types, and property values of 'expected output' and 'actual output'.", + "Check if the combination of events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", + # The criteria for aggregations must be more specific because there isn't a way to bypass them. + "Check if the math types in 'actual output' match those in 'expected output'. Math types sometimes are interchangeable, so use your judgement. If the aggregation type is specified by a property, user, or group in 'expected output', the same property, user, or group must be used in 'actual output'.", + "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different.", + "If 'expected output' contains a formula, check if 'actual output' contains a similar formula, and heavily penalize if the formula is not present or different.", + # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. + "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", + ], + evaluation_params=[ + LLMTestCaseParams.INPUT, + LLMTestCaseParams.EXPECTED_OUTPUT, + LLMTestCaseParams.ACTUAL_OUTPUT, + ], + threshold=0.7, + ) + + def _call_node(self, query): + graph: CompiledStateGraph = ( + AssistantGraph(self.team) + .add_edge(AssistantNodeName.START, AssistantNodeName.TRENDS_PLANNER) + .add_trends_planner(AssistantNodeName.END) + .compile() + ) + state = graph.invoke({"messages": [HumanMessage(content=query)]}) + return state["plan"] + + def test_no_excessive_property_filters(self): + query = "Show the $pageview trend" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - $pageview + - math operation: total count + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_no_excessive_property_filters_for_a_defined_math_type(self): + query = "What is the MAU?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - $pageview + - math operation: unique users + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_basic_filtering(self): + query = "can you compare how many Chrome vs Safari users uploaded a file in the last 30d?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - uploaded_file + - math operation: total count + - property filter 1: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Chrome + - property filter 2: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Safari + + Breakdown by: + - breakdown 1: + - entity: event + - property name: $browser + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_formula_mode(self): + query = "i want to see a ratio of identify divided by page views" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - $identify + - math operation: total count + - $pageview + - math operation: total count + + Formula: + `A/B`, where `A` is the total count of `$identify` and `B` is the total count of `$pageview` + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_math_type_by_a_property(self): + query = "what is the average session duration?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - All Events + - math operation: average by `$session_duration` + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_math_type_by_a_user(self): + query = "What is the median page view count for a user?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - $pageview + - math operation: median by users + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) + + def test_needle_in_a_haystack(self): + query = "How frequently do people pay for a personal-pro plan?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Events: + - paid_bill + - math operation: total count + - property filter 1: + - entity: event + - property name: plan + - property type: String + - operator: contains + - property value: personal/pro + """, + actual_output=self._call_node(query), + ) + assert_test(test_case, [self._get_plan_correctness_metric()]) diff --git a/ee/hogai/eval/utils.py b/ee/hogai/eval/utils.py new file mode 100644 index 0000000000000..473b47fe17a84 --- /dev/null +++ b/ee/hogai/eval/utils.py @@ -0,0 +1,28 @@ +import datetime as dt +import os + +import pytest +from flaky import flaky + +from posthog.demo.matrix.manager import MatrixManager +from posthog.tasks.demo_create_data import HedgeboxMatrix +from posthog.test.base import BaseTest + + +@pytest.mark.skipif(os.environ.get("DEEPEVAL") != "YES", reason="Only runs for the assistant evaluation") +@flaky(max_runs=3, min_passes=1) +class EvalBaseTest(BaseTest): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + matrix = HedgeboxMatrix( + seed="b1ef3c66-5f43-488a-98be-6b46d92fbcef", # this seed generates all events + now=dt.datetime.now(dt.UTC) - dt.timedelta(days=25), + days_past=60, + days_future=30, + n_clusters=60, + group_type_index_offset=0, + ) + matrix_manager = MatrixManager(matrix, print_steps=True) + existing_user = cls.team.organization.members.first() + matrix_manager.run_on_team(cls.team, existing_user) diff --git a/ee/hogai/funnels/test/test_nodes.py b/ee/hogai/funnels/test/test_nodes.py index d7b42f7a87200..59ba48ff6faf0 100644 --- a/ee/hogai/funnels/test/test_nodes.py +++ b/ee/hogai/funnels/test/test_nodes.py @@ -33,7 +33,9 @@ def test_node_runs(self): self.assertEqual( new_state, { - "messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])], + "messages": [ + VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True) + ], "intermediate_steps": None, }, ) diff --git a/ee/hogai/router/test/test_router.py b/ee/hogai/router/test/test_nodes.py similarity index 100% rename from ee/hogai/router/test/test_router.py rename to ee/hogai/router/test/test_nodes.py diff --git a/ee/hogai/schema_generator/nodes.py b/ee/hogai/schema_generator/nodes.py index f6e41c690270d..8845fb6a14a6a 100644 --- a/ee/hogai/schema_generator/nodes.py +++ b/ee/hogai/schema_generator/nodes.py @@ -23,7 +23,7 @@ QUESTION_PROMPT, ) from ee.hogai.schema_generator.utils import SchemaGeneratorOutput -from ee.hogai.utils import AssistantState, AssistantNode, filter_visualization_conversation +from ee.hogai.utils import AssistantNode, AssistantState, filter_visualization_conversation from posthog.models.group_type_mapping import GroupTypeMapping from posthog.schema import ( FailureMessage, @@ -101,6 +101,7 @@ def _run_with_prompt( plan=generated_plan, reasoning_steps=message.reasoning_steps, answer=message.answer, + done=True, ) ], "intermediate_steps": None, diff --git a/ee/hogai/schema_generator/test/test_nodes.py b/ee/hogai/schema_generator/test/test_nodes.py index 961bd88de229b..25f82e43d44ff 100644 --- a/ee/hogai/schema_generator/test/test_nodes.py +++ b/ee/hogai/schema_generator/test/test_nodes.py @@ -54,7 +54,9 @@ def test_node_runs(self): self.assertEqual( new_state, { - "messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])], + "messages": [ + VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True) + ], "intermediate_steps": None, }, ) diff --git a/ee/hogai/summarizer/__init__.py b/ee/hogai/summarizer/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ee/hogai/summarizer/nodes.py b/ee/hogai/summarizer/nodes.py new file mode 100644 index 0000000000000..8d5e8a406f45e --- /dev/null +++ b/ee/hogai/summarizer/nodes.py @@ -0,0 +1,95 @@ +import json +from time import sleep +from django.conf import settings +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableConfig +from langchain_openai import ChatOpenAI +from django.core.serializers.json import DjangoJSONEncoder +from rest_framework.exceptions import APIException +from sentry_sdk import capture_exception + +from ee.hogai.summarizer.prompts import SUMMARIZER_SYSTEM_PROMPT, SUMMARIZER_INSTRUCTION_PROMPT +from ee.hogai.utils import AssistantNode, AssistantNodeName, AssistantState +from posthog.api.services.query import process_query_dict +from posthog.clickhouse.client.execute_async import get_query_status +from posthog.errors import ExposedCHQueryError +from posthog.hogql.errors import ExposedHogQLError +from posthog.hogql_queries.query_runner import ExecutionMode +from posthog.schema import AssistantMessage, FailureMessage, HumanMessage, VisualizationMessage + + +class SummarizerNode(AssistantNode): + name = AssistantNodeName.SUMMARIZER + + def run(self, state: AssistantState, config: RunnableConfig): + viz_message = state["messages"][-1] + if not isinstance(viz_message, VisualizationMessage): + raise ValueError("Can only run summarization with a visualization message as the last one in the state") + if viz_message.answer is None: + raise ValueError("Did not found query in the visualization message") + + try: + results_response = process_query_dict( # type: ignore + self._team, # TODO: Add user + viz_message.answer.model_dump(mode="json"), # We need mode="json" so that + # Celery doesn't run in tests, so there we use force_blocking instead + # This does mean that the waiting logic is not tested + execution_mode=ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE + if not settings.TEST + else ExecutionMode.CALCULATE_BLOCKING_ALWAYS, + ).model_dump(mode="json") + if results_response.get("query_status") and not results_response["query_status"]["complete"]: + query_id = results_response["query_status"]["id"] + for i in range(0, 999): + sleep(i / 2) # We start at 0.5s and every iteration we wait 0.5s more + query_status = get_query_status(team_id=self._team.pk, query_id=query_id) + if query_status.error: + if query_status.error_message: + raise APIException(query_status.error_message) + else: + raise ValueError("Query failed") + if query_status.complete: + results_response = query_status.results + break + except (APIException, ExposedHogQLError, ExposedCHQueryError) as err: + err_message = str(err) + if isinstance(err, APIException): + if isinstance(err.detail, dict): + err_message = ", ".join(f"{key}: {value}" for key, value in err.detail.items()) + elif isinstance(err.detail, list): + err_message = ", ".join(map(str, err.detail)) + return {"messages": [FailureMessage(content=f"There was an error running this query: {err_message}")]} + except Exception as err: + capture_exception(err) + return {"messages": [FailureMessage(content="There was an unknown error running this query.")]} + + summarization_prompt = ChatPromptTemplate(self._construct_messages(state), template_format="mustache") + + chain = summarization_prompt | self._model + + message = chain.invoke( + { + "query_kind": viz_message.answer.kind, + "product_description": self._team.project.product_description, + "results": json.dumps(results_response["results"], cls=DjangoJSONEncoder), + }, + config, + ) + + return {"messages": [AssistantMessage(content=str(message.content), done=True)]} + + @property + def _model(self): + return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True) # Slightly higher temp than earlier steps + + def _construct_messages(self, state: AssistantState) -> list[tuple[str, str]]: + conversation: list[tuple[str, str]] = [("system", SUMMARIZER_SYSTEM_PROMPT)] + + for message in state.get("messages", []): + if isinstance(message, HumanMessage): + conversation.append(("human", message.content)) + elif isinstance(message, AssistantMessage): + conversation.append(("assistant", message.content)) + + conversation.append(("human", SUMMARIZER_INSTRUCTION_PROMPT)) + return conversation diff --git a/ee/hogai/summarizer/prompts.py b/ee/hogai/summarizer/prompts.py new file mode 100644 index 0000000000000..bf2272d9d4cbe --- /dev/null +++ b/ee/hogai/summarizer/prompts.py @@ -0,0 +1,17 @@ +SUMMARIZER_SYSTEM_PROMPT = """ +Act as an expert product manager. Your task is to summarize query results in a a concise way. +Offer actionable feedback if possible. Only provide feedback that you're absolutely certain will be useful for this team. + +The product being analyzed is described as follows: +{{product_description}}""" + +SUMMARIZER_INSTRUCTION_PROMPT = """ +Here are the {{query_kind}} results for this question: +```json +{{results}} +``` + +Answer my earlier question using the results above. Point out interesting trends or anomalies. +Take into account what you know about my product. If possible, offer actionable feedback, but avoid generic advice. +Limit yourself to a few sentences. The answer needs to be high-impact and relevant for me as a Silicon Valley engineer. +""" diff --git a/ee/hogai/summarizer/test/__init__.py b/ee/hogai/summarizer/test/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ee/hogai/summarizer/test/test_nodes.py b/ee/hogai/summarizer/test/test_nodes.py new file mode 100644 index 0000000000000..b0e8cdcd37f74 --- /dev/null +++ b/ee/hogai/summarizer/test/test_nodes.py @@ -0,0 +1,196 @@ +from unittest.mock import patch + +from django.test import override_settings +from langchain_core.runnables import RunnableLambda +from langchain_core.messages import ( + HumanMessage as LangchainHumanMessage, +) +from ee.hogai.summarizer.nodes import SummarizerNode +from ee.hogai.summarizer.prompts import SUMMARIZER_INSTRUCTION_PROMPT, SUMMARIZER_SYSTEM_PROMPT +from posthog.schema import ( + AssistantMessage, + AssistantTrendsEventsNode, + AssistantTrendsQuery, + FailureMessage, + HumanMessage, + VisualizationMessage, +) +from rest_framework.exceptions import ValidationError +from posthog.test.base import APIBaseTest, ClickhouseTestMixin +from posthog.api.services.query import process_query_dict + + +@override_settings(IN_UNIT_TESTING=True) +class TestSummarizerNode(ClickhouseTestMixin, APIBaseTest): + maxDiff = None + + @patch("ee.hogai.summarizer.nodes.process_query_dict", side_effect=process_query_dict) + def test_node_runs(self, mock_process_query_dict): + node = SummarizerNode(self.team) + with patch.object(SummarizerNode, "_model") as generator_model_mock: + generator_model_mock.return_value = RunnableLambda( + lambda _: LangchainHumanMessage(content="The results indicate foobar.") + ) + new_state = node.run( + { + "messages": [ + HumanMessage(content="Text"), + VisualizationMessage( + answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), + plan="Plan", + reasoning_steps=["step"], + done=True, + ), + ], + "plan": "Plan", + }, + {}, + ) + mock_process_query_dict.assert_called_once() # Query processing started + self.assertEqual( + new_state, + { + "messages": [ + AssistantMessage(content="The results indicate foobar.", done=True), + ], + }, + ) + + @patch( + "ee.hogai.summarizer.nodes.process_query_dict", + side_effect=ValueError("You have not glibbled the glorp before running this."), + ) + def test_node_handles_internal_error(self, mock_process_query_dict): + node = SummarizerNode(self.team) + with patch.object(SummarizerNode, "_model") as generator_model_mock: + generator_model_mock.return_value = RunnableLambda( + lambda _: LangchainHumanMessage(content="The results indicate foobar.") + ) + new_state = node.run( + { + "messages": [ + HumanMessage(content="Text"), + VisualizationMessage( + answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), + plan="Plan", + reasoning_steps=["step"], + done=True, + ), + ], + "plan": "Plan", + }, + {}, + ) + mock_process_query_dict.assert_called_once() # Query processing started + self.assertEqual( + new_state, + { + "messages": [ + FailureMessage(content="There was an unknown error running this query."), + ], + }, + ) + + @patch( + "ee.hogai.summarizer.nodes.process_query_dict", + side_effect=ValidationError( + "This query exceeds the capabilities of our picolator. Try de-brolling its flim-flam." + ), + ) + def test_node_handles_exposed_error(self, mock_process_query_dict): + node = SummarizerNode(self.team) + with patch.object(SummarizerNode, "_model") as generator_model_mock: + generator_model_mock.return_value = RunnableLambda( + lambda _: LangchainHumanMessage(content="The results indicate foobar.") + ) + new_state = node.run( + { + "messages": [ + HumanMessage(content="Text"), + VisualizationMessage( + answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), + plan="Plan", + reasoning_steps=["step"], + done=True, + ), + ], + "plan": "Plan", + }, + {}, + ) + mock_process_query_dict.assert_called_once() # Query processing started + self.assertEqual( + new_state, + { + "messages": [ + FailureMessage( + content=( + "There was an error running this query: This query exceeds the capabilities of our picolator. " + "Try de-brolling its flim-flam." + ) + ), + ], + }, + ) + + def test_node_requires_a_viz_message_in_state(self): + node = SummarizerNode(self.team) + + with self.assertRaisesMessage( + ValueError, "Can only run summarization with a visualization message as the last one in the state" + ): + node.run( + { + "messages": [ + HumanMessage(content="Text"), + ], + "plan": "Plan", + }, + {}, + ) + + def test_node_requires_viz_message_in_state_to_have_query(self): + node = SummarizerNode(self.team) + + with self.assertRaisesMessage(ValueError, "Did not found query in the visualization message"): + node.run( + { + "messages": [ + VisualizationMessage( + answer=None, + plan="Plan", + reasoning_steps=["step"], + done=True, + ), + ], + "plan": "Plan", + }, + {}, + ) + + def test_agent_reconstructs_conversation(self): + self.project.product_description = "Dating app for lonely hedgehogs." + self.project.save() + node = SummarizerNode(self.team) + + history = node._construct_messages( + { + "messages": [ + HumanMessage(content="What's the trends in signups?"), + VisualizationMessage( + answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]), + plan="Plan", + reasoning_steps=["step"], + done=True, + ), + ] + } + ) + self.assertEqual( + history, + [ + ("system", SUMMARIZER_SYSTEM_PROMPT), + ("human", "What's the trends in signups?"), + ("human", SUMMARIZER_INSTRUCTION_PROMPT), + ], + ) diff --git a/ee/hogai/taxonomy_agent/nodes.py b/ee/hogai/taxonomy_agent/nodes.py index 06d3b29df1a55..ef5c1953b994c 100644 --- a/ee/hogai/taxonomy_agent/nodes.py +++ b/ee/hogai/taxonomy_agent/nodes.py @@ -75,10 +75,8 @@ def _run_with_prompt_and_toolkit( AgentAction, agent.invoke( { - "react_format": REACT_FORMAT_PROMPT, + "react_format": self._get_react_format_prompt(toolkit), "react_format_reminder": REACT_FORMAT_REMINDER_PROMPT, - "tools": toolkit.render_text_description(), - "tool_names": ", ".join([t["name"] for t in toolkit.tools]), "product_description": self._team.project.product_description, "groups": self._team_group_types, "events": self._events_prompt, @@ -121,6 +119,17 @@ def router(self, state: AssistantState): def _model(self) -> ChatOpenAI: return ChatOpenAI(model="gpt-4o", temperature=0.2, streaming=True) + def _get_react_format_prompt(self, toolkit: TaxonomyAgentToolkit) -> str: + return cast( + str, + ChatPromptTemplate.from_template(REACT_FORMAT_PROMPT, template_format="mustache") + .format_messages( + tools=toolkit.render_text_description(), + tool_names=", ".join([t["name"] for t in toolkit.tools]), + )[0] + .content, + ) + @cached_property def _events_prompt(self) -> str: response = TeamTaxonomyQueryRunner(TeamTaxonomyQuery(), self._team).run( diff --git a/ee/hogai/taxonomy_agent/test/test_nodes.py b/ee/hogai/taxonomy_agent/test/test_nodes.py index 920dfacfb8581..1a5fe25d8bfe3 100644 --- a/ee/hogai/taxonomy_agent/test/test_nodes.py +++ b/ee/hogai/taxonomy_agent/test/test_nodes.py @@ -22,7 +22,7 @@ from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person -class TestToolkit(TaxonomyAgentToolkit): +class DummyToolkit(TaxonomyAgentToolkit): def _get_tools(self) -> list[ToolkitTool]: return self._default_tools @@ -36,8 +36,8 @@ def setUp(self): def _get_node(self): class Node(TaxonomyAgentPlannerNode): def run(self, state: AssistantState, config: RunnableConfig) -> AssistantState: - prompt = ChatPromptTemplate.from_messages([("user", "test")]) - toolkit = TestToolkit(self._team) + prompt: ChatPromptTemplate = ChatPromptTemplate.from_messages([("user", "test")]) + toolkit = DummyToolkit(self._team) return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) return Node(self.team) @@ -180,13 +180,21 @@ def test_node_outputs_all_events_prompt(self): node._events_prompt, ) + def test_format_prompt(self): + node = self._get_node() + self.assertNotIn("Human:", node._get_react_format_prompt(DummyToolkit(self.team))) + self.assertIn("retrieve_event_properties,", node._get_react_format_prompt(DummyToolkit(self.team))) + self.assertIn( + "retrieve_event_properties(event_name: str)", node._get_react_format_prompt(DummyToolkit(self.team)) + ) + @override_settings(IN_UNIT_TESTING=True) class TestTaxonomyAgentPlannerToolsNode(ClickhouseTestMixin, APIBaseTest): def _get_node(self): class Node(TaxonomyAgentPlannerToolsNode): def run(self, state: AssistantState, config: RunnableConfig) -> AssistantState: - toolkit = TestToolkit(self._team) + toolkit = DummyToolkit(self._team) return super()._run_with_toolkit(state, toolkit, config=config) return Node(self.team) diff --git a/ee/hogai/trends/test/test_nodes.py b/ee/hogai/trends/test/test_nodes.py index 334d321f5ae96..03c2ac85ea7cb 100644 --- a/ee/hogai/trends/test/test_nodes.py +++ b/ee/hogai/trends/test/test_nodes.py @@ -14,6 +14,8 @@ @override_settings(IN_UNIT_TESTING=True) class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest): + maxDiff = None + def setUp(self): self.schema = AssistantTrendsQuery(series=[]) @@ -33,7 +35,9 @@ def test_node_runs(self): self.assertEqual( new_state, { - "messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])], + "messages": [ + VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True) + ], "intermediate_steps": None, }, ) diff --git a/ee/hogai/utils.py b/ee/hogai/utils.py index 74e12ee2d751d..60fa74fc23007 100644 --- a/ee/hogai/utils.py +++ b/ee/hogai/utils.py @@ -50,6 +50,7 @@ class AssistantNodeName(StrEnum): FUNNEL_PLANNER_TOOLS = "funnel_planner_tools" FUNNEL_GENERATOR = "funnel_generator" FUNNEL_GENERATOR_TOOLS = "funnel_generator_tools" + SUMMARIZER = "summarizer" class AssistantNode(ABC): diff --git a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png index 202f1a5b6cbf9..a147e1eb54571 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png and b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png index acf86648a2db9..81e997f168c75 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png and b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--importing-module--dark.png b/frontend/__snapshots__/components-errors-error-display--importing-module--dark.png index 80e85acfb3a2f..ab946e6ae69b9 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--importing-module--dark.png and b/frontend/__snapshots__/components-errors-error-display--importing-module--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--importing-module--light.png b/frontend/__snapshots__/components-errors-error-display--importing-module--light.png index bcb070820baf4..6ac99e959b670 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--importing-module--light.png and b/frontend/__snapshots__/components-errors-error-display--importing-module--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--dark.png b/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--dark.png index ea3e5b099ec89..0312bb569ce05 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--dark.png and b/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--light.png b/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--light.png index 540d3972b0895..7d1583f3cd647 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--light.png and b/frontend/__snapshots__/components-errors-error-display--resize-observer-loop-limit-exceeded--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--safari-script-error--dark.png b/frontend/__snapshots__/components-errors-error-display--safari-script-error--dark.png index fa4a28066135f..c7dd6a65c1b7a 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--safari-script-error--dark.png and b/frontend/__snapshots__/components-errors-error-display--safari-script-error--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--safari-script-error--light.png b/frontend/__snapshots__/components-errors-error-display--safari-script-error--light.png index ef06a5729bc58..cf2e13d4c3a4d 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--safari-script-error--light.png and b/frontend/__snapshots__/components-errors-error-display--safari-script-error--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png new file mode 100644 index 0000000000000..30563222db743 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png new file mode 100644 index 0000000000000..7bd93910cad21 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png differ diff --git a/frontend/__snapshots__/components-itemperformanceevent--default--dark.png b/frontend/__snapshots__/components-itemperformanceevent--default--dark.png index c5d61e14c1f9a..738adb9ae4874 100644 Binary files a/frontend/__snapshots__/components-itemperformanceevent--default--dark.png and b/frontend/__snapshots__/components-itemperformanceevent--default--dark.png differ diff --git a/frontend/__snapshots__/components-itemperformanceevent--default--light.png b/frontend/__snapshots__/components-itemperformanceevent--default--light.png index 32f183bc9afb8..def2f72b7e957 100644 Binary files a/frontend/__snapshots__/components-itemperformanceevent--default--light.png and b/frontend/__snapshots__/components-itemperformanceevent--default--light.png differ diff --git a/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--dark.png b/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--dark.png index 6dd0161a8448b..e42315417d6a5 100644 Binary files a/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--dark.png and b/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--dark.png differ diff --git a/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--light.png b/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--light.png index 9eebd7c3c1f28..712bc799ea5b6 100644 Binary files a/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--light.png and b/frontend/__snapshots__/components-itemperformanceevent--no-performance-observer-captured-data--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png index 7494daa92ea60..420e810171c60 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png index 51fad07d62bd2..a5eaddfafa8fc 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--all-slow--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png index 7de04a1629761..a078dc1922b9b 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png index ee5050b64c7b4..bb8b99008f083 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--default--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png index 7de04a1629761..a078dc1922b9b 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png index ee5050b64c7b4..bb8b99008f083 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--expanded--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png index 251554643f0e4..92459b7dfc58c 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png index b276d05aab591..2484205c3d826 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png index 148fbeb97033d..34aab05bac888 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png index f89d65046983f..708c88f176b7c 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png index c80acad2a6a70..31c6a96ca514a 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png index a5d3a3a45554f..36f040521e52f 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--really-slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png index c18d7cf7370e3..c750624468474 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png index be6e13ff4238d..353ba62db76a5 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-dom-interactive--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png index 995c28ca2456c..6106f71c1f62e 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png index fb31ae3336877..e9d26af168a86 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-fcp--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png index 1fca7deec5e3e..9d98ab8ea4cfc 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png index b5a12f4c30bc9..fd332038ca699 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--slow-load-event--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png index c4923dbc362ea..9768e4df833e1 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png index b6490cd7b1f29..08eeadb5d8bb5 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-fast--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png index 40b68ed57c758..7b480de622209 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png index 4c665349a1789..f1c8e5cacb387 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-medium--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png index 9f82aee1015ca..5a1bd8020ef2c 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png index 80fa95f219d3d..215b67a249262 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-all-slow--light.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--dark.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--dark.png index 0d0bd60f88beb..488140bfb1571 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--dark.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--dark.png differ diff --git a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--light.png b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--light.png index 09f5bee19c39a..5dea38e2b1c7f 100644 Binary files a/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--light.png and b/frontend/__snapshots__/components-networkrequest-navigationitem--web-vitals-loading--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector--default--dark.png b/frontend/__snapshots__/components-playerinspector--default--dark.png index e1d088184e1c8..2b57e31f7eee7 100644 Binary files a/frontend/__snapshots__/components-playerinspector--default--dark.png and b/frontend/__snapshots__/components-playerinspector--default--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector--default--light.png b/frontend/__snapshots__/components-playerinspector--default--light.png index e9aabac97d7d9..f1820f5829033 100644 Binary files a/frontend/__snapshots__/components-playerinspector--default--light.png and b/frontend/__snapshots__/components-playerinspector--default--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemcomment--default--dark.png b/frontend/__snapshots__/components-playerinspector-itemcomment--default--dark.png index 6b78867555299..56e6ba7f18550 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemcomment--default--dark.png and b/frontend/__snapshots__/components-playerinspector-itemcomment--default--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemcomment--default--light.png b/frontend/__snapshots__/components-playerinspector-itemcomment--default--light.png index 21e8bd59796b2..3dde213beee52 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemcomment--default--light.png and b/frontend/__snapshots__/components-playerinspector-itemcomment--default--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--default--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--default--dark.png index b0bfbd0574e4a..da00c576592b2 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--default--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--default--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--default--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--default--light.png index b35692118dc4d..5f40cbab56c12 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--default--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--default--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png index 6c4a1dec3ea66..e2a3d5411be6c 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png index 8cbe6b6d59ff7..4df06c766f7b9 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--dark.png index ca46a1c4b9f7a..3b8d51e0a588b 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--light.png index c0c0c4e0a5486..ff7b2fed50cf8 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--group-identify-event--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png index 42fb7aff7bf50..30c670927df95 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png index e70dde07ab027..b93df7b950af2 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-current-url--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png index c401cc1dee55b..fc8e8fe2343d3 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png index 45532a4a203c6..86ab8c5f1c872 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--page-view-with-path--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png index 181a6448540f6..d87281ef5ed48 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png index 83b350186b4e4..268f601f82dc4 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png index 4a560e12b2583..564cb99e745f6 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png index e719f5a2d63cd..cff106a904abb 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--web-vitals-event--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png index e7d6b2a7d8326..9e94e50e11369 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png index d360db633aa3c..afcf5cd9c035f 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png index 7f417ca6f1d05..d66b1f11c1a6b 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png index 04c5d1236994e..eef82575bea8b 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png index ce20b2d82ad61..687b3e5258355 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png index bf60bd921e3d3..01823e88fef50 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png b/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png index 65789fcd50fe2..40140ddc7eaf2 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png and b/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png b/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png index 6afb89461f228..002eed214b8e2 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png and b/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--dark.png index 52a44ae8eece8..df215f085e972 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png index 0725eb97f8df0..ee27b11bed568 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-breakdown-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line-multi--light.png b/frontend/__snapshots__/scenes-app-insights--trends-line-multi--light.png index c22f4a19b188f..fbc4e44286086 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line-multi--light.png and b/frontend/__snapshots__/scenes-app-insights--trends-line-multi--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--dark.png b/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--dark.png index eedf3e01916a0..8c043460ab552 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--light.png b/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--light.png index c19e174167b7c..a042e88608e7a 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--light.png and b/frontend/__snapshots__/scenes-app-max-ai--empty-thread-loading--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--dark.png b/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--dark.png index 84a2a7828cdd5..69db9b395eca7 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--light.png b/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--light.png index 43ad8593f41e8..4a9c5c33c2c4b 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--light.png and b/frontend/__snapshots__/scenes-app-max-ai--generation-failure-thread--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread--dark.png b/frontend/__snapshots__/scenes-app-max-ai--thread--dark.png index 80aded4c79433..3e3689533ff1d 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--thread--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--thread--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread--light.png b/frontend/__snapshots__/scenes-app-max-ai--thread--light.png index 22c88333171fd..beb2dab935bda 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--thread--light.png and b/frontend/__snapshots__/scenes-app-max-ai--thread--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--dark.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--dark.png index eccd3471a1f51..e22dc0db87019 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--light.png b/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--light.png index da0efa6c70d73..483b9a3ff881e 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--light.png and b/frontend/__snapshots__/scenes-app-max-ai--thread-with-failed-generation--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png index b7b361d68260b..c844325595b28 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png index a2883e6e32ccf..a968d4cd3be77 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png index 820fd669da245..be67ab75d125b 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png index d09949248bbca..cf026b65c4994 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-loading-suggestions--light.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png index 1b0205298a1a4..10ef2c718a54f 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png index 5de13f2dc0f7c..5cfea77caa4b1 100644 Binary files a/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png and b/frontend/__snapshots__/scenes-app-max-ai--welcome-suggestions-available--light.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--dark.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--dark.png index f84db4546c21f..923fe9d53a9c8 100644 Binary files a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--dark.png and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--light.png b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--light.png index 2dce17c37f60d..8ac25dbcb685c 100644 Binary files a/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--light.png and b/frontend/__snapshots__/scenes-app-sidepanels--side-panel-settings--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-project--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png index c8cd83be7e7cd..80170dc473110 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png index e8fe754ac0735..6d3ceba73c298 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-project-with-replay-features--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-all-options--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-password-only--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-github--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-google--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-enforced-saml--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png index 24b46c282634e..aae9300c3af1f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png index ff07ce0325337..2cdd7edf2b26f 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-session-timeout-sso-only--light.png differ diff --git a/frontend/public/services/brevo.png b/frontend/public/services/brevo.png new file mode 100644 index 0000000000000..65dfdbac8030a Binary files /dev/null and b/frontend/public/services/brevo.png differ diff --git a/frontend/src/layout/navigation-3000/Navigation.scss b/frontend/src/layout/navigation-3000/Navigation.scss index 07bdfe66828a6..df5f78ab272c6 100644 --- a/frontend/src/layout/navigation-3000/Navigation.scss +++ b/frontend/src/layout/navigation-3000/Navigation.scss @@ -175,7 +175,7 @@ .Sidebar3000 { --sidebar-slider-padding: 0.125rem; --sidebar-horizontal-padding: 0.5rem; - --sidebar-row-height: 2rem; + --sidebar-row-height: 2.5rem; --sidebar-background: var(--bg-3000); position: relative; @@ -451,7 +451,8 @@ } // Accommodate menu button by moving stuff out of the way - &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link { + &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__link, + &.SidebarListItem--has-menu:not(.SidebarListItem--extended) .SidebarListItem__button { padding-right: calc(var(--sidebar-horizontal-padding) + 1.25rem); } @@ -523,6 +524,7 @@ } } +.SidebarListItem__button, .SidebarListItem__link, .SidebarListItem__rename { --sidebar-list-item-inset: calc( @@ -555,6 +557,17 @@ } } +.SidebarListItem__button { + row-gap: 1px; + padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset); + color: inherit !important; // Disable link color + cursor: pointer; + + &:hover { + background: var(--border-3000); + } +} + .SidebarListItem__rename { // Pseudo-elements don't work on inputs, so we use a wrapper div background: var(--bg-light); diff --git a/frontend/src/layout/navigation-3000/components/Navbar.tsx b/frontend/src/layout/navigation-3000/components/Navbar.tsx index 62308871fc11a..c0c641227828d 100644 --- a/frontend/src/layout/navigation-3000/components/Navbar.tsx +++ b/frontend/src/layout/navigation-3000/components/Navbar.tsx @@ -27,7 +27,7 @@ export function Navbar(): JSX.Element { const { isAccountPopoverOpen, systemStatusHealthy } = useValues(navigationLogic) const { closeAccountPopover, toggleAccountPopover } = useActions(navigationLogic) const { isNavShown, isSidebarShown, activeNavbarItemId, navbarItems, mobileLayout } = useValues(navigation3000Logic) - const { showSidebar, hideSidebar, toggleNavCollapsed, hideNavOnMobile } = useActions(navigation3000Logic) + const { toggleNavCollapsed, hideNavOnMobile, showSidebar, hideSidebar } = useActions(navigation3000Logic) const { featureFlags } = useValues(featureFlagLogic) const { toggleSearchBar } = useActions(commandBarLogic) diff --git a/frontend/src/layout/navigation-3000/components/Sidebar.tsx b/frontend/src/layout/navigation-3000/components/Sidebar.tsx index 52610910586b6..96497e047ff25 100644 --- a/frontend/src/layout/navigation-3000/components/Sidebar.tsx +++ b/frontend/src/layout/navigation-3000/components/Sidebar.tsx @@ -19,8 +19,16 @@ const SEARCH_DEBOUNCE_MS = 300 interface SidebarProps { navbarItem: SidebarNavbarItem // Sidebar can only be rendered if there's an active sidebar navbar item + sidebarOverlay?: React.ReactNode + sidebarOverlayProps?: SidebarOverlayProps } -export function Sidebar({ navbarItem }: SidebarProps): JSX.Element { + +interface SidebarOverlayProps { + className?: string + isOpen?: boolean +} + +export function Sidebar({ navbarItem, sidebarOverlay, sidebarOverlayProps }: SidebarProps): JSX.Element { const inputElementRef = useRef(null) const { @@ -81,6 +89,11 @@ export function Sidebar({ navbarItem }: SidebarProps): JSX.Element { } }} /> + {sidebarOverlay && ( + + {sidebarOverlay} + + )} ) } @@ -199,3 +212,24 @@ function SidebarKeyboardShortcut(): JSX.Element { ) } + +function SidebarOverlay({ + className, + isOpen = false, + children, + width, +}: SidebarOverlayProps & { children: React.ReactNode; width: number }): JSX.Element | null { + if (!isOpen) { + return null + } + + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/layout/navigation-3000/components/SidebarList.tsx b/frontend/src/layout/navigation-3000/components/SidebarList.tsx index d42b257b15d17..2b63b9a61e9c6 100644 --- a/frontend/src/layout/navigation-3000/components/SidebarList.tsx +++ b/frontend/src/layout/navigation-3000/components/SidebarList.tsx @@ -13,7 +13,14 @@ import { InfiniteLoader } from 'react-virtualized/dist/es/InfiniteLoader' import { List, ListProps } from 'react-virtualized/dist/es/List' import { ITEM_KEY_PART_SEPARATOR, navigation3000Logic } from '../navigationLogic' -import { BasicListItem, ExtendedListItem, ExtraListItemContext, SidebarCategory, TentativeListItem } from '../types' +import { + BasicListItem, + ButtonListItem, + ExtendedListItem, + ExtraListItemContext, + SidebarCategory, + TentativeListItem, +} from '../types' import { KeyboardShortcut } from './KeyboardShortcut' export function SidebarList({ category }: { category: SidebarCategory }): JSX.Element { @@ -122,7 +129,7 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El } interface SidebarListItemProps { - item: BasicListItem | ExtendedListItem | TentativeListItem + item: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem validateName?: SidebarCategory['validateName'] active?: boolean style: React.CSSProperties @@ -132,6 +139,10 @@ function isItemTentative(item: SidebarListItemProps['item']): item is TentativeL return 'onSave' in item } +function isItemClickable(item: SidebarListItemProps['item']): item is ButtonListItem { + return 'onClick' in item +} + function SidebarListItem({ item, validateName, active, style }: SidebarListItemProps): JSX.Element { const [isMenuOpen, setIsMenuOpen] = useState(false) const [newName, setNewName] = useState(null) @@ -218,7 +229,13 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP }) // Intentionally run on every render so that ref value changes are picked up let content: JSX.Element - if (!save || (!isItemTentative(item) && newName === null)) { + if (isItemClickable(item)) { + content = ( +
  • +
    {item.name}
    +
  • + ) + } else if (!save || (!isItemTentative(item) && newName === null)) { if (isItemTentative(item)) { throw new Error('Tentative items should not be rendered in read mode') } diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index ca43417d405ec..4a81a00349ca9 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -31,6 +31,7 @@ import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isNotNil } from 'lib/utils' import React from 'react' +import { editorSidebarLogic } from 'scenes/data-warehouse/editor/editorSidebarLogic' import { sceneLogic } from 'scenes/sceneLogic' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' @@ -103,9 +104,6 @@ export const navigation3000Logic = kea([ reducers({ isSidebarShown: [ true, - { - persist: true, - }, { hideSidebar: () => false, showSidebar: () => true, @@ -514,9 +512,10 @@ export const navigation3000Logic = kea([ featureFlags[FEATURE_FLAGS.SQL_EDITOR] ? { identifier: Scene.SQLEditor, - label: 'SQL editor', + label: 'Data warehouse', icon: , - to: isUsingSidebar ? undefined : urls.sqlEditor(), + to: urls.sqlEditor(), + logic: editorSidebarLogic, } : null, featureFlags[FEATURE_FLAGS.DATA_MODELING] && hasOnboardedAnyProduct @@ -598,6 +597,9 @@ export const navigation3000Logic = kea([ activeNavbarItemId: [ (s) => [s.activeNavbarItemIdRaw, featureFlagLogic.selectors.featureFlags], (activeNavbarItemIdRaw, featureFlags): string | null => { + if (featureFlags[FEATURE_FLAGS.SQL_EDITOR] && activeNavbarItemIdRaw === Scene.SQLEditor) { + return Scene.SQLEditor + } if (!featureFlags[FEATURE_FLAGS.POSTHOG_3000_NAV]) { return null } diff --git a/frontend/src/layout/navigation-3000/types.ts b/frontend/src/layout/navigation-3000/types.ts index 2ef13b34c258f..3f79f6dbda42f 100644 --- a/frontend/src/layout/navigation-3000/types.ts +++ b/frontend/src/layout/navigation-3000/types.ts @@ -104,6 +104,7 @@ export interface BasicListItem { * URL within the app. In specific cases this can be null - such items are italicized. */ url: string | null + onClick?: () => void /** An optional marker to highlight item state. */ marker?: { /** A marker of type `fold` is a small triangle in the top left, `ribbon` is a narrow ribbon to the left. */ @@ -146,3 +147,8 @@ export interface TentativeListItem { adding: boolean ref?: BasicListItem['ref'] } + +export interface ButtonListItem extends BasicListItem { + key: '__button__' + onClick: () => void +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d91ab7592dea2..b443065a41e9d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -113,6 +113,7 @@ import { } from '~/types' import { AlertType, AlertTypeWrite } from './components/Alerts/types' +import { ErrorTrackingStackFrameContext } from './components/Errors/types' import { ACTIVITY_PAGE_SIZE, DashboardPrivilegeLevel, @@ -719,6 +720,14 @@ class ApiRequest { return this.errorTracking().addPathComponent('upload_source_maps') } + public errorTrackingStackFrames(): ApiRequest { + return this.errorTracking().addPathComponent('stack_frames') + } + + public errorTrackingStackFrameContexts(ids: string[]): ApiRequest { + return this.errorTrackingStackFrames().addPathComponent('contexts').withQueryString(toParams({ ids }, true)) + } + // # Warehouse public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('warehouse_tables') @@ -1857,6 +1866,10 @@ const api = { async uploadSourceMaps(data: FormData): Promise<{ content: string }> { return await new ApiRequest().errorTrackingUploadSourceMaps().create({ data }) }, + + async fetchStackFrames(ids: string[]): Promise> { + return await new ApiRequest().errorTrackingStackFrameContexts(ids).get() + }, }, recordings: { @@ -2198,7 +2211,7 @@ const api = { }, async update( viewId: DataWarehouseSavedQuery['id'], - data: Pick + data: Partial ): Promise { return await new ApiRequest().dataWarehouseSavedQuery(viewId).update({ data }) }, diff --git a/frontend/src/lib/components/CodeSnippet/CodeSnippet.scss b/frontend/src/lib/components/CodeSnippet/CodeSnippet.scss index a23cdf21970c5..62352529bb29e 100644 --- a/frontend/src/lib/components/CodeSnippet/CodeSnippet.scss +++ b/frontend/src/lib/components/CodeSnippet/CodeSnippet.scss @@ -26,7 +26,6 @@ pre { padding: 0.75rem 1rem; - margin: 0; // Reset background: var(--accent-3000) !important; border: solid 1px var(--border-3000) !important; border-radius: var(--radius) !important; diff --git a/frontend/src/lib/components/CodeSnippet/CodeSnippet.tsx b/frontend/src/lib/components/CodeSnippet/CodeSnippet.tsx index cf7033aff1046..8bbf88f3bebf0 100644 --- a/frontend/src/lib/components/CodeSnippet/CodeSnippet.tsx +++ b/frontend/src/lib/components/CodeSnippet/CodeSnippet.tsx @@ -54,6 +54,53 @@ export enum Language { Kotlin = 'kotlin', } +export const getLanguage = (lang: string): Language => { + switch (lang) { + case 'bash': + return Language.Bash + case 'jsx': + return Language.JSX + case 'javascript': + return Language.JavaScript + case 'java': + return Language.Java + case 'ruby': + return Language.Ruby + case 'objectivec': + return Language.ObjectiveC + case 'swift': + return Language.Swift + case 'elixir': + return Language.Elixir + case 'php': + return Language.PHP + case 'python': + return Language.Python + case 'dart': + return Language.Dart + case 'go': + return Language.Go + case 'json': + return Language.JSON + case 'yaml': + return Language.YAML + case 'html': + return Language.HTML + case 'xml': + return Language.XML + case 'http': + return Language.HTTP + case 'markup': + return Language.Markup + case 'sql': + return Language.SQL + case 'kotlin': + return Language.Kotlin + default: + return Language.Text + } +} + SyntaxHighlighter.registerLanguage(Language.Bash, bash) SyntaxHighlighter.registerLanguage(Language.JSX, jsx) SyntaxHighlighter.registerLanguage(Language.JavaScript, javascript) @@ -98,8 +145,6 @@ export function CodeSnippet({ thing = 'snippet', maxLinesWithoutExpansion, }: CodeSnippetProps): JSX.Element | null { - const { isDarkModeOn } = useValues(themeLogic) - const [expanded, setExpanded] = useState(false) const [indexOfLimitNewline, setIndexOfLimitNewline] = useState( maxLinesWithoutExpansion ? indexOfNth(text || '', '\n', maxLinesWithoutExpansion) : -1 @@ -136,14 +181,7 @@ export function CodeSnippet({ noPadding /> - - {displayedText} - + {indexOfLimitNewline !== -1 && ( setExpanded(!expanded)} @@ -163,6 +201,30 @@ export function CodeSnippet({ ) } +export function CodeLine({ + text, + wrapLines, + language, +}: { + text: string + wrapLines: boolean + language: Language +}): JSX.Element { + const { isDarkModeOn } = useValues(themeLogic) + + return ( +
    {children}
    } + > + {text} +
    + ) +} + function indexOfNth(string: string, character: string, n: number): number { let count = 0, indexSoFar = 0 diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.scss b/frontend/src/lib/components/Errors/ErrorDisplay.scss new file mode 100644 index 0000000000000..11b989d20227b --- /dev/null +++ b/frontend/src/lib/components/Errors/ErrorDisplay.scss @@ -0,0 +1,6 @@ +.ErrorDisplay__stacktrace { + .LemonCollapsePanel__header { + min-height: 2.375rem !important; + padding: 0.25rem !important; + } +} diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx b/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx index 7e327618e48d0..6107628852b9e 100644 --- a/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx +++ b/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx @@ -1,11 +1,45 @@ import { Meta } from '@storybook/react' import { ErrorDisplay } from 'lib/components/Errors/ErrorDisplay' +import { mswDecorator } from '~/mocks/browser' import { EventType } from '~/types' const meta: Meta = { title: 'Components/Errors/Error Display', component: ErrorDisplay, + decorators: [ + mswDecorator({ + get: { + '/api/projects/:team_id/error_tracking/stack_frames/contexts/': { + rawId: { + before: [ + { + number: 7, + line: ' const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app)', + }, + { + number: 8, + line: '', + }, + { number: 9, line: ' useEffect(() => {' }, + ], + line: { number: 10, line: ' loadFrameContexts({ frames })' }, + after: [ + { number: 11, line: ' }, [frames, loadFrameContexts])' }, + { + number: 12, + line: '', + }, + { + number: 13, + line: ' const initiallyActiveIndex = displayFrames.findIndex((f) => f.in_app) || 0', + }, + ], + }, + }, + }, + }), + ], } export default meta @@ -55,13 +89,14 @@ function errorProperties(properties: Record): EventType['properties synthetic: true, }, stacktrace: { + type: 'resolved', frames: [ { - colno: 0, - filename: 'https://app.posthog.com/home', - function: '?', + column: 0, + source: 'https://app.posthog.com/home', + resolved_name: '?', in_app: true, - lineno: 0, + line: 0, }, ], }, @@ -141,13 +176,14 @@ export function AnonymousErrorWithStackTrace(): JSX.Element { type: 'Error', value: 'wat123', stacktrace: { + type: 'resolved', frames: [ { - filename: '', - function: '?', + source: '', + resolved_name: '?', in_app: true, - lineno: 1, - colno: 26, + line: 1, + column: 26, }, ], }, @@ -172,48 +208,14 @@ export function ChainedErrorStack(): JSX.Element { stacktrace: { frames: [ { - filename: 'example2.py', - abs_path: '/posthog-python/example2.py', - function: 'will_raise', - module: '__main__', - lineno: 33, - pre_context: [ - 'def more_obfuscation():', - ' print(3 / 0)', - '', - 'def will_raise():', - ' try:', - ], - context_line: ' more_obfuscation()', - post_context: [ - ' except Exception as e:', - ' raise CustomException("This is a custom exception") from e', - '', - 'will_raise()', - 'exit()', - ], + source: '/posthog-python/example2.py', + resolved_name: 'will_raise', + line: 33, }, { - filename: 'example2.py', - abs_path: '/posthog-python/example2.py', - function: 'more_obfuscation', - module: '__main__', - lineno: 29, - pre_context: [ - '', - 'class CustomException(Exception):', - ' pass', - '', - 'def more_obfuscation():', - ], - context_line: ' print(3 / 0)', - post_context: [ - '', - 'def will_raise():', - ' try:', - ' more_obfuscation()', - ' except Exception as e:', - ], + source: '/posthog-python/example2.py', + resolved_name: 'more_obfuscation', + line: 29, }, ], }, @@ -225,42 +227,43 @@ export function ChainedErrorStack(): JSX.Element { stacktrace: { frames: [ { - filename: 'example2.py', - abs_path: '/Users/neilkakkar/Project/posthog-python/example2.py', - function: '', - module: '__main__', - lineno: 37, - pre_context: [ - ' try:', - ' more_obfuscation()', - ' except Exception as e:', - ' raise CustomException("This is a custom exception") from e', - '', - ], - context_line: 'will_raise()', - post_context: [ - 'exit()', - '', - '', - '# print(posthog.get_all_flags("distinct_id_random_22"))', - '# print(', - ], + source: '/Users/neilkakkar/Project/posthog-python/example2.py', + resolved_name: '', + line: 37, }, { - filename: 'example2.py', - abs_path: '/Users/neilkakkar/Project/posthog-python/example2.py', - function: 'will_raise', - module: '__main__', - lineno: 35, - pre_context: [ - '', - 'def will_raise():', - ' try:', - ' more_obfuscation()', - ' except Exception as e:', - ], - context_line: ' raise CustomException("This is a custom exception") from e', - post_context: ['', 'will_raise()', 'exit()', '', ''], + source: '/Users/neilkakkar/Project/posthog-python/example2.py', + resolved_name: 'will_raise', + line: 35, + }, + ], + }, + }, + ], + })} + /> + ) +} + +export function StackTraceWithLineContext(): JSX.Element { + return ( + ', + resolved_name: '?', + in_app: true, + line: 1, + column: 26, + lang: 'javascript', }, ], }, diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.tsx b/frontend/src/lib/components/Errors/ErrorDisplay.tsx index 58770420674d0..de1e0858be5a2 100644 --- a/frontend/src/lib/components/Errors/ErrorDisplay.tsx +++ b/frontend/src/lib/components/Errors/ErrorDisplay.tsx @@ -1,71 +1,121 @@ +import './ErrorDisplay.scss' + import { IconFlag } from '@posthog/icons' +import { LemonBanner, LemonCollapse } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' import { TitledSnack } from 'lib/components/TitledSnack' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch' import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' import { Link } from 'lib/lemon-ui/Link' -import posthog from 'posthog-js' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { EventType } from '~/types' -interface StackFrame { - filename: string - lineno: number - colno: number - function: string - context_line?: string - in_app?: boolean -} +import { CodeLine, getLanguage, Language } from '../CodeSnippet/CodeSnippet' +import { stackFrameLogic } from './stackFrameLogic' +import { + ErrorTrackingException, + ErrorTrackingStackFrame, + ErrorTrackingStackFrameContext, + ErrorTrackingStackFrameContextLine, +} from './types' -interface ExceptionTrace { - stacktrace: { - frames: StackFrame[] - } - module: string - type: string - value: string -} +function StackTrace({ + frames, + showAllFrames, +}: { + frames: ErrorTrackingStackFrame[] + showAllFrames: boolean +}): JSX.Element | null { + const { frameContexts } = useValues(stackFrameLogic) + const { loadFrameContexts } = useActions(stackFrameLogic) + const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app) + + useEffect(() => { + loadFrameContexts({ frames }) + }, [frames, loadFrameContexts]) + + const initiallyActiveIndex = displayFrames.findIndex((f) => f.in_app) || 0 -function parseToFrames(rawTrace: string): StackFrame[] { - return JSON.parse(rawTrace) + const panels = displayFrames.map(({ raw_id, source, line, column, resolved_name, lang }, index) => { + const frameContext = frameContexts[raw_id] + return { + key: index, + header: ( +
    + {source} + {resolved_name ? ( +
    + in + {resolved_name} +
    + ) : null} + {line ? ( +
    + @ + + {line} + {column && `:${column}`} + +
    + ) : null} +
    + ), + content: frameContext ? : null, + className: 'p-0', + } + }) + + return } -function StackTrace({ rawTrace, showAllFrames }: { rawTrace: string; showAllFrames: boolean }): JSX.Element | null { - try { - const frames = parseToFrames(rawTrace) - return ( - <> - {frames.length ? ( - frames.map((frame, index) => { - const { filename, lineno, colno, function: functionName, context_line, in_app } = frame - - return showAllFrames || in_app ? ( - - {filename}:{lineno}:{colno} - {context_line ? `:${context_line}` : ''} - - } - /> - ) : null - }) - ) : ( - Empty stack trace - )} - - ) - } catch (e: any) { - //very meta - posthog.capture('Cannot parse stack trace in Exception event', { tag: 'error-display-stack-trace', e }) - return Error parsing stack trace - } +function FrameContext({ + context, + language, +}: { + context: ErrorTrackingStackFrameContext + language: Language +}): JSX.Element { + const { before, line, after } = context + return ( + <> + + + + + ) } -function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[] }): JSX.Element { +function FrameContextLine({ + lines, + language, + highlight, +}: { + lines: ErrorTrackingStackFrameContextLine[] + language: Language + highlight?: boolean +}): JSX.Element { + return ( +
    + {lines + .sort((l) => l.number) + .map(({ number, line }) => ( +
    +
    {number}
    + +
    + ))} +
    + ) +} +function ChainedStackTraces({ + exceptionList, + ingestionErrors, +}: { + exceptionList: ErrorTrackingException[] + ingestionErrors: string[] +}): JSX.Element { const [showAllFrames, setShowAllFrames] = useState(false) return ( @@ -81,19 +131,32 @@ function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[] }} /> + {ingestionErrors && ( + +
      + {ingestionErrors.map((e, i) => ( +
    • {e}
    • + ))} +
    +
    + )} {exceptionList.map(({ stacktrace, value }, index) => { - const { frames } = stacktrace || {} - if (!showAllFrames && !frames?.some((frame) => frame.in_app)) { - // if we're not showing all frames and there are no in_app frames, skip this exception - return null + if (stacktrace.type === 'resolved') { + const { frames } = stacktrace + if (!showAllFrames && !frames?.some((frame) => frame.in_app)) { + // if we're not showing all frames and there are no in_app frames, skip this exception + return null + } + + return ( +
    +

    {value}

    + +
    + ) } - return ( -
    -

    {value}

    - -
    - ) + return null })} ) @@ -190,6 +253,7 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType[' $sentry_url, $exception_list, $level, + $cymbal_errors, } = getExceptionPropertiesFrom(eventProperties) return ( @@ -221,7 +285,9 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType[' - {$exception_list?.length ? : null} + {$exception_list?.length ? ( + + ) : null}

    Active Feature Flags

    diff --git a/frontend/src/lib/components/Errors/stackFrameLogic.ts b/frontend/src/lib/components/Errors/stackFrameLogic.ts new file mode 100644 index 0000000000000..ab1744481c0a8 --- /dev/null +++ b/frontend/src/lib/components/Errors/stackFrameLogic.ts @@ -0,0 +1,25 @@ +import { kea, path } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' + +import type { stackFrameLogicType } from './stackFrameLogicType' +import { ErrorTrackingStackFrame, ErrorTrackingStackFrameContext } from './types' + +export const stackFrameLogic = kea([ + path(['components', 'Errors', 'stackFrameLogic']), + loaders(({ values }) => ({ + frameContexts: [ + {} as Record, + { + loadFrameContexts: async ({ frames }: { frames: ErrorTrackingStackFrame[] }) => { + const loadedFrameIds = Object.keys(values.frameContexts) + const ids = frames + .filter(({ raw_id }) => loadedFrameIds.includes(raw_id)) + .map(({ raw_id }) => raw_id) + const response = await api.errorTracking.fetchStackFrames(ids) + return { ...values.frameContexts, ...response } + }, + }, + ], + })), +]) diff --git a/frontend/src/lib/components/Errors/types.ts b/frontend/src/lib/components/Errors/types.ts new file mode 100644 index 0000000000000..df359ada3b16e --- /dev/null +++ b/frontend/src/lib/components/Errors/types.ts @@ -0,0 +1,54 @@ +export interface ErrorTrackingException { + stacktrace: ErrorTrackingRawStackTrace | ErrorTrackingResolvedStackTrace + module: string + type: string + value: string +} + +interface ErrorTrackingRawStackTrace { + type: 'raw' + frames: any[] // TODO: type more concretely if we end up needing this (right now we show the $cymbal_errors instead) +} +interface ErrorTrackingResolvedStackTrace { + type: 'resolved' + frames: ErrorTrackingStackFrame[] +} + +export interface ErrorTrackingStackFrameRecord { + id: string + raw_id: string + created_at: string + symbol_set: string + resolved: boolean + context: ErrorTrackingStackFrameContext | null + contents: ErrorTrackingStackFrame // For now, while we're not 100% on content structure +} + +export type ErrorTrackingStackFrameContext = { + before: ErrorTrackingStackFrameContextLine[] + line: ErrorTrackingStackFrameContextLine + after: ErrorTrackingStackFrameContextLine[] +} +export type ErrorTrackingStackFrameContextLine = { number: number; line: string } + +export interface ErrorTrackingStackFrame { + raw_id: string + mangled_name: string + line: number | null + column: number | null + source: string | null + in_app: boolean + resolved_name: string | null + lang: string + resolved: boolean + resolve_failure: string | null +} + +export interface ErrorTrackingSymbolSet { + id: string + ref: string + team_id: number + created_at: string + storage_ptr: string | null + failure_reason: string | null +} diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx index 1f7d2c8cd5f17..70b61d4841019 100644 --- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx +++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx @@ -959,7 +959,7 @@ export const HedgehogBuddy = React.forwardRef { onPositionChange?.(actor) - }, [actor.x, actor.y]) + }, [actor.x, actor.y, actor.direction]) const onClick = (): void => { !actor.isDragging && _onClick?.(actor) diff --git a/frontend/src/lib/components/TaxonomicFilter/cohortFilterUtils.ts b/frontend/src/lib/components/TaxonomicFilter/cohortFilterUtils.ts index 89ba438fd2fb3..0ecf87bdc0590 100644 --- a/frontend/src/lib/components/TaxonomicFilter/cohortFilterUtils.ts +++ b/frontend/src/lib/components/TaxonomicFilter/cohortFilterUtils.ts @@ -10,7 +10,7 @@ function isCohortCriteriaGroupFilter( const hasBehavioralFilter = (cohort: CohortType, allCohorts: CohortType[]): boolean => { const checkCriteriaGroup = (group: CohortCriteriaGroupFilter): boolean => { - return group.values.some((value) => { + return group.values?.some((value) => { if (isCohortCriteriaGroupFilter(value)) { return checkCriteriaGroup(value) } diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 881e9691b4f06..d9d84f586c12e 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -169,6 +169,7 @@ export const FEATURE_FLAGS = { SURVEYS_EVENTS: 'surveys-events', // owner: #team-feature-success SURVEYS_ACTIONS: 'surveys-actions', // owner: #team-feature-success SURVEYS_RECURRING: 'surveys-recurring', // owner: #team-feature-success + SURVEYS_ADAPTIVE_COLLECTION: 'surveys-recurring', // owner: #team-feature-success YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay DISCUSSIONS: 'discussions', // owner: #team-replay @@ -221,7 +222,6 @@ export const FEATURE_FLAGS = { REPLAY_TEMPLATES: 'replay-templates', // owner: @raquelmsmith #team-replay EXPERIMENTS_HOGQL: 'experiments-hogql', // owner: @jurajmajerik #team-experiments ROLE_BASED_ACCESS_CONTROL: 'role-based-access-control', // owner: @zach - EXPERIMENTS_HOLDOUTS: 'experiments-holdouts', // owner: @jurajmajerik #team-experiments MESSAGING: 'messaging', // owner @mariusandra #team-cdp SESSION_REPLAY_URL_BLOCKLIST: 'session-replay-url-blocklist', // owner: @richard-better #team-replay BILLING_TRIAL_FLOW: 'billing-trial-flow', // owner: @zach @@ -229,6 +229,8 @@ export const FEATURE_FLAGS = { EDIT_DWH_SOURCE_CONFIG: 'edit_dwh_source_config', // owner: @Gilbert09 #team-data-warehouse AI_SURVEY_RESPONSE_SUMMARY: 'ai-survey-response-summary', // owner: @pauldambra CUSTOM_CHANNEL_TYPE_RULES: 'custom-channel-type-rules', // owner: @robbie-c #team-web-analytics + SELF_SERVE_CREDIT_OVERRIDE: 'self-serve-credit-override', // owner: @zach + EXPERIMENTS_MIGRATION_DISABLE_UI: 'experiments-migration-disable-ui', // owner: @jurajmajerik #team-experiments } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/hooks/useUploadFiles.ts b/frontend/src/lib/hooks/useUploadFiles.ts index f486c50649580..62d09217cbd0f 100644 --- a/frontend/src/lib/hooks/useUploadFiles.ts +++ b/frontend/src/lib/hooks/useUploadFiles.ts @@ -1,5 +1,5 @@ import api from 'lib/api' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { MediaUploadResponse } from '~/types' @@ -47,14 +47,17 @@ export function useUploadFiles({ } { const [uploading, setUploading] = useState(false) const [filesToUpload, setFilesToUpload] = useState([]) + const uploadInProgressRef = useRef(false) + useEffect(() => { const uploadFiles = async (): Promise => { - if (filesToUpload.length === 0) { + if (filesToUpload.length === 0 || uploadInProgressRef.current) { setUploading(false) return } try { + uploadInProgressRef.current = true setUploading(true) const file: File = filesToUpload[0] const media = await uploadFile(file) @@ -63,6 +66,7 @@ export function useUploadFiles({ const errorDetail = (error as any).detail || 'unknown error' onError(errorDetail) } finally { + uploadInProgressRef.current = false setUploading(false) setFilesToUpload([]) } diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss index 6e7dfba7a766c..5a27fde413644 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss @@ -32,6 +32,11 @@ &.LemonButton:active { transform: inherit; } + + &--disabled:hover { + cursor: default; + background-color: var(--bg-light) !important; + } } .LemonCollapsePanel__body { diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx index 640bac2ab6da3..36e67c77447b5 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx @@ -116,18 +116,28 @@ function LemonCollapsePanel({ return (
    - { - onHeaderClick && onHeaderClick() - onChange(!isExpanded) - }} - icon={isExpanded ? : } - className="LemonCollapsePanel__header" - {...(dataAttr ? { 'data-attr': dataAttr } : {})} - size={size} - > - {header} - + {content ? ( + { + onHeaderClick && onHeaderClick() + onChange(!isExpanded) + }} + icon={isExpanded ? : } + className="LemonCollapsePanel__header" + {...(dataAttr ? { 'data-attr': dataAttr } : {})} + size={size} + > + {header} + + ) : ( + + {header} + + )} {(status) => (
    & { initialValues: Record onSubmit: (values: Record) => void | Promise + shouldAwaitSubmit?: boolean } export type LemonDialogProps = Pick< @@ -26,6 +27,7 @@ export type LemonDialogProps = Pick< onClose?: () => void onAfterClose?: () => void closeOnNavigate?: boolean + shouldAwaitSubmit?: boolean } export function LemonDialog({ @@ -37,12 +39,14 @@ export function LemonDialog({ content, initialFormValues, closeOnNavigate = true, + shouldAwaitSubmit = false, footer, ...props }: LemonDialogProps): JSX.Element { const [isOpen, setIsOpen] = useState(true) const { currentLocation } = useValues(router) const lastLocation = useRef(currentLocation.pathname) + const [isLoading, setIsLoading] = useState(false) primaryButton = primaryButton || @@ -63,8 +67,20 @@ export function LemonDialog({ { - button.onClick?.(e) + loading={button === primaryButton && shouldAwaitSubmit ? isLoading : undefined} + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onClick={async (e) => { + if (button === primaryButton && shouldAwaitSubmit) { + setIsLoading(true) + try { + // eslint-disable-next-line @typescript-eslint/await-thenable + await button.onClick?.(e) + } finally { + setIsLoading(false) + } + } else { + button.onClick?.(e) + } setIsOpen(false) }} /> @@ -117,7 +133,8 @@ export const LemonFormDialog = ({ type: 'primary', children: 'Submit', htmlType: 'submit', - onClick: () => void onSubmit(form), + // eslint-disable-next-line @typescript-eslint/no-misused-promises + onClick: props.shouldAwaitSubmit ? async () => await onSubmit(form) : () => void onSubmit(form), disabledReason: !isFormValid ? firstError : undefined, } diff --git a/frontend/src/lib/lemon-ui/Popover/Popover.tsx b/frontend/src/lib/lemon-ui/Popover/Popover.tsx index 94801efe09244..f14e507a99433 100644 --- a/frontend/src/lib/lemon-ui/Popover/Popover.tsx +++ b/frontend/src/lib/lemon-ui/Popover/Popover.tsx @@ -199,7 +199,7 @@ export const Popover = React.forwardRef(function P if (visible && referenceRef?.current && floatingElement) { return autoUpdate(referenceRef.current, floatingElement, update) } - }, [visible, referenceRef?.current, floatingElement, ...additionalRefs]) + }, [visible, placement, referenceRef?.current, floatingElement, ...additionalRefs]) const floatingContainer = useFloatingContainer() diff --git a/frontend/src/lib/lemon-ui/icons/categories.ts b/frontend/src/lib/lemon-ui/icons/categories.ts index 879673b1c94c1..1f07cd24ec25a 100644 --- a/frontend/src/lib/lemon-ui/icons/categories.ts +++ b/frontend/src/lib/lemon-ui/icons/categories.ts @@ -52,9 +52,18 @@ export const OBJECTS = { 'IconGearFilled', 'IconStack', 'IconSparkles', + 'IconPlug', 'IconPuzzle', ], - People: ['IconPeople', 'IconPeopleFilled', 'IconPerson', 'IconProfile', 'IconUser', 'IconGroups'], + People: [ + 'IconPeople', + 'IconPeopleFilled', + 'IconPerson', + 'IconProfile', + 'IconUser', + 'IconGroups', + 'IconShieldPeople', + ], 'Business & Finance': ['IconStore', 'IconCart', 'IconReceipt', 'IconPiggyBank', 'IconHandMoney'], Time: ['IconHourglass', 'IconCalendar', 'IconClock'], Nature: ['IconDay', 'IconNight', 'IconGlobe', 'IconCloud', 'IconBug'], @@ -183,6 +192,7 @@ export const TEAMS_AND_COMPANIES = { 'IconPageChart', 'IconSampling', 'IconLive', + 'IconRefresh', 'IconBadge', ], Replay: [ diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index 1264e4956a678..de0a796582003 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -14,6 +14,7 @@ import { TimeToSeeDataPayload } from 'lib/internalMetrics' import { isCoreFilter, PROPERTY_KEYS } from 'lib/taxonomy' import { objectClean } from 'lib/utils' import posthog from 'posthog-js' +import { Holdout } from 'scenes/experiments/holdoutsLogic' import { isFilterWithDisplay, isFunnelsFilter, isTrendsFilter } from 'scenes/insights/sharedUtils' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { EventIndex } from 'scenes/session-recordings/player/eventIndex' @@ -486,6 +487,14 @@ export const eventUsageLogic = kea([ reportExperimentResultsLoadingTimeout: (experimentId: ExperimentIdType) => ({ experimentId }), reportExperimentReleaseConditionsViewed: (experimentId: ExperimentIdType) => ({ experimentId }), reportExperimentReleaseConditionsUpdated: (experimentId: ExperimentIdType) => ({ experimentId }), + reportExperimentHoldoutCreated: (holdout: Holdout) => ({ holdout }), + reportExperimentHoldoutAssigned: ({ + experimentId, + holdoutId, + }: { + experimentId: ExperimentIdType + holdoutId: Holdout['id'] + }) => ({ experimentId, holdoutId }), // Definition Popover reportDataManagementDefinitionHovered: (type: TaxonomicFilterGroupType) => ({ type }), @@ -1082,6 +1091,19 @@ export const eventUsageLogic = kea([ experiment_id: experimentId, }) }, + reportExperimentHoldoutCreated: ({ holdout }) => { + posthog.capture('experiment holdout created', { + name: holdout.name, + holdout_id: holdout.id, + filters: holdout.filters, + }) + }, + reportExperimentHoldoutAssigned: ({ experimentId, holdoutId }) => { + posthog.capture('experiment holdout assigned', { + experiment_id: experimentId, + holdout_id: holdoutId, + }) + }, reportPropertyGroupFilterAdded: () => { posthog.capture('property group filter added') }, diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx index 55946477d9489..1f00712833e5a 100644 --- a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx +++ b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx @@ -348,7 +348,9 @@ export const LineGraph = (): JSX.Element => { }, ]} uppercaseHeader={false} - rowRibbonColor={(_datum, index) => getSeriesColor(index)} + rowRibbonColor={(_datum, index) => + ySeriesData[index]?.settings?.display?.color ?? getSeriesColor(index) + } showHeader />
    diff --git a/frontend/src/queries/nodes/DataVisualization/Components/seriesBreakdownLogic.test.ts b/frontend/src/queries/nodes/DataVisualization/Components/seriesBreakdownLogic.test.ts new file mode 100644 index 0000000000000..b57c0c8b89e87 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/seriesBreakdownLogic.test.ts @@ -0,0 +1,318 @@ +import { expectLogic } from 'kea-test-utils' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { DataVisualizationNode, NodeKind } from '~/queries/schema' +import { initKeaTests } from '~/test/init' +import { ChartDisplayType } from '~/types' + +import { dataNodeLogic } from '../../DataNode/dataNodeLogic' +import { dataVisualizationLogic, DataVisualizationLogicProps } from '../dataVisualizationLogic' +import { seriesBreakdownLogic } from './seriesBreakdownLogic' + +const testUniqueKey = 'testUniqueKey' + +const initialQuery: DataVisualizationNode = { + kind: NodeKind.DataVisualizationNode, + source: { + kind: NodeKind.HogQLQuery, + query: `select event, properties.$browser as browser, count() as total_count from events group by 1, 2`, + }, + tableSettings: { + columns: [ + { + column: 'event', + settings: { + formatting: { + prefix: '', + suffix: '', + }, + }, + }, + { + column: 'browser', + settings: { + formatting: { + prefix: '', + suffix: '', + }, + }, + }, + { + column: 'total_count', + settings: { + formatting: { + prefix: '', + suffix: '', + }, + }, + }, + ], + conditionalFormatting: [], + }, + chartSettings: { goalLines: undefined }, +} + +// globalQuery represents the query object that is passed into the data +// visualization logic and series breakdown logic it is modified by calls to +// setQuery so we want to ensure this is updated correctly +let globalQuery = { ...initialQuery } + +const dummyDataVisualizationLogicProps: DataVisualizationLogicProps = { + key: testUniqueKey, + query: globalQuery, + setQuery: (query) => { + globalQuery = query + }, + insightLogicProps: { + cachedInsight: null, + dashboardItemId: 'new-test-SQL', + }, +} + +describe('seriesBreakdownLogic', () => { + let logic: ReturnType + let builtDataVizLogic: ReturnType + + beforeEach(() => { + initKeaTests() + + // Mock prefersColorSchemeMedia to avoid TypeError + // (this is a known issue with jest and window.matchMedia) + // and is used by themeLogic + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + dispatchEvent: jest.fn(), + })), + }) + + featureFlagLogic.mount() + + // ensure we reset the globalQuery state before each test + globalQuery = { ...initialQuery } + + builtDataVizLogic = dataVisualizationLogic(dummyDataVisualizationLogicProps) + builtDataVizLogic.mount() + }) + + afterEach(() => { + logic?.unmount() + builtDataVizLogic?.unmount() + }) + + it('sets the correct values after mounting', async () => { + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: false, + selectedSeriesBreakdownColumn: null, + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: null, + }, + }) + }) + + // this was an example of a previous bug where the series breakdown logic + // would mount and override the existing query settings + it('does not override existing query settings after mounting', async () => { + // set visualization type to line graph and ensure this persists + builtDataVizLogic.actions.setVisualizationType(ChartDisplayType.ActionsLineGraph) + expect(globalQuery.display).toEqual(ChartDisplayType.ActionsLineGraph) + + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: false, + selectedSeriesBreakdownColumn: null, + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: null, + }, + display: ChartDisplayType.ActionsLineGraph, + }) + }) + + it('adds a series breakdown', async () => { + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + logic.actions.addSeriesBreakdown('test_column') + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: true, + selectedSeriesBreakdownColumn: 'test_column', + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: 'test_column', + }, + }) + }) + + it('adds a series breakdown after mount if one already selected in query', async () => { + builtDataVizLogic.actions.setQuery({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: 'test_column', + }, + }) + + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: true, + selectedSeriesBreakdownColumn: 'test_column', + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: 'test_column', + }, + }) + }) + + it('deletes a series breakdown', async () => { + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + logic.actions.addSeriesBreakdown('test_column') + + logic.actions.deleteSeriesBreakdown() + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: false, + selectedSeriesBreakdownColumn: null, + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + chartSettings: { + goalLines: undefined, + seriesBreakdownColumn: null, + }, + }) + }) + + it('deletes a series breakdown when clearAxis is called', async () => { + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + logic.actions.addSeriesBreakdown('test_column') + + logic.actions.clearAxis() + await expectLogic(logic).toMatchValues({ + showSeriesBreakdown: false, + selectedSeriesBreakdownColumn: null, + }) + + expect(globalQuery).toEqual({ + ...initialQuery, + tableSettings: { + columns: [], + conditionalFormatting: [], + }, + chartSettings: { + goalLines: undefined, + }, + }) + }) + + it('computes the correct data', async () => { + logic = seriesBreakdownLogic({ key: testUniqueKey }) + logic.mount() + + const builtDataNodeLogic = dataNodeLogic({ + key: testUniqueKey, + query: globalQuery.source, + }) + builtDataNodeLogic.mount() + builtDataNodeLogic.actions.setResponse({ + results: [ + ['signed_up', 'Safari', 11], + ['signed_up', 'Firefox', 22], + ['signed_up', 'Chrome', 59], + ['logged_out', 'Safari', 32], + ['downloaded_file', 'Firefox', 820], + ['logged_out', 'Chrome', 173], + ['downloaded_file', 'Chrome', 2218], + ['downloaded_file', 'Safari', 282], + ['logged_out', 'Firefox', 60], + ], + columns: ['event', 'browser', 'total_count'], + types: [ + ['event', 'String'], + ['browser', 'Nullable(String)'], + ['total_count', 'UInt64'], + ], + }) + + builtDataVizLogic.actions.updateXSeries('event') + + logic.actions.addSeriesBreakdown('browser') + + await expectLogic(logic).toMatchValues({ + breakdownColumnValues: ['Safari', 'Firefox', 'Chrome'], + seriesBreakdownData: { + xData: { + column: { + name: 'event', + type: { name: 'STRING', isNumerical: false }, + label: 'event - String', + dataIndex: 0, + }, + data: ['signed_up', 'logged_out', 'downloaded_file'], + }, + seriesData: [ + { + name: 'Safari', + data: [11, 32, 282], + settings: { + formatting: { prefix: '', suffix: '' }, + display: { displayType: undefined, yAxisPosition: undefined }, + }, + }, + { + name: 'Firefox', + data: [22, 60, 820], + settings: { + formatting: { prefix: '', suffix: '' }, + display: { displayType: undefined, yAxisPosition: undefined }, + }, + }, + { + name: 'Chrome', + data: [59, 173, 2218], + settings: { + formatting: { prefix: '', suffix: '' }, + display: { displayType: undefined, yAxisPosition: undefined }, + }, + }, + ], + isUnaggregated: false, + }, + }) + }) +}) diff --git a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx index 2f6a7ae53e1e9..37a8c5fce49c9 100644 --- a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx +++ b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx @@ -290,7 +290,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element { hasErrors ? error ?? 'Query has errors' : !isValidView - ? 'All fields must have an alias' + ? 'Some fields may need an alias' : '' } data-attr="hogql-query-editor-update-view" @@ -307,7 +307,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element { hasErrors ? error ?? 'Query has errors' : !isValidView - ? 'All fields must have an alias' + ? 'Some fields may need an alias' : '' } data-attr="hogql-query-editor-save-as-view" diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 805533b1f5c02..1da587c9fcda6 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -1089,6 +1089,10 @@ "content": { "type": "string" }, + "done": { + "description": "We only need this \"done\" value to tell when the particular message is finished during its streaming. It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added.", + "type": "boolean" + }, "type": { "const": "ai", "type": "string" @@ -6089,11 +6093,14 @@ "$ref": "#/definitions/HogQLQueryModifiers", "description": "Modifiers used when performing the query" }, + "name": { + "type": "string" + }, "response": { "$ref": "#/definitions/ExperimentFunnelsQueryResponse" } }, - "required": ["experiment_id", "funnels_query", "kind"], + "required": ["funnels_query", "kind"], "type": "object" }, "ExperimentFunnelsQueryResponse": { @@ -6184,11 +6191,14 @@ "$ref": "#/definitions/HogQLQueryModifiers", "description": "Modifiers used when performing the query" }, + "name": { + "type": "string" + }, "response": { "$ref": "#/definitions/ExperimentTrendsQueryResponse" } }, - "required": ["count_query", "experiment_id", "kind"], + "required": ["count_query", "kind"], "type": "object" }, "ExperimentTrendsQueryResponse": { @@ -6296,12 +6306,16 @@ "content": { "type": "string" }, + "done": { + "const": true, + "type": "boolean" + }, "type": { "const": "ai/failure", "type": "string" } }, - "required": ["type"], + "required": ["type", "done"], "type": "object" }, "FeaturePropertyFilter": { @@ -7618,12 +7632,17 @@ "content": { "type": "string" }, + "done": { + "const": true, + "description": "Human messages are only appended when done.", + "type": "boolean" + }, "type": { "const": "human", "type": "string" } }, - "required": ["type", "content"], + "required": ["type", "content", "done"], "type": "object" }, "InsightActorsQuery": { @@ -11687,12 +11706,17 @@ "content": { "type": "string" }, + "done": { + "const": true, + "description": "Router messages are not streamed, so they can only be done.", + "type": "boolean" + }, "type": { "const": "ai/router", "type": "string" } }, - "required": ["type", "content"], + "required": ["type", "content", "done"], "type": "object" }, "SamplingRate": { @@ -12837,6 +12861,9 @@ } ] }, + "done": { + "type": "boolean" + }, "plan": { "type": "string" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 4ea06bcbb8f79..a6a78a93cceb3 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -2020,17 +2020,19 @@ export type CachedExperimentFunnelsQueryResponse = CachedQueryResponse { kind: NodeKind.ExperimentFunnelsQuery + name?: string + experiment_id?: integer funnels_query: FunnelsQuery - experiment_id: integer } export interface ExperimentTrendsQuery extends DataNode { kind: NodeKind.ExperimentTrendsQuery + name?: string + experiment_id?: integer count_query: TrendsQuery // Defaults to $feature_flag_called if not specified // https://github.com/PostHog/posthog/blob/master/posthog/hogql_queries/experiments/experiment_trends_query_runner.py exposure_query?: TrendsQuery - experiment_id: integer } /** @@ -2478,11 +2480,18 @@ export enum AssistantMessageType { export interface HumanMessage { type: AssistantMessageType.Human content: string + /** Human messages are only appended when done. */ + done: true } export interface AssistantMessage { type: AssistantMessageType.Assistant content: string + /** + * We only need this "done" value to tell when the particular message is finished during its streaming. + * It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added. + */ + done?: boolean } export interface VisualizationMessage { @@ -2490,16 +2499,20 @@ export interface VisualizationMessage { plan?: string reasoning_steps?: string[] | null answer?: AssistantTrendsQuery | AssistantFunnelsQuery + done?: boolean } export interface FailureMessage { type: AssistantMessageType.Failure content?: string + done: true } export interface RouterMessage { type: AssistantMessageType.Router content: string + /** Router messages are not streamed, so they can only be done. */ + done: true } export type RootAssistantMessage = diff --git a/frontend/src/scenes/billing/CreditCTAHero.tsx b/frontend/src/scenes/billing/CreditCTAHero.tsx index 0f076245c0a48..85742aa8ef1d1 100644 --- a/frontend/src/scenes/billing/CreditCTAHero.tsx +++ b/frontend/src/scenes/billing/CreditCTAHero.tsx @@ -2,19 +2,25 @@ import { IconX } from '@posthog/icons' import { LemonButton, LemonDivider } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { BurningMoneyHog } from 'lib/components/hedgehogs' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import useResizeObserver from 'use-resize-observer' import { billingLogic } from './billingLogic' import { PurchaseCreditsModal } from './PurchaseCreditsModal' +export const DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD = 500 + export const CreditCTAHero = (): JSX.Element | null => { const { width, ref: heroRef } = useResizeObserver() + const { featureFlags } = useValues(featureFlagLogic) const { creditOverview, isPurchaseCreditsModalOpen, isCreditCTAHeroDismissed, computedDiscount } = useValues(billingLogic) const { showPurchaseCreditsModal, toggleCreditCTAHeroDismissed } = useActions(billingLogic) - if (!creditOverview.eligible || creditOverview.status === 'paid') { + const isEligible = creditOverview.eligible || featureFlags[FEATURE_FLAGS.SELF_SERVE_CREDIT_OVERRIDE] + if (creditOverview.status === 'paid' || !isEligible) { return null } @@ -37,6 +43,8 @@ export const CreditCTAHero = (): JSX.Element | null => { ) } + const estimatedMonthlyCreditAmountUsd = + creditOverview?.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD return (
    {
    )}
    - {creditOverview.eligible && creditOverview.status === 'pending' && ( + {isEligible && creditOverview.status === 'pending' && ( <>

    We're applying your credits

    @@ -78,7 +86,7 @@ export const CreditCTAHero = (): JSX.Element | null => { )} )} - {creditOverview.eligible && creditOverview.status === 'none' && ( + {isEligible && (!creditOverview || creditOverview.status === 'none') && ( <>

    Stop burning money.{' '} @@ -87,20 +95,20 @@ export const CreditCTAHero = (): JSX.Element | null => {

    Based on your usage, your monthly bill is forecasted to be an average of{' '} - ${creditOverview.estimated_monthly_credit_amount_usd.toFixed(0)}/month over - the next year. + ${estimatedMonthlyCreditAmountUsd.toFixed(0)}/month over the next year.

    This qualifies you for a {computedDiscount * 100}% discount by pre-purchasing usage credits. Which gives you a net savings of{' '} $ - {Math.round( - creditOverview.estimated_monthly_credit_amount_usd * computedDiscount * 12 - ).toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - })} + {Math.round(estimatedMonthlyCreditAmountUsd * computedDiscount * 12).toLocaleString( + 'en-US', + { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + } + )} {' '} over the next year.

    diff --git a/frontend/src/scenes/billing/PaymentEntryModal.tsx b/frontend/src/scenes/billing/PaymentEntryModal.tsx index 52092c47d0ee7..38fc535191fd5 100644 --- a/frontend/src/scenes/billing/PaymentEntryModal.tsx +++ b/frontend/src/scenes/billing/PaymentEntryModal.tsx @@ -71,18 +71,22 @@ export const PaymentEntryModal = ({ const [stripePromise, setStripePromise] = useState(null) useEffect(() => { - // Load Stripe.js asynchronously - const loadStripeJs = async (): Promise => { - const { loadStripe } = await stripeJs() - const publicKey = window.STRIPE_PUBLIC_KEY! - setStripePromise(await loadStripe(publicKey)) + // Only load Stripe.js when the modal is opened + if (paymentEntryModalOpen && !stripePromise) { + const loadStripeJs = async (): Promise => { + const { loadStripe } = await stripeJs() + const publicKey = window.STRIPE_PUBLIC_KEY! + setStripePromise(await loadStripe(publicKey)) + } + void loadStripeJs() } - void loadStripeJs() - }, []) + }, [paymentEntryModalOpen, stripePromise]) useEffect(() => { - initiateAuthorization(redirectPath) - }, [initiateAuthorization, redirectPath]) + if (paymentEntryModalOpen) { + initiateAuthorization(redirectPath) + } + }, [paymentEntryModalOpen, initiateAuthorization, redirectPath]) return ( { @@ -16,6 +17,8 @@ export const PurchaseCreditsModal = (): JSX.Element | null => { const { openSupportForm } = useActions(supportLogic) const creditInputValue: number = +creditForm.creditInput || 0 + const estimatedMonthlyCreditAmountUsd = + creditOverview.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD return ( showPurchaseCreditsModal(false)} @@ -56,7 +59,7 @@ export const PurchaseCreditsModal = (): JSX.Element | null => { Based on your usage, we think you'll use{' '} $ - {(+creditOverview.estimated_monthly_credit_amount_usd).toLocaleString('en-US', { + {(+estimatedMonthlyCreditAmountUsd).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0, })} @@ -64,7 +67,7 @@ export const PurchaseCreditsModal = (): JSX.Element | null => { of credits per month, for a total of{' '} $ - {(+creditOverview.estimated_monthly_credit_amount_usd * 12).toLocaleString('en-US', { + {(+estimatedMonthlyCreditAmountUsd * 12).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0, })} diff --git a/frontend/src/scenes/billing/billingLogic.tsx b/frontend/src/scenes/billing/billingLogic.tsx index 4db25f0fc360f..ac78f13424b72 100644 --- a/frontend/src/scenes/billing/billingLogic.tsx +++ b/frontend/src/scenes/billing/billingLogic.tsx @@ -18,6 +18,7 @@ import { userLogic } from 'scenes/userLogic' import { BillingPlanType, BillingProductV2Type, BillingType, ProductKey } from '~/types' import type { billingLogicType } from './billingLogicType' +import { DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD } from './CreditCTAHero' export const ALLOCATION_THRESHOLD_ALERT = 0.85 // Threshold to show warning of event usage near limit export const ALLOCATION_THRESHOLD_BLOCK = 1.2 // Threshold to block usage @@ -325,7 +326,7 @@ export const billingLogic = kea([ creditOverview: [ { eligible: false, - estimated_monthly_credit_amount_usd: 0, + estimated_monthly_credit_amount_usd: DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD, status: 'none', invoice_url: null, collection_method: null, @@ -340,7 +341,10 @@ export const billingLogic = kea([ if (!values.creditForm.creditInput) { actions.setCreditFormValue( 'creditInput', - Math.round(response.estimated_monthly_credit_amount_usd * 12) + Math.round( + (response.estimated_monthly_credit_amount_usd || + DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD) * 12 + ) ) } @@ -352,7 +356,7 @@ export const billingLogic = kea([ // Return default values if not subscribed return { eligible: false, - estimated_monthly_credit_amount_usd: 0, + estimated_monthly_credit_amount_usd: DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD, status: 'none', invoice_url: null, collection_method: null, @@ -531,7 +535,8 @@ export const billingLogic = kea([ posthog.capture('credits cta shown', { eligible: creditOverview.eligible, status: creditOverview.status, - estimated_monthly_credit_amount_usd: creditOverview.estimated_monthly_credit_amount_usd, + estimated_monthly_credit_amount_usd: + creditOverview.estimated_monthly_credit_amount_usd || DEFAULT_ESTIMATED_MONTHLY_CREDIT_AMOUNT_USD, }) }, toggleCreditCTAHeroDismissed: ({ isDismissed }) => { diff --git a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx index 1b36477047ccf..3576303ebddd9 100644 --- a/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx +++ b/frontend/src/scenes/data-warehouse/editor/EditorScene.tsx @@ -1,14 +1,23 @@ -import { BindLogic } from 'kea' +import { IconArrowLeft } from '@posthog/icons' +import { BindLogic, useActions, useValues } from 'kea' +import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' +import { DatabaseTableTree } from 'lib/components/DatabaseTableTree/DatabaseTableTree' +import { LemonButton } from 'lib/lemon-ui/LemonButton' import { useRef } from 'react' +import { Sidebar } from '~/layout/navigation-3000/components/Sidebar' +import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic' + +import { editorSceneLogic } from './editorSceneLogic' import { editorSizingLogic } from './editorSizingLogic' import { QueryWindow } from './QueryWindow' -import { SourceNavigator } from './SourceNavigator' export function EditorScene(): JSX.Element { const ref = useRef(null) const navigatorRef = useRef(null) const queryPaneRef = useRef(null) + const { activeNavbarItem } = useValues(navigation3000Logic) + const { sidebarOverlayOpen } = useValues(editorSceneLogic) const editorSizingLogicProps = { editorSceneRef: ref, @@ -28,9 +37,41 @@ export function EditorScene(): JSX.Element { return (
    - + {activeNavbarItem && ( + } + sidebarOverlayProps={{ isOpen: sidebarOverlayOpen }} + /> + )}
    ) } + +const EditorSidebarOverlay = (): JSX.Element => { + const { setSidebarOverlayOpen } = useActions(editorSceneLogic) + const { sidebarOverlayTreeItems, selectedSchema } = useValues(editorSceneLogic) + + return ( +
    +
    + } onClick={() => setSidebarOverlayOpen(false)} /> + {selectedSchema?.name && ( + + {selectedSchema?.name} + + )} +
    + +
    + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx index 8d28cf576145c..10e36c436e739 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryPane.tsx @@ -1,6 +1,5 @@ import { useValues } from 'kea' import { Resizer } from 'lib/components/Resizer/Resizer' -import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { CodeEditor, CodeEditorProps } from 'lib/monaco/CodeEditor' import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' @@ -16,43 +15,44 @@ export function QueryPane(props: QueryPaneProps): JSX.Element { const { queryPaneHeight, queryPaneResizerProps } = useValues(editorSizingLogic) return ( -
    -
    - {props.promptError ? {props.promptError} : null} - - {({ height, width }) => ( - - )} - + <> +
    +
    + + {({ height, width }) => ( + + )} + +
    +
    - -
    + ) } diff --git a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx index b49acba958440..35a41c0f402b7 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx @@ -1,41 +1,42 @@ import { IconPlus, IconX } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' import clsx from 'clsx' -import { Uri } from 'monaco-editor' + +import { QueryTab } from './multitabEditorLogic' interface QueryTabsProps { - models: Uri[] - onClick: (model: Uri) => void - onClear: (model: Uri) => void + models: QueryTab[] + onClick: (model: QueryTab) => void + onClear: (model: QueryTab) => void onAdd: () => void - activeModelUri: Uri | null + activeModelUri: QueryTab | null } export function QueryTabs({ models, onClear, onClick, onAdd, activeModelUri }: QueryTabsProps): JSX.Element { return ( -
    - {models.map((model: Uri) => ( - + {models.map((model: QueryTab) => ( + 1 ? onClear : undefined} onClick={onClick} - active={activeModelUri?.path === model.path} + active={activeModelUri?.uri.path === model.uri.path} /> ))} - } /> + onAdd()} icon={} />
    ) } interface QueryTabProps { - model: Uri - onClick: (model: Uri) => void - onClear?: (model: Uri) => void + model: QueryTab + onClick: (model: QueryTab) => void + onClear?: (model: QueryTab) => void active: boolean } -function QueryTab({ model, active, onClear, onClick }: QueryTabProps): JSX.Element { +function QueryTabComponent({ model, active, onClear, onClick }: QueryTabProps): JSX.Element { return (
    diff --git a/frontend/src/scenes/data-warehouse/editor/ResultPane.tsx b/frontend/src/scenes/data-warehouse/editor/ResultPane.tsx index 40dfee342d270..ac7915651296a 100644 --- a/frontend/src/scenes/data-warehouse/editor/ResultPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/ResultPane.tsx @@ -1,14 +1,19 @@ import 'react-data-grid/lib/styles.css' import { LemonButton, LemonTabs, Spinner } from '@posthog/lemon-ui' -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' +import { router } from 'kea-router' import { useMemo } from 'react' import DataGrid from 'react-data-grid' +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' import { themeLogic } from '~/layout/navigation-3000/themeLogic' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' import { NodeKind } from '~/queries/schema' +import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' +import { multitabEditorLogic } from './multitabEditorLogic' + enum ResultsTab { Results = 'results', Visualization = 'visualization', @@ -29,6 +34,13 @@ export function ResultPane({ logicKey, query, }: ResultPaneProps): JSX.Element { + const codeEditorKey = `hogQLQueryEditor/${router.values.location.pathname}` + + const { editingView, queryInput } = useValues( + multitabEditorLogic({ + key: codeEditorKey, + }) + ) const { isDarkModeOn } = useValues(themeLogic) const { response, responseLoading } = useValues( dataNodeLogic({ @@ -40,6 +52,8 @@ export function ResultPane({ doNotLoad: !query, }) ) + const { dataWarehouseSavedQueriesLoading } = useValues(dataWarehouseViewsLogic) + const { updateDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic) const columns = useMemo(() => { return ( @@ -78,11 +92,32 @@ export function ResultPane({ ]} />
    - onSave()} disabledReason={saveDisabledReason}> - Save - - onQueryInputChange()}> - Run + {editingView ? ( + <> + + updateDataWarehouseSavedQuery({ + id: editingView.id, + query: { + kind: NodeKind.HogQLQuery, + query: queryInput, + }, + }) + } + > + Update + + + ) : ( + onSave()} disabledReason={saveDisabledReason}> + Save + + )} + onQueryInputChange()}> + Run +
    diff --git a/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx b/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx deleted file mode 100644 index ca9f4991245bf..0000000000000 --- a/frontend/src/scenes/data-warehouse/editor/SourceNavigator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useValues } from 'kea' -import { Resizer } from 'lib/components/Resizer/Resizer' - -import { DatabaseTableTreeWithItems } from '../external/DataWarehouseTables' -import { editorSizingLogic } from './editorSizingLogic' -import { SchemaSearch } from './SchemaSearch' - -export function SourceNavigator(): JSX.Element { - const { sourceNavigatorWidth, sourceNavigatorResizerProps } = useValues(editorSizingLogic) - - return ( -
    - - - -
    - ) -} diff --git a/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts new file mode 100644 index 0000000000000..175183d6020c7 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/editorSceneLogic.ts @@ -0,0 +1,56 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { TreeItem } from 'lib/components/DatabaseTableTree/DatabaseTableTree' + +import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema' +import { DataWarehouseSavedQuery } from '~/types' + +import type { editorSceneLogicType } from './editorSceneLogicType' + +export const editorSceneLogic = kea([ + path(['scenes', 'data-warehouse', 'editor', 'editorSceneLogic']), + actions({ + setSidebarOverlayOpen: (isOpen: boolean) => ({ isOpen }), + selectSchema: (schema: DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery) => ({ + schema, + }), + }), + reducers({ + sidebarOverlayOpen: [ + false, + { + setSidebarOverlayOpen: (_, { isOpen }) => isOpen, + selectSchema: (_, { schema }) => schema !== null, + }, + ], + selectedSchema: [ + null as DatabaseSchemaDataWarehouseTable | DatabaseSchemaTable | DataWarehouseSavedQuery | null, + { + selectSchema: (_, { schema }) => schema, + }, + ], + }), + selectors({ + sidebarOverlayTreeItems: [ + (s) => [s.selectedSchema], + (selectedSchema): TreeItem[] => { + if (selectedSchema === null) { + return [] + } + if ('fields' in selectedSchema) { + return Object.values(selectedSchema.fields).map((field) => ({ + name: field.name, + type: field.type, + })) + } + + if ('columns' in selectedSchema) { + return Object.values(selectedSchema.columns).map((column) => ({ + name: column.name, + type: column.type, + })) + } + return [] + }, + ], + }), +]) diff --git a/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts new file mode 100644 index 0000000000000..8239bd166551d --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts @@ -0,0 +1,203 @@ +import Fuse from 'fuse.js' +import { connect, kea, path, selectors } from 'kea' +import { router } from 'kea-router' +import { subscriptions } from 'kea-subscriptions' +import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' +import { sceneLogic } from 'scenes/sceneLogic' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { navigation3000Logic } from '~/layout/navigation-3000/navigationLogic' +import { FuseSearchMatch } from '~/layout/navigation-3000/sidebars/utils' +import { SidebarCategory } from '~/layout/navigation-3000/types' +import { DatabaseSchemaDataWarehouseTable, DatabaseSchemaTable } from '~/queries/schema' +import { DataWarehouseSavedQuery, PipelineTab } from '~/types' + +import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' +import { editorSceneLogic } from './editorSceneLogic' +import type { editorSidebarLogicType } from './editorSidebarLogicType' +import { multitabEditorLogic } from './multitabEditorLogic' + +const dataWarehouseTablesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +const posthogTablesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +const savedQueriesfuse = new Fuse([], { + keys: [{ name: 'name', weight: 2 }], + threshold: 0.3, + ignoreLocation: true, + includeMatches: true, +}) + +export const editorSidebarLogic = kea([ + path(['data-warehouse', 'editor', 'editorSidebarLogic']), + connect({ + values: [ + sceneLogic, + ['activeScene', 'sceneParams'], + dataWarehouseViewsLogic, + ['dataWarehouseSavedQueries', 'dataWarehouseSavedQueryMapById', 'dataWarehouseSavedQueriesLoading'], + databaseTableListLogic, + ['posthogTables', 'dataWarehouseTables', 'databaseLoading', 'views', 'viewsMapById'], + ], + actions: [editorSceneLogic, ['selectSchema'], dataWarehouseViewsLogic, ['deleteDataWarehouseSavedQuery']], + }), + selectors(({ actions }) => ({ + contents: [ + (s) => [ + s.relevantSavedQueries, + s.dataWarehouseSavedQueriesLoading, + s.relevantPosthogTables, + s.relevantDataWarehouseTables, + s.databaseLoading, + ], + ( + relevantSavedQueries, + dataWarehouseSavedQueriesLoading, + relevantPosthogTables, + relevantDataWarehouseTables, + databaseLoading + ) => [ + { + key: 'data-warehouse-sources', + noun: ['source', 'external source'], + loading: databaseLoading, + items: relevantDataWarehouseTables.map(([table, matches]) => ({ + key: table.id, + name: table.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(table) + }, + })), + onAdd: () => { + router.actions.push(urls.pipeline(PipelineTab.Sources)) + }, + } as SidebarCategory, + { + key: 'data-warehouse-tables', + noun: ['table', 'tables'], + loading: databaseLoading, + items: relevantPosthogTables.map(([table, matches]) => ({ + key: table.id, + name: table.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(table) + }, + })), + } as SidebarCategory, + { + key: 'data-warehouse-views', + noun: ['view', 'views'], + loading: dataWarehouseSavedQueriesLoading, + items: relevantSavedQueries.map(([savedQuery, matches]) => ({ + key: savedQuery.id, + name: savedQuery.name, + url: '', + searchMatch: matches + ? { + matchingFields: matches.map((match) => match.key), + nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices, + } + : null, + onClick: () => { + actions.selectSchema(savedQuery) + }, + menuItems: [ + { + label: 'Edit view definition', + onClick: () => { + multitabEditorLogic({ + key: `hogQLQueryEditor/${router.values.location.pathname}`, + }).actions.createTab(savedQuery.query.query, savedQuery) + }, + }, + { + label: 'Delete', + status: 'danger', + onClick: () => { + actions.deleteDataWarehouseSavedQuery(savedQuery.id) + }, + }, + ], + })), + } as SidebarCategory, + ], + ], + activeListItemKey: [ + (s) => [s.activeScene, s.sceneParams], + (activeScene, sceneParams): [string, number] | null => { + return activeScene === Scene.DataWarehouse && sceneParams.params.id + ? ['saved-queries', parseInt(sceneParams.params.id)] + : null + }, + ], + relevantDataWarehouseTables: [ + (s) => [s.dataWarehouseTables, navigation3000Logic.selectors.searchTerm], + (dataWarehouseTables, searchTerm): [DatabaseSchemaDataWarehouseTable, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return dataWarehouseTablesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return dataWarehouseTables.map((table) => [table, null]) + }, + ], + relevantPosthogTables: [ + (s) => [s.posthogTables, navigation3000Logic.selectors.searchTerm], + (posthogTables, searchTerm): [DatabaseSchemaTable, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return posthogTablesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return posthogTables.map((table) => [table, null]) + }, + ], + relevantSavedQueries: [ + (s) => [s.dataWarehouseSavedQueries, navigation3000Logic.selectors.searchTerm], + (dataWarehouseSavedQueries, searchTerm): [DataWarehouseSavedQuery, FuseSearchMatch[] | null][] => { + if (searchTerm) { + return savedQueriesfuse + .search(searchTerm) + .map((result) => [result.item, result.matches as FuseSearchMatch[]]) + } + return dataWarehouseSavedQueries.map((savedQuery) => [savedQuery, null]) + }, + ], + })), + subscriptions({ + dataWarehouseTables: (dataWarehouseTables) => { + dataWarehouseTablesfuse.setCollection(dataWarehouseTables) + }, + posthogTables: (posthogTables) => { + posthogTablesfuse.setCollection(posthogTables) + }, + dataWarehouseSavedQueries: (dataWarehouseSavedQueries) => { + savedQueriesfuse.setCollection(dataWarehouseSavedQueries) + }, + }), +]) diff --git a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx index 7a4a3d4e84e9c..cad1c656c0b91 100644 --- a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx @@ -1,6 +1,6 @@ import { Monaco } from '@monaco-editor/react' -import { LemonDialog, LemonInput } from '@posthog/lemon-ui' -import { actions, kea, listeners, path, props, propsChanged, reducers, selectors } from 'kea' +import { LemonDialog, LemonInput, lemonToast } from '@posthog/lemon-ui' +import { actions, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' import { subscriptions } from 'kea-subscriptions' import { LemonField } from 'lib/lemon-ui/LemonField' import { ModelMarker } from 'lib/monaco/codeEditorLogic' @@ -9,6 +9,7 @@ import { editor, MarkerSeverity, Uri } from 'monaco-editor' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' import { performQuery } from '~/queries/query' import { HogLanguage, HogQLMetadata, HogQLMetadataResponse, HogQLNotice, HogQLQuery, NodeKind } from '~/queries/schema' +import { DataWarehouseSavedQuery } from '~/types' import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' import type { multitabEditorLogicType } from './multitabEditorLogicType' @@ -22,29 +23,41 @@ export interface MultitabEditorLogicProps { export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries` export const activemodelStateKey = (key: string | number): string => `${key}/activeModelUri` +export interface QueryTab { + uri: Uri + view?: DataWarehouseSavedQuery +} + export const multitabEditorLogic = kea([ path(['data-warehouse', 'editor', 'multitabEditorLogic']), props({} as MultitabEditorLogicProps), + key((props) => props.key), + connect({ + actions: [ + dataWarehouseViewsLogic, + ['deleteDataWarehouseSavedQuerySuccess', 'createDataWarehouseSavedQuerySuccess'], + ], + }), actions({ setQueryInput: (queryInput: string) => ({ queryInput }), updateState: true, runQuery: (queryOverride?: string) => ({ queryOverride }), setActiveQuery: (query: string) => ({ query }), - setTabs: (tabs: Uri[]) => ({ tabs }), - addTab: (tab: Uri) => ({ tab }), - createTab: () => null, - deleteTab: (tab: Uri) => ({ tab }), - removeTab: (tab: Uri) => ({ tab }), - selectTab: (tab: Uri) => ({ tab }), + setTabs: (tabs: QueryTab[]) => ({ tabs }), + addTab: (tab: QueryTab) => ({ tab }), + createTab: (query?: string, view?: DataWarehouseSavedQuery) => ({ query, view }), + deleteTab: (tab: QueryTab) => ({ tab }), + removeTab: (tab: QueryTab) => ({ tab }), + selectTab: (tab: QueryTab) => ({ tab }), setLocalState: (key: string, value: any) => ({ key, value }), initialize: true, saveAsView: true, - saveAsViewSuccess: (name: string) => ({ name }), + saveAsViewSubmit: (name: string) => ({ name }), reloadMetadata: true, setMetadata: (query: string, metadata: HogQLMetadataResponse) => ({ query, metadata }), }), - propsChanged(({ actions }, oldProps) => { - if (!oldProps.monaco && !oldProps.editor) { + propsChanged(({ actions, props }, oldProps) => { + if (!oldProps.monaco && !oldProps.editor && props.monaco && props.editor) { actions.initialize() } }), @@ -62,20 +75,26 @@ export const multitabEditorLogic = kea([ }, ], activeModelUri: [ - null as Uri | null, + null as QueryTab | null, { selectTab: (_, { tab }) => tab, }, ], + editingView: [ + null as DataWarehouseSavedQuery | null, + { + selectTab: (_, { tab }) => tab.view ?? null, + }, + ], allTabs: [ - [] as Uri[], + [] as QueryTab[], { addTab: (state, { tab }) => { const newTabs = [...state, tab] return newTabs }, removeTab: (state, { tab: tabToRemove }) => { - const newModels = state.filter((tab) => tab.toString() !== tabToRemove.toString()) + const newModels = state.filter((tab) => tab.uri.toString() !== tabToRemove.uri.toString()) return newModels }, setTabs: (_, { tabs }) => tabs, @@ -130,25 +149,32 @@ export const multitabEditorLogic = kea([ }, ], })), - listeners(({ values, props, actions }) => ({ - createTab: () => { + listeners(({ values, props, actions, asyncActions }) => ({ + createTab: ({ query = '', view }) => { let currentModelCount = 1 - const allNumbers = values.allTabs.map((tab) => parseInt(tab.path.split('/').pop() || '0')) + const allNumbers = values.allTabs.map((tab) => parseInt(tab.uri.path.split('/').pop() || '0')) while (allNumbers.includes(currentModelCount)) { currentModelCount++ } if (props.monaco) { const uri = props.monaco.Uri.parse(currentModelCount.toString()) - const model = props.monaco.editor.createModel('', 'hogQL', uri) + const model = props.monaco.editor.createModel(query, 'hogQL', uri) props.editor?.setModel(model) - actions.addTab(uri) - actions.selectTab(uri) + actions.addTab({ + uri, + view, + }) + actions.selectTab({ + uri, + view, + }) const queries = values.allTabs.map((tab) => { return { - query: props.monaco?.editor.getModel(tab)?.getValue() || '', - path: tab.path.split('/').pop(), + query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', + path: tab.uri.path.split('/').pop(), + view: uri.path === tab.uri.path ? view : tab.view, } }) actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) @@ -156,18 +182,20 @@ export const multitabEditorLogic = kea([ }, selectTab: ({ tab }) => { if (props.monaco) { - const model = props.monaco.editor.getModel(tab) + const model = props.monaco.editor.getModel(tab.uri) props.editor?.setModel(model) } - const path = tab.path.split('/').pop() + const path = tab.uri.path.split('/').pop() path && actions.setLocalState(activemodelStateKey(props.key), path) }, deleteTab: ({ tab: tabToRemove }) => { if (props.monaco) { - const model = props.monaco.editor.getModel(tabToRemove) - if (tabToRemove == values.activeModelUri) { - const indexOfModel = values.allTabs.findIndex((tab) => tab.toString() === tabToRemove.toString()) + const model = props.monaco.editor.getModel(tabToRemove.uri) + if (tabToRemove.uri.toString() === values.activeModelUri?.uri.toString()) { + const indexOfModel = values.allTabs.findIndex( + (tab) => tab.uri.toString() === tabToRemove.uri.toString() + ) const nextModel = values.allTabs[indexOfModel + 1] || values.allTabs[indexOfModel - 1] || values.allTabs[0] // there will always be one actions.selectTab(nextModel) @@ -176,8 +204,9 @@ export const multitabEditorLogic = kea([ actions.removeTab(tabToRemove) const queries = values.allTabs.map((tab) => { return { - query: props.monaco?.editor.getModel(tab)?.getValue() || '', - path: tab.path.split('/').pop(), + query: props.monaco?.editor.getModel(tab.uri)?.getValue() || '', + path: tab.uri.path.split('/').pop(), + view: tab.view, } }) actions.setLocalState(editorModelsStateKey(props.key), JSON.stringify(queries)) @@ -197,14 +226,17 @@ export const multitabEditorLogic = kea([ }) const models = JSON.parse(allModelQueries || '[]') - const newModels: Uri[] = [] + const newModels: QueryTab[] = [] models.forEach((model: Record) => { if (props.monaco) { const uri = props.monaco.Uri.parse(model.path) const newModel = props.monaco.editor.createModel(model.query, 'hogQL', uri) props.editor?.setModel(newModel) - newModels.push(uri) + newModels.push({ + uri, + view: model.view, + }) } }) @@ -221,9 +253,17 @@ export const multitabEditorLogic = kea([ actions.setQueryInput(val) actions.runQuery() } - uri && actions.selectTab(uri) + const activeView = newModels.find((tab) => tab.uri.path.split('/').pop() === activeModelUri)?.view + + uri && + actions.selectTab({ + uri, + view: activeView, + }) } else if (newModels.length) { - actions.selectTab(newModels[0]) + actions.selectTab({ + uri: newModels[0].uri, + }) } } else { const model = props.editor?.getModel() @@ -240,13 +280,23 @@ export const multitabEditorLogic = kea([ await breakpoint(100) const queries = values.allTabs.map((model) => { return { - query: props.monaco?.editor.getModel(model)?.getValue() || '', - path: model.path.split('/').pop(), + query: props.monaco?.editor.getModel(model.uri)?.getValue() || '', + path: model.uri.path.split('/').pop(), + view: model.view, } }) localStorage.setItem(editorModelsStateKey(props.key), JSON.stringify(queries)) }, runQuery: ({ queryOverride }) => { + if (values.activeQuery === queryOverride || values.activeQuery === values.queryInput) { + dataNodeLogic({ + key: values.activeTabKey, + query: { + kind: NodeKind.HogQLQuery, + query: queryOverride || values.queryInput, + }, + }).actions.loadData(true) + } actions.setActiveQuery(queryOverride || values.queryInput) }, saveAsView: async () => { @@ -261,10 +311,13 @@ export const multitabEditorLogic = kea([ errors: { viewName: (name) => (!name ? 'You must enter a name' : undefined), }, - onSubmit: ({ viewName }) => actions.saveAsViewSuccess(viewName), + onSubmit: async ({ viewName }) => { + await asyncActions.saveAsViewSubmit(viewName) + }, + shouldAwaitSubmit: true, }) }, - saveAsViewSuccess: async ({ name }) => { + saveAsViewSubmit: async ({ name }) => { const query: HogQLQuery = { kind: NodeKind.HogQLQuery, query: values.queryInput, @@ -290,11 +343,34 @@ export const multitabEditorLogic = kea([ breakpoint() actions.setMetadata(query, response) }, + deleteDataWarehouseSavedQuerySuccess: ({ payload: viewId }) => { + const tabToRemove = values.allTabs.find((tab) => tab.view?.id === viewId) + if (tabToRemove) { + actions.deleteTab(tabToRemove) + } + lemonToast.success('View deleted') + }, + createDataWarehouseSavedQuerySuccess: ({ dataWarehouseSavedQueries, payload: view }) => { + const newView = view && dataWarehouseSavedQueries.find((v) => v.name === view.name) + if (newView) { + const newTabs = values.allTabs.map((tab) => ({ + ...tab, + view: tab.uri.path === values.activeModelUri?.uri.path ? newView : tab.view, + })) + const newTab = newTabs.find((tab) => tab.uri.path === values.activeModelUri?.uri.path) + actions.setTabs(newTabs) + newTab && actions.selectTab(newTab) + actions.updateState() + } + }, + updateDataWarehouseSavedQuerySuccess: () => { + lemonToast.success('View updated') + }, })), subscriptions(({ props, actions, values }) => ({ activeModelUri: (activeModelUri) => { if (props.monaco) { - const _model = props.monaco.editor.getModel(activeModelUri) + const _model = props.monaco.editor.getModel(activeModelUri.uri) const val = _model?.getValue() actions.setQueryInput(val ?? '') actions.runQuery() @@ -313,7 +389,7 @@ export const multitabEditorLogic = kea([ }, })), selectors({ - activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.path}`], + activeTabKey: [(s) => [s.activeModelUri], (activeModelUri) => `hogQLQueryEditor/${activeModelUri?.uri.path}`], isValidView: [(s) => [s.metadata], (metadata) => !!(metadata && metadata[1]?.isValidView)], hasErrors: [ (s) => [s.modelMarkers], diff --git a/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts b/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts deleted file mode 100644 index 406ae6e0d4ea2..0000000000000 --- a/frontend/src/scenes/data-warehouse/editor/sourceNavigatorLogic.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { kea } from 'kea' - -import type { sourceNavigatorLogicType } from './sourceNavigatorLogicType' - -export const sourceNavigatorLogic = kea({ - path: ['scenes', 'data-warehouse', 'editor', 'sourceNavigatorLogic'], - actions: { - setWidth: (width: number) => ({ width }), - }, - reducers: { - navigatorWidth: [ - 200, - { - setWidth: (_, { width }: { width: number }) => width, - }, - ], - }, -}) diff --git a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx index 10df0ed6c1f8b..37c744e633d9b 100644 --- a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx +++ b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx @@ -46,7 +46,7 @@ export const dataWarehouseViewsLogic = kea([ await api.dataWarehouseSavedQueries.delete(viewId) return values.dataWarehouseSavedQueries.filter((view) => view.id !== viewId) }, - updateDataWarehouseSavedQuery: async (view: DatabaseSchemaViewTable) => { + updateDataWarehouseSavedQuery: async (view: Partial & { id: string }) => { const newView = await api.dataWarehouseSavedQueries.update(view.id, view) return values.dataWarehouseSavedQueries.map((savedQuery) => { if (savedQuery.id === view.id) { diff --git a/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx b/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx index e4e36cdc54ee6..8df7e27c760d7 100644 --- a/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx +++ b/frontend/src/scenes/data-warehouse/settings/source/SourceConfiguration.tsx @@ -43,7 +43,7 @@ function UpdateSourceConnectionFormContainer(props: UpdateSourceConnectionFormCo <> Overwrite your existing configuration here
    - +
    { const { experiment, featureFlags, groupTypes, aggregationLabel, dynamicFeatureFlagKey } = useValues(experimentLogic) - const { - addExperimentGroup, - removeExperimentGroup, - setExperiment, - setNewExperimentInsight, - createExperiment, - setExperimentType, - } = useActions(experimentLogic) + const { addExperimentGroup, removeExperimentGroup, setExperiment, createExperiment, setExperimentType } = + useActions(experimentLogic) const { webExperimentsAvailable } = useValues(experimentsLogic) - return ( + return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? ( + + ) : (
    @@ -130,7 +127,6 @@ const ExperimentFormFields = (): JSX.Element => { aggregation_group_type_index: groupTypeIndex ?? undefined, }, }) - setNewExperimentInsight() }} options={[ { value: -1, label: 'Persons' }, @@ -228,14 +224,12 @@ const ExperimentFormFields = (): JSX.Element => {
    - {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && ( -
    -

    Holdout group

    -
    Exclude a stable group of users from the experiment.
    - - -
    - )} +
    +

    Holdout group

    +
    Exclude a stable group of users from the experiment.
    + + +
    => { - const experimentInsightType = experiment.filters?.insight || InsightType.TRENDS +export function CumulativeExposuresChart(): JSX.Element { + const { experiment, experimentResults, getMetricType } = useValues(experimentLogic) + const { featureFlags } = useValues(featureFlagLogic) + + const metricIdx = 0 + const metricType = getMetricType(metricIdx) const variants = experiment.parameters?.feature_flag_variants?.map((variant) => variant.key) || [] if (experiment.holdout) { variants.push(`holdout-${experiment.holdout.id}`) } - // Trends Experiment - if (experimentInsightType === InsightType.TRENDS && experiment.parameters?.custom_exposure_filter) { - const trendResults = experimentResults as _TrendsExperimentResults - const queryFilters = { - ...trendResults.exposure_filters, - display: ChartDisplayType.ActionsLineGraphCumulative, - } as _TrendsExperimentResults['exposure_filters'] - return queryFromFilters(transformResultFilters(queryFilters)) - } - return { - kind: NodeKind.InsightVizNode, - source: { - kind: NodeKind.TrendsQuery, - dateRange: { - date_from: experiment.start_date, - date_to: experiment.end_date, - }, - interval: 'day', - trendsFilter: { + let query + + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + if (metricType === InsightType.TRENDS) { + query = { + kind: NodeKind.InsightVizNode, + source: (experimentResults as CachedExperimentTrendsQueryResponse).exposure_query, + } + } else { + query = { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange: { + date_from: experiment.start_date, + date_to: experiment.end_date, + }, + interval: 'day', + trendsFilter: { + display: ChartDisplayType.ActionsLineGraphCumulative, + showLegend: false, + smoothingIntervals: 1, + }, + series: [ + { + kind: NodeKind.EventsNode, + event: experiment.filters?.events?.[0]?.name, + math: BaseMathType.UniqueUsers, + properties: [ + { + key: `$feature/${experiment.feature_flag_key}`, + value: variants, + operator: PropertyOperator.Exact, + type: PropertyFilterType.Event, + }, + ], + }, + ], + breakdownFilter: { + breakdown: `$feature/${experiment.feature_flag_key}`, + breakdown_type: 'event', + }, + }, + } + } + } else { + if (metricType === InsightType.TRENDS && experiment.parameters?.custom_exposure_filter) { + const trendResults = experimentResults as _TrendsExperimentResults + const queryFilters = { + ...trendResults.exposure_filters, display: ChartDisplayType.ActionsLineGraphCumulative, - showLegend: false, - smoothingIntervals: 1, - }, - series: [ - { - kind: NodeKind.EventsNode, - event: - experimentInsightType === InsightType.TRENDS - ? '$feature_flag_called' - : experiment.filters?.events?.[0]?.name, - math: BaseMathType.UniqueUsers, - properties: [ + } as _TrendsExperimentResults['exposure_filters'] + query = queryFromFilters(transformResultFilters(queryFilters)) + } else { + query = { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange: { + date_from: experiment.start_date, + date_to: experiment.end_date, + }, + interval: 'day', + trendsFilter: { + display: ChartDisplayType.ActionsLineGraphCumulative, + showLegend: false, + smoothingIntervals: 1, + }, + series: [ { - key: `$feature/${experiment.feature_flag_key}`, - value: variants, - operator: PropertyOperator.Exact, - type: PropertyFilterType.Event, + kind: NodeKind.EventsNode, + event: + metricType === InsightType.TRENDS + ? '$feature_flag_called' + : experiment.filters?.events?.[0]?.name, + math: BaseMathType.UniqueUsers, + properties: [ + { + key: `$feature/${experiment.feature_flag_key}`, + value: variants, + operator: PropertyOperator.Exact, + type: PropertyFilterType.Event, + }, + ], }, ], + breakdownFilter: { + breakdown: `$feature/${experiment.feature_flag_key}`, + breakdown_type: 'event', + }, }, - ], - breakdownFilter: { - breakdown: `$feature/${experiment.feature_flag_key}`, - breakdown_type: 'event', - }, - }, + } + } } -} - -export function CumulativeExposuresChart(): JSX.Element { - const { experiment, experimentResults } = useValues(experimentLogic) return (
    @@ -94,7 +139,7 @@ export function CumulativeExposuresChart(): JSX.Element { {experiment.start_date ? ( ), showTable: true, }} setQuery={() => {}} diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx index 214d38aa20928..b6a69aeababa3 100644 --- a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx @@ -19,7 +19,7 @@ export function DataCollection(): JSX.Element { const { experimentId, experiment, - experimentInsightType, + getMetricType, funnelResultsPersonsTotal, actualRunningTime, minimumDetectableEffect, @@ -27,11 +27,13 @@ export function DataCollection(): JSX.Element { const { openExperimentCollectionGoalModal } = useActions(experimentLogic) + const metricType = getMetricType(0) + const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1 const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100 const experimentProgressPercent = - experimentInsightType === InsightType.FUNNELS + metricType === InsightType.FUNNELS ? (funnelResultsPersonsTotal / recommendedSampleSize) * 100 : (actualRunningTime / recommendedRunningTime) * 100 @@ -83,7 +85,7 @@ export function DataCollection(): JSX.Element { size="large" percent={experimentProgressPercent} /> - {experimentInsightType === InsightType.TRENDS && ( + {metricType === InsightType.TRENDS && (
    Completed  @@ -103,7 +105,7 @@ export function DataCollection(): JSX.Element {
    )} - {experimentInsightType === InsightType.FUNNELS && ( + {metricType === InsightType.FUNNELS && (
    @@ -170,11 +172,19 @@ export function DataCollection(): JSX.Element { } export function DataCollectionGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { - const { isExperimentCollectionGoalModalOpen, goalInsightDataLoading } = useValues(experimentLogic({ experimentId })) + const { + isExperimentCollectionGoalModalOpen, + getMetricType, + trendMetricInsightLoading, + funnelMetricInsightLoading, + } = useValues(experimentLogic({ experimentId })) const { closeExperimentCollectionGoalModal, updateExperimentCollectionGoal } = useActions( experimentLogic({ experimentId }) ) + const isInsightLoading = + getMetricType(0) === InsightType.TRENDS ? trendMetricInsightLoading : funnelMetricInsightLoading + return ( } > - {goalInsightDataLoading ? ( + {isInsightLoading ? (
    diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollectionCalculator.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollectionCalculator.tsx index 91f2d8a0c7a0d..95938242c143d 100644 --- a/frontend/src/scenes/experiments/ExperimentView/DataCollectionCalculator.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/DataCollectionCalculator.tsx @@ -9,9 +9,8 @@ import { insightLogic } from 'scenes/insights/insightLogic' import { Query } from '~/queries/Query/Query' import { ExperimentIdType, InsightType } from '~/types' -import { EXPERIMENT_INSIGHT_ID } from '../constants' +import { MetricInsightId } from '../constants' import { experimentLogic } from '../experimentLogic' - interface ExperimentCalculatorProps { experimentId: ExperimentIdType } @@ -108,20 +107,25 @@ function TrendCalculation({ experimentId }: ExperimentCalculatorProps): JSX.Elem } export function DataCollectionCalculator({ experimentId }: ExperimentCalculatorProps): JSX.Element { - const { experimentInsightType, minimumDetectableEffect, experiment, conversionMetrics } = useValues( + const { getMetricType, minimumDetectableEffect, experiment, conversionMetrics } = useValues( experimentLogic({ experimentId }) ) const { setExperiment } = useActions(experimentLogic({ experimentId })) + const metricType = getMetricType(0) + // :KLUDGE: need these to mount the Query component to load the insight */ - const insightLogicInstance = insightLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID, syncWithUrl: false }) + const insightLogicInstance = insightLogic({ + dashboardItemId: metricType === InsightType.FUNNELS ? MetricInsightId.Funnels : MetricInsightId.Trends, + syncWithUrl: false, + }) const { insightProps } = useValues(insightLogicInstance) const { query } = useValues(insightDataLogic(insightProps)) const funnelConversionRate = conversionMetrics?.totalRate * 100 || 0 let sliderMaxValue = 0 - if (experimentInsightType === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { if (100 - funnelConversionRate < 50) { sliderMaxValue = 100 - funnelConversionRate } else { @@ -204,7 +208,7 @@ export function DataCollectionCalculator({ experimentId }: ExperimentCalculatorP The calculations are based on the events received in the last 14 days. This event count may differ from what was considered in earlier estimates. - {experimentInsightType === InsightType.TRENDS ? ( + {getMetricType(0) === InsightType.TRENDS ? ( ) : ( diff --git a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx index f6d45ad12d314..30d6b93ea8278 100644 --- a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx @@ -20,11 +20,12 @@ import { Experiment, MultivariateFlagVariant } from '~/types' import { experimentLogic } from '../experimentLogic' import { VariantTag } from './components' +import { HoldoutSelector } from './HoldoutSelector' import { VariantScreenshot } from './VariantScreenshot' export function DistributionModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { const { experiment, experimentLoading, isDistributionModalOpen } = useValues(experimentLogic({ experimentId })) - const { closeDistributionModal } = useActions(experimentLogic({ experimentId })) + const { closeDistributionModal, updateExperiment } = useActions(experimentLogic({ experimentId })) const _featureFlagLogic = featureFlagLogic({ id: experiment.feature_flag?.id ?? null } as FeatureFlagLogicProps) const { featureFlag, areVariantRolloutsValid, variantRolloutSum } = useValues(_featureFlagLogic) @@ -63,6 +64,7 @@ export function DistributionModal({ experimentId }: { experimentId: Experiment[' { saveSidebarExperimentFeatureFlag(featureFlag) + updateExperiment({ holdout_id: experiment.holdout_id }) closeDistributionModal() }} type="primary" @@ -124,6 +126,7 @@ export function DistributionModal({ experimentId }: { experimentId: Experiment['

    )}
    +
    ) @@ -179,6 +182,9 @@ export function DistributionTable(): JSX.Element { key: 'variant_screenshot', title: 'Screenshot', render: function Key(_, item): JSX.Element { + if (item.key === `holdout-${experiment.holdout?.id}`) { + return
    + } return (
    @@ -213,6 +219,23 @@ export function DistributionTable(): JSX.Element { }) } + const holdoutData = experiment.holdout + ? [ + { + key: `holdout-${experiment.holdout.id}`, + rollout_percentage: experiment.holdout.filters[0].rollout_percentage, + } as MultivariateFlagVariant, + ] + : [] + + const variantData = (experiment.feature_flag?.filters.multivariate?.variants || []).map((variant) => ({ + ...variant, + rollout_percentage: + variant.rollout_percentage * ((100 - (experiment.holdout?.filters[0].rollout_percentage || 0)) / 100), + })) + + const tableData = [...variantData, ...holdoutData] + return (
    @@ -237,10 +260,17 @@ export function DistributionTable(): JSX.Element {
    + {experiment.holdout && ( + + This experiment has a holdout group of {experiment.holdout.filters[0].rollout_percentage}%. The + variants are modified to show their relative rollout percentage. + + )} (item.key === `holdout-${experiment.holdout?.id}` ? 'bg-mid' : '')} />
    ) diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx index c8a5ffa913833..446ee2fdf4500 100644 --- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx @@ -2,11 +2,13 @@ import '../Experiment.scss' import { LemonDivider, LemonTabs } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' import { PostHogFeature } from 'posthog-js/react' import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperimentImplementationDetails' import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails' import { experimentLogic } from '../experimentLogic' +import { ExperimentsDisabledBanner } from '../Experiments' import { ExperimentLoadingAnimation, LoadingState, @@ -17,7 +19,7 @@ import { import { CumulativeExposuresChart } from './CumulativeExposuresChart' import { DataCollection } from './DataCollection' import { DistributionModal, DistributionTable } from './DistributionTable' -import { ExperimentExposureModal, ExperimentGoalModal, Goal } from './Goal' +import { Goal } from './Goal' import { Info } from './Info' import { Overview } from './Overview' import { ReleaseConditionsModal, ReleaseConditionsTable } from './ReleaseConditionsTable' @@ -26,7 +28,6 @@ import { SecondaryMetricsTable } from './SecondaryMetricsTable' const ResultsTab = (): JSX.Element => { const { experiment, experimentResults } = useValues(experimentLogic) - const { updateExperimentSecondaryMetrics } = useActions(experimentLogic) const hasResultsInsight = experimentResults && experimentResults.insight @@ -50,12 +51,7 @@ const ResultsTab = (): JSX.Element => { )} )} - updateExperimentSecondaryMetrics(metrics)} - initialMetrics={experiment.secondary_metrics} - defaultAggregationType={experiment.parameters?.aggregation_group_type_index} - /> +
    ) } @@ -73,14 +69,16 @@ const VariantsTab = (): JSX.Element => { } export function ExperimentView(): JSX.Element { - const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey } = + const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey, featureFlags } = useValues(experimentLogic) const { setTabKey } = useActions(experimentLogic) const hasResultsInsight = experimentResults && experimentResults.insight - return ( + return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? ( + + ) : ( <>
    @@ -126,8 +124,6 @@ export function ExperimentView(): JSX.Element { /> )} - - diff --git a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx index 776cd61c16d78..1bf11115771d1 100644 --- a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx @@ -1,29 +1,84 @@ -import '../Experiment.scss' - import { IconInfo, IconPlus } from '@posthog/icons' -import { LemonButton, LemonDivider, LemonModal, Tooltip } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { Field, Form } from 'kea-forms' import { InsightLabel } from 'lib/components/InsightLabel' import { PropertyFilterButton } from 'lib/components/PropertyFilters/components/PropertyFilterButton' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { dayjs } from 'lib/dayjs' +import { useState } from 'react' + +import { ExperimentFunnelsQuery, ExperimentTrendsQuery, FunnelsQuery, NodeKind, TrendsQuery } from '~/queries/schema' +import { ActionFilter, AnyPropertyFilter, ChartDisplayType, Experiment, FilterType, InsightType } from '~/types' + +import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric } from '../experimentLogic' +import { PrimaryMetricModal } from '../Metrics/PrimaryMetricModal' +import { PrimaryTrendsExposureModal } from '../Metrics/PrimaryTrendsExposureModal' -import { ActionFilter as ActionFilterType, AnyPropertyFilter, Experiment, FilterType, InsightType } from '~/types' +export function MetricDisplayTrends({ query }: { query: TrendsQuery | undefined }): JSX.Element { + const event = query?.series?.[0] as unknown as ActionFilter -import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from '../constants' -import { experimentLogic } from '../experimentLogic' -import { MetricSelector } from '../MetricSelector' + if (!event) { + return <> + } -export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Element { - const experimentInsightType = filters?.insight || InsightType.TRENDS + return ( + <> +
    +
    + + + +
    +
    + {event.properties?.map((prop: AnyPropertyFilter) => ( + + ))} +
    +
    + + ) +} +export function MetricDisplayFunnels({ query }: { query: FunnelsQuery }): JSX.Element { return ( <> - {([...(filters?.events || []), ...(filters?.actions || [])] as ActionFilterType[]) + {(query.series || []).map((event: any, idx: number) => ( +
    +
    +
    + {idx + 1} +
    + + + +
    +
    + {event.properties?.map((prop: AnyPropertyFilter) => ( + + ))} +
    +
    + ))} + + ) +} + +// :FLAG: CLEAN UP AFTER MIGRATION +export function MetricDisplayOld({ filters }: { filters?: FilterType }): JSX.Element { + const metricType = filters?.insight || InsightType.TRENDS + + return ( + <> + {([...(filters?.events || []), ...(filters?.actions || [])] as ActionFilter[]) .sort((a, b) => (a.order || 0) - (b.order || 0)) - .map((event: ActionFilterType, idx: number) => ( + .map((event: ActionFilter, idx: number) => (
    - {experimentInsightType === InsightType.FUNNELS && ( + {metricType === InsightType.FUNNELS && (
    @@ -53,8 +108,19 @@ export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Elemen } export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { - const { experiment } = useValues(experimentLogic({ experimentId })) - const { openExperimentExposureModal, updateExperimentExposure } = useActions(experimentLogic({ experimentId })) + const { experiment, featureFlags } = useValues(experimentLogic({ experimentId })) + const { updateExperimentExposure, loadExperiment, setExperiment } = useActions(experimentLogic({ experimentId })) + const [isModalOpen, setIsModalOpen] = useState(false) + + const metricIdx = 0 + + // :FLAG: CLEAN UP AFTER MIGRATION + let hasCustomExposure = false + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + hasCustomExposure = !!(experiment.metrics[metricIdx] as ExperimentTrendsQuery).exposure_query + } else { + hasCustomExposure = !!experiment.parameters?.custom_exposure_filter + } return ( <> @@ -66,154 +132,117 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'
    - {experiment.parameters?.custom_exposure_filter ? ( - + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? ( + hasCustomExposure ? ( + + ) : ( + Default via $feature_flag_called events + ) + ) : hasCustomExposure ? ( + ) : ( Default via $feature_flag_called events )}
    - + { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + if (!hasCustomExposure) { + setExperiment({ + ...experiment, + metrics: experiment.metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + exposure_query: { + kind: NodeKind.TrendsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$pageview', + event: '$pageview', + }, + ], + interval: 'day', + dateRange: { + date_from: dayjs() + .subtract(EXPERIMENT_DEFAULT_DURATION, 'day') + .format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + explicitDate: true, + }, + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + filterTestAccounts: true, + }, + } + : metric + ), + }) + } + } else { + if (!hasCustomExposure) { + setExperiment({ + ...experiment, + parameters: { + ...experiment.parameters, + custom_exposure_filter: getDefaultFilters(InsightType.TRENDS, undefined), + }, + }) + } + } + setIsModalOpen(true) + }} + className="mr-2" + > Change exposure metric - {experiment.parameters?.custom_exposure_filter && ( + {hasCustomExposure && ( updateExperimentExposure(null)} + onClick={() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setExperiment({ + ...experiment, + metrics: experiment.metrics.map((metric, idx) => + idx === metricIdx ? { ...metric, exposure_query: undefined } : metric + ), + }) + } + updateExperimentExposure(null) + }} > Reset )}
    + { + setIsModalOpen(false) + loadExperiment() + }} + /> ) } -export function ExperimentGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { - const { experiment, isExperimentGoalModalOpen, experimentLoading, goalInsightDataLoading, experimentInsightType } = - useValues(experimentLogic({ experimentId })) - const { closeExperimentGoalModal, updateExperimentGoal, setNewExperimentInsight } = useActions( - experimentLogic({ experimentId }) - ) - - const experimentFiltersLength = - (experiment.filters?.events?.length || 0) + (experiment.filters?.actions?.length || 0) - - return ( - - - Cancel - - { - updateExperimentGoal(experiment.filters) - }} - type="primary" - loading={experimentLoading} - data-attr="create-annotation-submit" - > - Save - -
    - } - > - - - - - - - ) -} - -export function ExperimentExposureModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { - const { experiment, isExperimentExposureModalOpen, experimentLoading } = useValues( - experimentLogic({ experimentId }) - ) - const { closeExperimentExposureModal, updateExperimentExposure, setExperimentExposureInsight } = useActions( - experimentLogic({ experimentId }) - ) - - return ( - - - Cancel - - { - if (experiment.parameters.custom_exposure_filter) { - updateExperimentExposure(experiment.parameters.custom_exposure_filter) - } - }} - type="primary" - loading={experimentLoading} - data-attr="create-annotation-submit" - > - Save - -
    - } - > -
    - - - -
    - - ) -} - export function Goal(): JSX.Element { - const { experiment, experimentId, experimentInsightType, experimentMathAggregationForTrends, hasGoalSet } = + const { experiment, experimentId, getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } = useValues(experimentLogic) - const { openExperimentGoalModal } = useActions(experimentLogic({ experimentId })) + const { setExperiment, loadExperiment } = useActions(experimentLogic) + const [isModalOpen, setIsModalOpen] = useState(false) + const metricType = getMetricType(0) return (
    @@ -224,8 +253,8 @@ export function Goal(): JSX.Element { title={ <> {' '} - This {experimentInsightType === InsightType.FUNNELS ? 'funnel' : 'trend'}{' '} - {experimentInsightType === InsightType.FUNNELS + This {metricType === InsightType.FUNNELS ? 'funnel' : 'trend'}{' '} + {metricType === InsightType.FUNNELS ? 'experiment measures conversion at each stage.' : 'experiment tracks the count of a single metric.'} @@ -245,7 +274,20 @@ export function Goal(): JSX.Element { type="secondary" size="small" data-attr="add-experiment-goal" - onClick={openExperimentGoalModal} + onClick={() => { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setExperiment({ + ...experiment, + metrics: [getDefaultFunnelsMetric()], + }) + } else { + setExperiment({ + ...experiment, + filters: getDefaultFilters(InsightType.FUNNELS, undefined), + }) + } + setIsModalOpen(true) + }} > Add goal @@ -254,14 +296,27 @@ export function Goal(): JSX.Element {
    - {experimentInsightType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'} + {metricType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'}
    - - + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? ( + metricType === InsightType.FUNNELS ? ( + + ) : ( + + ) + ) : ( + + )} + setIsModalOpen(true)}> Change goal
    - {experimentInsightType === InsightType.TRENDS && !experimentMathAggregationForTrends() && ( + {metricType === InsightType.TRENDS && !experimentMathAggregationForTrends() && ( <>
    @@ -273,6 +328,14 @@ export function Goal(): JSX.Element { )}
    )} + { + setIsModalOpen(false) + loadExperiment() + }} + />
    ) } diff --git a/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx b/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx index f8982ad3d3636..56129767d6564 100644 --- a/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/HoldoutSelector.tsx @@ -6,7 +6,7 @@ import { experimentLogic } from '../experimentLogic' export function HoldoutSelector(): JSX.Element { const { experiment, holdouts, isExperimentRunning } = useValues(experimentLogic) - const { setExperiment, updateExperiment } = useActions(experimentLogic) + const { setExperiment, reportExperimentHoldoutAssigned } = useActions(experimentLogic) const holdoutOptions = holdouts.map((holdout) => ({ value: holdout.id, @@ -37,7 +37,7 @@ export function HoldoutSelector(): JSX.Element { ...experiment, holdout_id: value, }) - updateExperiment({ holdout_id: value }) + reportExperimentHoldoutAssigned({ experimentId: experiment.id, holdoutId: value }) }} data-attr="experiment-holdout-selector" /> diff --git a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx index 066144566b081..40a1e8133be54 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx @@ -1,131 +1,36 @@ -import '../Experiment.scss' - import { IconInfo, IconPencil, IconPlus } from '@posthog/icons' -import { LemonButton, LemonInput, LemonModal, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui' +import { LemonButton, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' -import { Form } from 'kea-forms' import { EntityFilterInfo } from 'lib/components/EntityFilterInfo' +import { FEATURE_FLAGS } from 'lib/constants' import { IconAreaChart } from 'lib/lemon-ui/icons' -import { LemonField } from 'lib/lemon-ui/LemonField' import { capitalizeFirstLetter } from 'lib/utils' +import { useState } from 'react' -import { InsightType } from '~/types' +import { Experiment, InsightType } from '~/types' -import { SECONDARY_METRIC_INSIGHT_ID } from '../constants' -import { experimentLogic, TabularSecondaryMetricResults } from '../experimentLogic' -import { MetricSelector } from '../MetricSelector' -import { MAX_SECONDARY_METRICS, secondaryMetricsLogic, SecondaryMetricsProps } from '../secondaryMetricsLogic' -import { ResultsQuery, VariantTag } from './components' +import { + experimentLogic, + getDefaultFilters, + getDefaultFunnelsMetric, + TabularSecondaryMetricResults, +} from '../experimentLogic' +import { SecondaryMetricChartModal } from '../Metrics/SecondaryMetricChartModal' +import { SecondaryMetricModal } from '../Metrics/SecondaryMetricModal' +import { VariantTag } from './components' -export function SecondaryMetricsModal({ - onMetricsChange, - initialMetrics, - experimentId, - defaultAggregationType, -}: SecondaryMetricsProps): JSX.Element { - const logic = secondaryMetricsLogic({ onMetricsChange, initialMetrics, experimentId, defaultAggregationType }) - const { - secondaryMetricModal, - isModalOpen, - showResults, - isSecondaryMetricModalSubmitting, - existingModalSecondaryMetric, - metricIdx, - } = useValues(logic) +const MAX_SECONDARY_METRICS = 10 - const { deleteMetric, closeModal, saveSecondaryMetric, setPreviewInsight } = useActions(logic) - const { secondaryMetricResults, isExperimentRunning } = useValues(experimentLogic({ experimentId })) - const targetResults = secondaryMetricResults && secondaryMetricResults[metricIdx] - - return ( - - Close - - ) : ( - <> - {existingModalSecondaryMetric && ( - deleteMetric(metricIdx)} - > - Delete - - )} -
    - - Cancel - - - {existingModalSecondaryMetric ? 'Save' : 'Create'} - -
    - - ) - } - > - {showResults ? ( - - ) : ( -
    - - - - - - -
    - )} -
    - ) -} - -export function SecondaryMetricsTable({ - onMetricsChange, - initialMetrics, - experimentId, - defaultAggregationType, -}: SecondaryMetricsProps): JSX.Element { - const logic = secondaryMetricsLogic({ onMetricsChange, initialMetrics, experimentId, defaultAggregationType }) - const { metrics } = useValues(logic) - - const { openModalToCreateSecondaryMetric, openModalToEditSecondaryMetric } = useActions(logic) +export function SecondaryMetricsTable({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { + const [isEditModalOpen, setIsEditModalOpen] = useState(false) + const [isChartModalOpen, setIsChartModalOpen] = useState(false) + const [modalMetricIdx, setModalMetricIdx] = useState(null) const { experimentResults, secondaryMetricResultsLoading, experiment, + getSecondaryMetricType, secondaryMetricResults, tabularSecondaryMetricResults, countDataForVariant, @@ -134,7 +39,38 @@ export function SecondaryMetricsTable({ credibleIntervalForVariant, experimentMathAggregationForTrends, getHighestProbabilityVariant, + featureFlags, } = useValues(experimentLogic({ experimentId })) + const { loadExperiment } = useActions(experimentLogic({ experimentId })) + + const openEditModal = (idx: number): void => { + setModalMetricIdx(idx) + setIsEditModalOpen(true) + } + + const closeEditModal = (): void => { + setIsEditModalOpen(false) + setModalMetricIdx(null) + loadExperiment() + } + + const openChartModal = (idx: number): void => { + setModalMetricIdx(idx) + setIsChartModalOpen(true) + } + + const closeChartModal = (): void => { + setIsChartModalOpen(false) + setModalMetricIdx(null) + } + + // :FLAG: CLEAN UP AFTER MIGRATION + let metrics + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + metrics = experiment.metrics_secondary + } else { + metrics = experiment.secondary_metrics + } const columns: LemonTableColumns = [ { @@ -156,14 +92,15 @@ export function SecondaryMetricsTable({ }, ] - experiment.secondary_metrics?.forEach((metric, idx) => { + metrics?.forEach((metric, idx) => { const targetResults = secondaryMetricResults?.[idx] const winningVariant = getHighestProbabilityVariant(targetResults || null) + const metricType = getSecondaryMetricType(idx) const Header = (): JSX.Element => (
    -
    {capitalizeFirstLetter(metric.name)}
    +
    {capitalizeFirstLetter(metric.name || '')}
    } - onClick={() => openModalToEditSecondaryMetric(metric, idx, true)} + onClick={() => openChartModal(idx)} disabledReason={ targetResults && targetResults.insight ? undefined @@ -183,7 +120,7 @@ export function SecondaryMetricsTable({ type="secondary" size="xsmall" icon={} - onClick={() => openModalToEditSecondaryMetric(metric, idx, false)} + onClick={() => openEditModal(idx)} />
    @@ -191,7 +128,7 @@ export function SecondaryMetricsTable({
    ) - if (metric.filters.insight === InsightType.TRENDS) { + if (metricType === InsightType.TRENDS) { columns.push({ title:
    , children: [ @@ -230,7 +167,11 @@ export function SecondaryMetricsTable({ if (item.variant === 'control') { return Baseline } - const credibleInterval = credibleIntervalForVariant(targetResults || null, item.variant) + const credibleInterval = credibleIntervalForVariant( + targetResults || null, + item.variant, + metricType + ) if (!credibleInterval) { return <>— } @@ -281,7 +222,11 @@ export function SecondaryMetricsTable({ return Baseline } - const credibleInterval = credibleIntervalForVariant(targetResults || null, item.variant) + const credibleInterval = credibleIntervalForVariant( + targetResults || null, + item.variant, + metricType + ) if (!credibleInterval) { return <>— } @@ -332,18 +277,11 @@ export function SecondaryMetricsTable({
    {metrics && metrics.length > 0 && (
    - = MAX_SECONDARY_METRICS - ? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.` - : undefined - } - > - Add metric - +
    )}
    @@ -365,24 +303,76 @@ export function SecondaryMetricsTable({ Add up to {MAX_SECONDARY_METRICS} secondary metrics to monitor side effects of your experiment.
    - } - type="secondary" - size="small" - onClick={openModalToCreateSecondaryMetric} - > - Add metric - +
    )}
    - + ) } + +const AddSecondaryMetricButton = ({ + experimentId, + metrics, + openEditModal, +}: { + experimentId: Experiment['id'] + metrics: any + openEditModal: (metricIdx: number) => void +}): JSX.Element => { + const { experiment, featureFlags } = useValues(experimentLogic({ experimentId })) + const { setExperiment } = useActions(experimentLogic({ experimentId })) + return ( + } + type="secondary" + size="small" + onClick={() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()] + setExperiment({ + metrics_secondary: newMetricsSecondary, + }) + openEditModal(newMetricsSecondary.length - 1) + } else { + const newSecondaryMetrics = [ + ...experiment.secondary_metrics, + { + name: '', + filters: getDefaultFilters(InsightType.FUNNELS, undefined), + }, + ] + setExperiment({ + secondary_metrics: newSecondaryMetrics, + }) + openEditModal(newSecondaryMetrics.length - 1) + } + }} + disabledReason={ + metrics.length >= MAX_SECONDARY_METRICS + ? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.` + : undefined + } + > + Add metric + + ) +} diff --git a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx index d4049eedd49f8..b859dae72e071 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx @@ -11,8 +11,6 @@ import posthog from 'posthog-js' import { urls } from 'scenes/urls' import { - _FunnelExperimentResults, - _TrendsExperimentResults, FilterLogicalOperator, FunnelExperimentVariant, InsightType, @@ -33,13 +31,15 @@ export function SummaryTable(): JSX.Element { experiment, experimentResults, tabularExperimentResults, - experimentInsightType, + getMetricType, exposureCountDataForVariant, conversionRateForVariant, experimentMathAggregationForTrends, countDataForVariant, getHighestProbabilityVariant, + credibleIntervalForVariant, } = useValues(experimentLogic) + const metricType = getMetricType(0) if (!experimentResults) { return <> @@ -61,7 +61,7 @@ export function SummaryTable(): JSX.Element { }, ] - if (experimentInsightType === InsightType.TRENDS) { + if (metricType === InsightType.TRENDS) { columns.push({ key: 'counts', title: ( @@ -163,22 +163,11 @@ export function SummaryTable(): JSX.Element { return Baseline } - const credibleInterval = (experimentResults as _TrendsExperimentResults)?.credible_intervals?.[ - variant.key - ] + const credibleInterval = credibleIntervalForVariant(experimentResults || null, variant.key, metricType) if (!credibleInterval) { return <>— } - - const controlVariant = (experimentResults.variants as TrendExperimentVariant[]).find( - ({ key }) => key === 'control' - ) as TrendExperimentVariant - const controlMean = controlVariant.count / controlVariant.absolute_exposure - - // Calculate the percentage difference between the credible interval bounds of the variant and the control's mean. - // This represents the range in which the true percentage change relative to the control is likely to fall. - const lowerBound = ((credibleInterval[0] - controlMean) / controlMean) * 100 - const upperBound = ((credibleInterval[1] - controlMean) / controlMean) * 100 + const [lowerBound, upperBound] = credibleInterval return (
    {`[${lowerBound > 0 ? '+' : ''}${lowerBound.toFixed(2)}%, ${ @@ -189,7 +178,7 @@ export function SummaryTable(): JSX.Element { }) } - if (experimentInsightType === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { columns.push({ key: 'conversionRate', title: 'Conversion rate', @@ -248,27 +237,11 @@ export function SummaryTable(): JSX.Element { return Baseline } - const credibleInterval = (experimentResults as _FunnelExperimentResults)?.credible_intervals?.[ - item.key - ] + const credibleInterval = credibleIntervalForVariant(experimentResults || null, item.key, metricType) if (!credibleInterval) { return <>— } - - const controlVariant = (experimentResults.variants as FunnelExperimentVariant[]).find( - ({ key }) => key === 'control' - ) as FunnelExperimentVariant - const controlConversionRate = - controlVariant.success_count / (controlVariant.success_count + controlVariant.failure_count) - - if (!controlConversionRate) { - return <>— - } - - // Calculate the percentage difference between the credible interval bounds of the variant and the control's conversion rate. - // This represents the range in which the true percentage change relative to the control is likely to fall. - const lowerBound = ((credibleInterval[0] - controlConversionRate) / controlConversionRate) * 100 - const upperBound = ((credibleInterval[1] - controlConversionRate) / controlConversionRate) * 100 + const [lowerBound, upperBound] = credibleInterval return (
    {`[${lowerBound > 0 ? '+' : ''}${lowerBound.toFixed(2)}%, ${ diff --git a/frontend/src/scenes/experiments/ExperimentView/VariantScreenshot.tsx b/frontend/src/scenes/experiments/ExperimentView/VariantScreenshot.tsx index 3580428d04175..81286363c3b6b 100644 --- a/frontend/src/scenes/experiments/ExperimentView/VariantScreenshot.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/VariantScreenshot.tsx @@ -1,4 +1,4 @@ -import { IconUpload, IconX } from '@posthog/icons' +import { IconX } from '@posthog/icons' import { LemonButton, LemonDivider, LemonFileInput, LemonModal, LemonSkeleton, lemonToast } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { useUploadFiles } from 'lib/hooks/useUploadFiles' @@ -17,20 +17,34 @@ export function VariantScreenshot({ const { experiment } = useValues(experimentLogic) const { updateExperimentVariantImages, reportExperimentVariantScreenshotUploaded } = useActions(experimentLogic) - const [mediaId, setMediaId] = useState(experiment.parameters?.variant_screenshot_media_ids?.[variantKey] || null) - const [isLoadingImage, setIsLoadingImage] = useState(true) - const [isModalOpen, setIsModalOpen] = useState(false) + const getInitialMediaIds = (): string[] => { + const variantImages = experiment.parameters?.variant_screenshot_media_ids?.[variantKey] + if (!variantImages) { + return [] + } + + return Array.isArray(variantImages) ? variantImages : [variantImages] + } + + const [mediaIds, setMediaIds] = useState(getInitialMediaIds()) + const [loadingImages, setLoadingImages] = useState>({}) + const [selectedImageIndex, setSelectedImageIndex] = useState(null) const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({ onUpload: (_, __, id) => { - setMediaId(id) - if (id) { + if (id && mediaIds.length < 5) { + const newMediaIds = [...mediaIds, id] + setMediaIds(newMediaIds) + const updatedVariantImages = { ...experiment.parameters?.variant_screenshot_media_ids, - [variantKey]: id, + [variantKey]: newMediaIds, } + updateExperimentVariantImages(updatedVariantImages) reportExperimentVariantScreenshotUploaded(experiment.id) + } else if (mediaIds.length >= 5) { + lemonToast.error('Maximum of 5 images allowed') } }, onError: (detail) => { @@ -38,64 +52,107 @@ export function VariantScreenshot({ }, }) + const handleImageLoad = (mediaId: string): void => { + setLoadingImages((prev) => ({ ...prev, [mediaId]: false })) + } + + const handleImageError = (mediaId: string): void => { + setLoadingImages((prev) => ({ ...prev, [mediaId]: false })) + } + + const handleDelete = (indexToDelete: number): void => { + const newMediaIds = mediaIds.filter((_, index) => index !== indexToDelete) + setMediaIds(newMediaIds) + + const updatedVariantImages = { + ...experiment.parameters?.variant_screenshot_media_ids, + [variantKey]: newMediaIds, + } + + updateExperimentVariantImages(updatedVariantImages) + } + + const getThumbnailWidth = (): string => { + const totalItems = mediaIds.length < 5 ? mediaIds.length + 1 : mediaIds.length + switch (totalItems) { + case 1: + return 'w-20' + case 2: + return 'w-20' + case 3: + return 'w-16' + case 4: + return 'w-14' + case 5: + return 'w-12' + default: + return 'w-20' + } + } + + const widthClass = getThumbnailWidth() + return (
    - {!mediaId ? ( - - - Upload a preview of this variant's UI - - } - /> - ) : ( -
    -
    -
    setIsModalOpen(true)} className="cursor-zoom-in relative"> -
    - {isLoadingImage && } - setIsLoadingImage(false)} - onLoad={() => setIsLoadingImage(false)} - /> -
    -
    - } - onClick={(e) => { - e.stopPropagation() - setMediaId(null) - const updatedVariantImages = { - ...experiment.parameters?.variant_screenshot_media_ids, - } - delete updatedVariantImages[variantKey] - updateExperimentVariantImages(updatedVariantImages) - }} - size="small" - tooltip="Remove" - tooltipPlacement="right" - noPadding - className="group-hover:flex hidden absolute right-0 top-0" - /> +
    + {mediaIds.map((mediaId, index) => ( +
    +
    +
    setSelectedImageIndex(index)} className="cursor-zoom-in relative"> +
    + {loadingImages[mediaId] && } + handleImageError(mediaId)} + onLoad={() => handleImageLoad(mediaId)} + /> +
    +
    + } + onClick={(e) => { + e.stopPropagation() + handleDelete(index) + }} + size="small" + tooltip="Remove" + tooltipPlacement="right" + noPadding + className="group-hover:flex hidden absolute right-0 top-0" + /> +
    -
    - )} + ))} + + {mediaIds.length < 5 && ( +
    + + + +
    + } + /> +
    + )} +
    + setIsModalOpen(false)} + isOpen={selectedImageIndex !== null} + onClose={() => setSelectedImageIndex(null)} title={
    - Screenshot + Screenshot {selectedImageIndex !== null ? selectedImageIndex + 1 : ''} {rolloutPercentage !== undefined && ( @@ -104,12 +161,20 @@ export function VariantScreenshot({
    } > - {`Screenshot: + {selectedImageIndex !== null && mediaIds[selectedImageIndex] && ( + {`Screenshot + )}
    ) } + +export default VariantScreenshot diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx index ddcd2bbd14bd1..fd8751da6c2b6 100644 --- a/frontend/src/scenes/experiments/ExperimentView/components.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx @@ -153,7 +153,6 @@ export function ResultsQuery({ } as InsightVizNode, result: newQueryResults?.insight, disable_baseline: true, - last_refresh: newQueryResults?.last_refresh, }, doNotLoad: true, }, @@ -265,6 +264,8 @@ export function ExploreButton({ icon = }: { icon?: JSX.Element } export function ResultsHeader(): JSX.Element { + const { experimentResults } = useValues(experimentLogic) + return (
    @@ -275,9 +276,7 @@ export function ResultsHeader(): JSX.Element {
    -
    - -
    +
    {experimentResults && }
    ) @@ -691,7 +690,7 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i export function ActionBanner(): JSX.Element { const { experiment, - experimentInsightType, + getMetricType, experimentResults, experimentLoading, experimentResultsLoading, @@ -708,6 +707,9 @@ export function ActionBanner(): JSX.Element { const { archiveExperiment } = useActions(experimentLogic) const { aggregationLabel } = useValues(groupsModel) + + const metricType = getMetricType(0) + const aggregationTargetName = experiment.filters.aggregation_group_type_index != null ? aggregationLabel(experiment.filters.aggregation_group_type_index).plural @@ -766,7 +768,7 @@ export function ActionBanner(): JSX.Element { // Results insignificant, but a large enough sample/running time has been achieved // Further collection unlikely to change the result -> recommmend cutting the losses if ( - experimentInsightType === InsightType.FUNNELS && + metricType === InsightType.FUNNELS && funnelResultsPersonsTotal > Math.max(recommendedSampleSize, 500) && dayjs().diff(experiment.start_date, 'day') > 2 // at least 2 days running ) { @@ -778,7 +780,7 @@ export function ActionBanner(): JSX.Element { ) } - if (experimentInsightType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) { + if (metricType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) { return ( Your experiment has been running long enough, but the results are still inconclusive. Continuing the @@ -807,7 +809,7 @@ export function ActionBanner(): JSX.Element { // Win probability only slightly over 0.9 and the recommended sample/time just met -> proceed with caution if ( - experimentInsightType === InsightType.FUNNELS && + metricType === InsightType.FUNNELS && funnelResultsPersonsTotal < recommendedSampleSize + 50 && winProbability < 0.93 ) { @@ -821,7 +823,7 @@ export function ActionBanner(): JSX.Element { } if ( - experimentInsightType === InsightType.TRENDS && + metricType === InsightType.TRENDS && actualRunningTime < recommendedRunningTime + 2 && winProbability < 0.93 ) { diff --git a/frontend/src/scenes/experiments/ExperimentWorkflow.tsx b/frontend/src/scenes/experiments/ExperimentWorkflow.tsx deleted file mode 100644 index 763c367eb719d..0000000000000 --- a/frontend/src/scenes/experiments/ExperimentWorkflow.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import './Experiment.scss' - -import { IconCheckCircle } from '@posthog/icons' -import clsx from 'clsx' -import { IconRadioButtonUnchecked } from 'lib/lemon-ui/icons' -import { useState } from 'react' - -export function ExperimentWorkflow(): JSX.Element { - const [workflowValidateStepCompleted, setWorkflowValidateStepCompleted] = useState(false) - const [workflowLaunchStepCompleted, setWorkflowLaunchStepCompleted] = useState(false) - - return ( - <> -
    -
    Experiment workflow
    -
    -
    -
    -
    - - Create experiment -
    -
    Set variants, select participants, and add secondary metrics
    -
    -
    -
    -
    setWorkflowValidateStepCompleted(!workflowValidateStepCompleted)} - > -
    - {workflowValidateStepCompleted ? ( - - ) : ( - - )} - Validate experiment -
    -
    - Once you've written your code, it's a good idea to test that each variant behaves as - you'd expect. -
    -
    -
    -
    -
    setWorkflowLaunchStepCompleted(!workflowLaunchStepCompleted)} - > -
    - {workflowLaunchStepCompleted ? ( - - ) : ( - - )} - Launch experiment -
    -
    - Run your experiment, monitor results, and decide when to terminate your experiment. -
    -
    -
    -
    -
    - - ) -} diff --git a/frontend/src/scenes/experiments/Experiments.tsx b/frontend/src/scenes/experiments/Experiments.tsx index e0846346935d9..6cd77e394393b 100644 --- a/frontend/src/scenes/experiments/Experiments.tsx +++ b/frontend/src/scenes/experiments/Experiments.tsx @@ -1,7 +1,7 @@ import { LemonDialog, LemonInput, LemonSelect } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { router } from 'kea-router' -import { ExperimentsHog } from 'lib/components/hedgehogs' +import { DetectiveHog, ExperimentsHog } from 'lib/components/hedgehogs' import { MemberSelect } from 'lib/components/MemberSelect' import { PageHeader } from 'lib/components/PageHeader' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' @@ -16,6 +16,7 @@ import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { Link } from 'lib/lemon-ui/Link' import stringWithWBR from 'lib/utils/stringWithWBR' +import posthog from 'posthog-js' import { SceneExport } from 'scenes/sceneTypes' import { urls } from 'scenes/urls' @@ -30,6 +31,33 @@ export const scene: SceneExport = { logic: experimentsLogic, } +export const ExperimentsDisabledBanner = (): JSX.Element => { + const payload = posthog.getFeatureFlagPayload(FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI) + + return ( +
    +
    +
    +
    + +
    +
    +
    +

    We'll be right back!

    +

    + We’re upgrading experiments to a new schema to make them faster, more reliable, and ready for + future improvements. +

    +

    + We expect to be done by {payload}. Thanks for your + patience! +

    +
    +
    +
    + ) +} + export function Experiments(): JSX.Element { const { filteredExperiments, @@ -189,7 +217,9 @@ export function Experiments(): JSX.Element { }, ] - return ( + return featureFlags[FEATURE_FLAGS.EXPERIMENTS_MIGRATION_DISABLE_UI] ? ( + + ) : (
    - {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOLDOUTS] && tab === ExperimentsTabs.Holdouts ? ( + {tab === ExperimentsTabs.Holdouts ? ( ) : ( <> diff --git a/frontend/src/scenes/experiments/MetricSelector.tsx b/frontend/src/scenes/experiments/MetricSelector.tsx deleted file mode 100644 index 446505d67728b..0000000000000 --- a/frontend/src/scenes/experiments/MetricSelector.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import './Experiment.scss' - -import { IconInfo } from '@posthog/icons' -import { LemonSelect, Link } from '@posthog/lemon-ui' -import { BindLogic, useActions, useValues } from 'kea' -import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' -import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants' -import { LemonBanner } from 'lib/lemon-ui/LemonBanner' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { useEffect } from 'react' -import { Attribution } from 'scenes/insights/EditorFilters/AttributionFilter' -import { SamplingFilter } from 'scenes/insights/EditorFilters/SamplingFilter' -import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' -import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' -import { AggregationSelect } from 'scenes/insights/filters/AggregationSelect' -import { insightDataLogic } from 'scenes/insights/insightDataLogic' -import { insightLogic } from 'scenes/insights/insightLogic' -import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' -import { FunnelConversionWindowFilter } from 'scenes/insights/views/Funnels/FunnelConversionWindowFilter' - -import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' -import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' -import { InsightTestAccountFilter } from '~/queries/nodes/InsightViz/filters/InsightTestAccountFilter' -import { Query } from '~/queries/Query/Query' -import { FunnelsQuery, InsightQueryNode, TrendsQuery } from '~/queries/schema' -import { EditorFilterProps, FilterType, InsightLogicProps, InsightShortId, InsightType } from '~/types' - -export interface MetricSelectorProps { - dashboardItemId: InsightShortId - setPreviewInsight: (filters?: Partial) => void - showDateRangeBanner?: boolean - forceTrendExposureMetric?: boolean -} - -export function MetricSelector({ - dashboardItemId, - setPreviewInsight, - showDateRangeBanner, - forceTrendExposureMetric, -}: MetricSelectorProps): JSX.Element { - // insightLogic - const logic = insightLogic({ dashboardItemId, syncWithUrl: false }) - const { insightProps } = useValues(logic) - - // insightDataLogic - const { query } = useValues(insightDataLogic(insightProps)) - - // insightVizDataLogic - const { isTrends } = useValues(insightVizDataLogic(insightProps)) - - useEffect(() => { - if (forceTrendExposureMetric && !isTrends) { - setPreviewInsight({ insight: InsightType.TRENDS }) - } - }, [forceTrendExposureMetric, isTrends]) - - return ( - <> -
    - Insight Type - { - val && setPreviewInsight({ insight: val }) - }} - options={[ - { value: InsightType.TRENDS, label: Trends }, - { value: InsightType.FUNNELS, label: Funnels }, - ]} - disabledReason={forceTrendExposureMetric ? 'Exposure metric can only be a trend graph' : undefined} - /> -
    - -
    - -
    -
    - - - - {showDateRangeBanner && ( - - Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a - mismatch between the preview and the actual results. - - )} - -
    - - - -
    - - ) -} - -export function ExperimentInsightCreator({ insightProps }: { insightProps: InsightLogicProps }): JSX.Element { - // insightVizDataLogic - const { isTrends, series, querySource } = useValues(insightVizDataLogic(insightProps)) - const { updateQuerySource } = useActions(insightVizDataLogic(insightProps)) - - // calculated properties - const filterSteps = series || [] - const isStepsEmpty = filterSteps.length === 0 - - return ( - <> - ): void => { - updateQuerySource({ - series: actionsAndEventsToSeries( - payload as any, - true, - isTrends ? MathAvailability.All : MathAvailability.None - ), - } as TrendsQuery | FunnelsQuery) - }} - typeKey={`experiment-${isTrends ? InsightType.TRENDS : InsightType.FUNNELS}-${ - insightProps.dashboardItemId - }-metric`} - mathAvailability={isTrends ? undefined : MathAvailability.None} - hideDeleteBtn={isTrends || filterSteps.length === 1} - buttonCopy={isTrends ? 'Add graph series' : 'Add funnel step'} - showSeriesIndicator={isTrends || !isStepsEmpty} - entitiesLimit={isTrends ? 1 : undefined} - seriesIndicatorType={isTrends ? undefined : 'numeric'} - sortable={isTrends ? undefined : true} - showNestedArrow={isTrends ? undefined : true} - showNumericalPropsOnly={isTrends} - actionsTaxonomicGroupTypes={[ - TaxonomicFilterGroupType.Events, - TaxonomicFilterGroupType.Actions, - TaxonomicFilterGroupType.DataWarehouse, - ]} - propertiesTaxonomicGroupTypes={[ - TaxonomicFilterGroupType.EventProperties, - TaxonomicFilterGroupType.PersonProperties, - TaxonomicFilterGroupType.EventFeatureFlags, - TaxonomicFilterGroupType.Cohorts, - TaxonomicFilterGroupType.Elements, - TaxonomicFilterGroupType.SessionProperties, - TaxonomicFilterGroupType.HogQLExpression, - TaxonomicFilterGroupType.DataWarehouseProperties, - TaxonomicFilterGroupType.DataWarehousePersonProperties, - ]} - /> -
    - {!isTrends && ( - <> -
    - Aggregating by - -
    - - - - )} - -
    - - ) -} - -export function AttributionSelect({ insightProps }: EditorFilterProps): JSX.Element { - return ( -
    -
    - Attribution type - -
    - When breaking down funnels, it's possible that the same properties don't exist on every - event. For example, if you want to break down by browser on a funnel that contains both - frontend and backend events. -
    -
    - In this case, you can choose from which step the properties should be selected from by - modifying the attribution type. There are four modes to choose from: -
    -
      -
    • First touchpoint: the first property value seen in any of the steps is chosen.
    • -
    • Last touchpoint: the last property value seen from all steps is chosen.
    • -
    • - All steps: the property value must be seen in all steps to be considered in the - funnel. -
    • -
    • Specific step: only the property value seen at the selected step is chosen.
    • -
    -
    - Read more in the{' '} - - documentation. - -
    -
    - } - > - - -
    - -
    - ) -} diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx new file mode 100644 index 0000000000000..aefca698f5ad7 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx @@ -0,0 +1,309 @@ +import { LemonLabel } from '@posthog/lemon-ui' +import { LemonInput } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema' +import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { + commonActionFilterProps, + FunnelAggregationSelect, + FunnelAttributionSelect, + FunnelConversionWindowFilter, +} from './Selectors' +export function PrimaryGoalFunnels(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic) + const { setExperiment, setFunnelsMetric } = useActions(experimentLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + + const metricIdx = 0 + const currentMetric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery + + return ( + <> +
    + Name (optional) + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && ( + { + setFunnelsMetric({ + metricIdx, + name: newName, + }) + }} + /> + )} +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return queryNodeToFilter(currentMetric.funnels_query) + } + return experiment.filters + })()} + setFilters={({ actions, events, data_warehouse }: Partial): void => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.None + ) + + setFunnelsMetric({ + metricIdx, + series, + }) + } else { + if (actions?.length) { + setExperiment({ + filters: { + ...experiment.filters, + actions, + events: undefined, + data_warehouse: undefined, + }, + }) + } else if (events?.length) { + setExperiment({ + filters: { + ...experiment.filters, + events, + actions: undefined, + data_warehouse: undefined, + }, + }) + } else if (data_warehouse?.length) { + setExperiment({ + filters: { + ...experiment.filters, + data_warehouse, + actions: undefined, + events: undefined, + }, + }) + } + } + }} + typeKey="experiment-metric" + mathAvailability={MathAvailability.None} + buttonCopy="Add funnel step" + showSeriesIndicator={true} + seriesIndicatorType="numeric" + sortable={true} + showNestedArrow={true} + {...commonActionFilterProps} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return getHogQLValue( + currentMetric.funnels_query.aggregation_group_type_index ?? undefined, + currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined + ) + } + return getHogQLValue( + experiment.filters.aggregation_group_type_index, + (experiment.filters as FunnelsFilterType).funnel_aggregate_by_hogql + ) + })()} + onChange={(value) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelAggregateByHogQL: value, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + funnel_aggregate_by_hogql: value, + }, + }) + } + }} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval + } + return (experiment.filters as FunnelsFilterType).funnel_window_interval + })()} + funnelWindowIntervalUnit={(() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit + } + return (experiment.filters as FunnelsFilterType).funnel_window_interval_unit + })()} + onFunnelWindowIntervalChange={(funnelWindowInterval) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelWindowInterval: funnelWindowInterval, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + funnel_window_interval: funnelWindowInterval, + }, + }) + } + }} + onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + funnel_window_interval_unit: funnelWindowIntervalUnit || undefined, + }, + }) + } + }} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + let breakdownAttributionType + let breakdownAttributionValue + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + breakdownAttributionType = + currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType + breakdownAttributionValue = + currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue + } else { + breakdownAttributionType = (experiment.filters as FunnelsFilterType) + .breakdown_attribution_type + breakdownAttributionValue = (experiment.filters as FunnelsFilterType) + .breakdown_attribution_value + } + + const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` = + !breakdownAttributionType + ? BreakdownAttributionType.FirstTouch + : breakdownAttributionType === BreakdownAttributionType.Step + ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}` + : breakdownAttributionType + + return currentValue + })()} + onChange={(value) => { + const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/') + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType, + breakdownAttributionValue: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : undefined, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + breakdown_attribution_type: breakdownAttributionType as BreakdownAttributionType, + breakdown_attribution_value: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : 0, + }, + }) + } + }} + stepsLength={(() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.series?.length + } + return Math.max( + experiment.filters.actions?.length ?? 0, + experiment.filters.events?.length ?? 0, + experiment.filters.data_warehouse?.length ?? 0 + ) + })()} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const val = (experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query + ?.filterTestAccounts + return hasFilters ? !!val : false + } + return hasFilters ? !!experiment.filters.filter_test_accounts : false + })()} + onChange={(checked: boolean) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + filterTestAccounts: checked, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + filter_test_accounts: checked, + }, + }) + } + }} + fullWidth + /> +
    + {isExperimentRunning && ( + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + )} +
    + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query + } + return filtersToQueryNode(experiment.filters) + })(), + showTable: false, + showLastComputation: true, + showLastComputationRefresh: false, + }} + readOnly + /> +
    + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx new file mode 100644 index 0000000000000..0ce1cb72e33da --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx @@ -0,0 +1,160 @@ +import { LemonInput, LemonLabel } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema' +import { FilterType } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { commonActionFilterProps } from './Selectors' + +export function PrimaryGoalTrends(): JSX.Element { + const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic) + const { setExperiment, setTrendsMetric } = useActions(experimentLogic) + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + + const metricIdx = 0 + const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery + + return ( + <> +
    + Name (optional) + {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && ( + { + setTrendsMetric({ + metricIdx, + name: newName, + }) + }} + /> + )} +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return queryNodeToFilter(currentMetric.count_query) + } + return experiment.filters + })()} + setFilters={({ actions, events, data_warehouse }: Partial): void => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + + setTrendsMetric({ + metricIdx, + series, + }) + } else { + if (actions?.length) { + setExperiment({ + filters: { + ...experiment.filters, + actions, + events: undefined, + data_warehouse: undefined, + }, + }) + } else if (events?.length) { + setExperiment({ + filters: { + ...experiment.filters, + events, + actions: undefined, + data_warehouse: undefined, + }, + }) + } else if (data_warehouse?.length) { + setExperiment({ + filters: { + ...experiment.filters, + data_warehouse, + actions: undefined, + events: undefined, + }, + }) + } + } + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + {...commonActionFilterProps} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const val = currentMetric.count_query?.filterTestAccounts + return hasFilters ? !!val : false + } + return hasFilters ? !!experiment.filters.filter_test_accounts : false + })()} + onChange={(checked: boolean) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setTrendsMetric({ + metricIdx, + filterTestAccounts: checked, + }) + } else { + setExperiment({ + filters: { + ...experiment.filters, + filter_test_accounts: checked, + }, + }) + } + }} + fullWidth + /> +
    + {isExperimentRunning && ( + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + )} +
    + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.count_query + } + return filtersToQueryNode(experiment.filters) + })(), + showTable: false, + showLastComputation: true, + showLastComputationRefresh: false, + }} + readOnly + /> +
    + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx new file mode 100644 index 0000000000000..4ebe43c30e928 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx @@ -0,0 +1,157 @@ +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema' +import { FilterType } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { commonActionFilterProps } from './Selectors' + +export function PrimaryGoalTrendsExposure(): JSX.Element { + const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic) + const { setExperiment, setTrendsExposureMetric } = useActions(experimentLogic) + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + const currentMetric = experiment.metrics[0] as ExperimentTrendsQuery + + return ( + <> + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return queryNodeToFilter(currentMetric.exposure_query as InsightQueryNode) + } + return experiment.parameters.custom_exposure_filter as FilterType + })()} + setFilters={({ actions, events, data_warehouse }: Partial): void => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + + setTrendsExposureMetric({ + metricIdx: 0, + series, + }) + } else { + if (actions?.length) { + setExperiment({ + parameters: { + ...experiment.parameters, + custom_exposure_filter: { + ...experiment.parameters.custom_exposure_filter, + actions, + events: undefined, + data_warehouse: undefined, + }, + }, + }) + } else if (events?.length) { + setExperiment({ + parameters: { + ...experiment.parameters, + custom_exposure_filter: { + ...experiment.parameters.custom_exposure_filter, + events, + actions: undefined, + data_warehouse: undefined, + }, + }, + }) + } else if (data_warehouse?.length) { + setExperiment({ + parameters: { + ...experiment.parameters, + custom_exposure_filter: { + ...experiment.parameters.custom_exposure_filter, + data_warehouse, + actions: undefined, + events: undefined, + }, + }, + }) + } + } + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + {...commonActionFilterProps} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const val = currentMetric.exposure_query?.filterTestAccounts + return hasFilters ? !!val : false + } + return hasFilters + ? !!(experiment.parameters.custom_exposure_filter as FilterType).filter_test_accounts + : false + })()} + onChange={(checked: boolean) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setTrendsExposureMetric({ + metricIdx: 0, + filterTestAccounts: checked, + }) + } else { + setExperiment({ + parameters: { + ...experiment.parameters, + custom_exposure_filter: { + ...experiment.parameters.custom_exposure_filter, + filter_test_accounts: checked, + }, + }, + }) + } + }} + fullWidth + /> +
    + {isExperimentRunning && ( + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + )} +
    + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.exposure_query + } + return filtersToQueryNode(experiment.parameters.custom_exposure_filter as FilterType) + })(), + showTable: false, + showLastComputation: true, + showLastComputationRefresh: false, + }} + readOnly + /> +
    + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx new file mode 100644 index 0000000000000..14fd6c7d4e967 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx @@ -0,0 +1,98 @@ +import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' + +import { ExperimentFunnelsQuery } from '~/queries/schema' +import { Experiment, InsightType } from '~/types' + +import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic' +import { PrimaryGoalFunnels } from '../Metrics/PrimaryGoalFunnels' +import { PrimaryGoalTrends } from '../Metrics/PrimaryGoalTrends' + +export function PrimaryMetricModal({ + experimentId, + isOpen, + onClose, +}: { + experimentId: Experiment['id'] + isOpen: boolean + onClose: () => void +}): JSX.Element { + const { experiment, experimentLoading, getMetricType, featureFlags } = useValues(experimentLogic({ experimentId })) + const { updateExperimentGoal, setExperiment } = useActions(experimentLogic({ experimentId })) + + const metricIdx = 0 + const metricType = getMetricType(metricIdx) + + let funnelStepsLength = 0 + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && metricType === InsightType.FUNNELS) { + const metric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery + funnelStepsLength = metric?.funnels_query?.series?.length || 0 + } else { + funnelStepsLength = (experiment.filters?.events?.length || 0) + (experiment.filters?.actions?.length || 0) + } + + return ( + + + Cancel + + { + updateExperimentGoal(experiment.filters) + }} + type="primary" + loading={experimentLoading} + data-attr="create-annotation-submit" + > + Save + +
    + } + > +
    + Metric type + { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setExperiment({ + ...experiment, + metrics: [ + ...experiment.metrics.slice(0, metricIdx), + newMetricType === InsightType.TRENDS + ? getDefaultTrendsMetric() + : getDefaultFunnelsMetric(), + ...experiment.metrics.slice(metricIdx + 1), + ], + }) + } else { + setExperiment({ + ...experiment, + filters: getDefaultFilters(newMetricType, undefined), + }) + } + }} + options={[ + { value: InsightType.TRENDS, label: Trends }, + { value: InsightType.FUNNELS, label: Funnels }, + ]} + /> +
    + {metricType === InsightType.TRENDS ? : } + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx new file mode 100644 index 0000000000000..7c4f49c114a73 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx @@ -0,0 +1,57 @@ +import { LemonButton, LemonModal } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' + +import { Experiment } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { PrimaryGoalTrendsExposure } from '../Metrics/PrimaryGoalTrendsExposure' + +export function PrimaryTrendsExposureModal({ + experimentId, + isOpen, + onClose, +}: { + experimentId: Experiment['id'] + isOpen: boolean + onClose: () => void +}): JSX.Element { + const { experiment, experimentLoading, featureFlags } = useValues(experimentLogic({ experimentId })) + const { updateExperimentExposure, updateExperiment } = useActions(experimentLogic({ experimentId })) + + return ( + + + Cancel + + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + updateExperiment({ + metrics: experiment.metrics, + }) + } else { + updateExperimentExposure(experiment.parameters.custom_exposure_filter ?? null) + } + }} + type="primary" + loading={experimentLoading} + data-attr="create-annotation-submit" + > + Save + +
    + } + > + + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx new file mode 100644 index 0000000000000..a0e903fdeab84 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx @@ -0,0 +1,391 @@ +import { LemonLabel } from '@posthog/lemon-ui' +import { LemonInput } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema' +import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { + commonActionFilterProps, + FunnelAggregationSelect, + FunnelAttributionSelect, + FunnelConversionWindowFilter, +} from './Selectors' + +export function SecondaryGoalFunnels({ metricIdx }: { metricIdx: number }): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic) + const { setExperiment, setFunnelsMetric } = useActions(experimentLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery + + return ( + <> +
    + Name (optional) + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.name + } + return experiment.secondary_metrics[metricIdx].name + })()} + onChange={(newName) => { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + name: newName, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx ? { ...metric, name: newName } : metric + ), + }) + } + }} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return queryNodeToFilter(currentMetric.funnels_query) + } + return experiment.secondary_metrics[metricIdx].filters + })()} + setFilters={({ actions, events, data_warehouse }: Partial): void => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.None + ) + + setFunnelsMetric({ + metricIdx, + series, + isSecondary: true, + }) + } else { + if (actions?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + actions, + events: undefined, + data_warehouse: undefined, + }, + } + : metric + ), + }) + } else if (events?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + events, + actions: undefined, + data_warehouse: undefined, + }, + } + : metric + ), + }) + } else if (data_warehouse?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + data_warehouse, + actions: undefined, + events: undefined, + }, + } + : metric + ), + }) + } + } + }} + typeKey="experiment-metric" + mathAvailability={MathAvailability.None} + buttonCopy="Add funnel step" + showSeriesIndicator={true} + seriesIndicatorType="numeric" + sortable={true} + showNestedArrow={true} + {...commonActionFilterProps} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return getHogQLValue( + currentMetric.funnels_query.aggregation_group_type_index ?? undefined, + currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined + ) + } + return getHogQLValue( + experiment.secondary_metrics[metricIdx].filters.aggregation_group_type_index, + (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType) + .funnel_aggregate_by_hogql + ) + })()} + onChange={(value) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelAggregateByHogQL: value, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + funnel_aggregate_by_hogql: value, + }, + } + : metric + ), + }) + } + }} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval + } + return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType) + .funnel_window_interval + })()} + funnelWindowIntervalUnit={(() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit + } + return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType) + .funnel_window_interval_unit + })()} + onFunnelWindowIntervalChange={(funnelWindowInterval) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelWindowInterval: funnelWindowInterval, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + funnel_window_interval: funnelWindowInterval, + }, + } + : metric + ), + }) + } + }} + onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + funnel_window_interval_unit: funnelWindowIntervalUnit || undefined, + }, + } + : metric + ), + }) + } + }} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + let breakdownAttributionType + let breakdownAttributionValue + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + breakdownAttributionType = + currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType + breakdownAttributionValue = + currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue + } else { + breakdownAttributionType = ( + experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType + ).breakdown_attribution_type + breakdownAttributionValue = ( + experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType + ).breakdown_attribution_value + } + + const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` = + !breakdownAttributionType + ? BreakdownAttributionType.FirstTouch + : breakdownAttributionType === BreakdownAttributionType.Step + ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}` + : breakdownAttributionType + + return currentValue + })()} + onChange={(value) => { + const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/') + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType, + breakdownAttributionValue: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : undefined, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + breakdown_attribution_type: + breakdownAttributionType as BreakdownAttributionType, + breakdown_attribution_value: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : 0, + }, + } + : metric + ), + }) + } + }} + stepsLength={(() => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query?.series?.length + } + return Math.max( + experiment.secondary_metrics[metricIdx].filters.actions?.length ?? 0, + experiment.secondary_metrics[metricIdx].filters.events?.length ?? 0, + experiment.secondary_metrics[metricIdx].filters.data_warehouse?.length ?? 0 + ) + })()} + /> + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const val = (experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery) + .funnels_query?.filterTestAccounts + return hasFilters ? !!val : false + } + return hasFilters + ? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts + : false + })()} + onChange={(checked: boolean) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setFunnelsMetric({ + metricIdx, + filterTestAccounts: checked, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + filter_test_accounts: checked, + }, + } + : metric + ), + }) + } + }} + fullWidth + /> +
    + {isExperimentRunning && ( + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + )} +
    + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.funnels_query + } + return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters) + })(), + showTable: false, + showLastComputation: true, + showLastComputationRefresh: false, + }} + readOnly + /> +
    + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx new file mode 100644 index 0000000000000..20aae645e6e1e --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx @@ -0,0 +1,204 @@ +import { LemonLabel } from '@posthog/lemon-ui' +import { LemonInput } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants' +import { LemonBanner } from 'lib/lemon-ui/LemonBanner' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' +import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { Query } from '~/queries/Query/Query' +import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema' +import { FilterType } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { commonActionFilterProps } from './Selectors' + +export function SecondaryGoalTrends({ metricIdx }: { metricIdx: number }): JSX.Element { + const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic) + const { setExperiment, setTrendsMetric } = useActions(experimentLogic) + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentTrendsQuery + + return ( + <> +
    + Name (optional) + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.name + } + return experiment.secondary_metrics[metricIdx].name + })()} + onChange={(newName) => { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setTrendsMetric({ + metricIdx, + name: newName, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx ? { ...metric, name: newName } : metric + ), + }) + } + }} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return queryNodeToFilter(currentMetric.count_query) + } + return experiment.secondary_metrics[metricIdx].filters + })()} + setFilters={({ actions, events, data_warehouse }: Partial): void => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + + setTrendsMetric({ + metricIdx, + series, + isSecondary: true, + }) + } else { + if (actions?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + actions, + events: undefined, + data_warehouse: undefined, + }, + } + : metric + ), + }) + } else if (events?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + events, + actions: undefined, + data_warehouse: undefined, + }, + } + : metric + ), + }) + } else if (data_warehouse?.length) { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + data_warehouse, + actions: undefined, + events: undefined, + }, + } + : metric + ), + }) + } + } + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + {...commonActionFilterProps} + /> +
    + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const val = currentMetric.count_query?.filterTestAccounts + return hasFilters ? !!val : false + } + return hasFilters + ? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts + : false + })()} + onChange={(checked: boolean) => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setTrendsMetric({ + metricIdx, + filterTestAccounts: checked, + isSecondary: true, + }) + } else { + setExperiment({ + secondary_metrics: experiment.secondary_metrics.map((metric, idx) => + idx === metricIdx + ? { + ...metric, + filters: { + ...metric.filters, + filter_test_accounts: checked, + }, + } + : metric + ), + }) + } + }} + fullWidth + /> +
    + {isExperimentRunning && ( + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + )} +
    + {/* :FLAG: CLEAN UP AFTER MIGRATION */} + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return currentMetric.count_query + } + return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters) + })(), + showTable: false, + showLastComputation: true, + showLastComputationRefresh: false, + }} + readOnly + /> +
    + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx new file mode 100644 index 0000000000000..ec540aa43c056 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx @@ -0,0 +1,38 @@ +import { LemonButton, LemonModal } from '@posthog/lemon-ui' +import { useValues } from 'kea' + +import { Experiment } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { ResultsQuery } from '../ExperimentView/components' + +export function SecondaryMetricChartModal({ + experimentId, + metricIdx, + isOpen, + onClose, +}: { + experimentId: Experiment['id'] + metricIdx: number + isOpen: boolean + onClose: () => void +}): JSX.Element { + const { secondaryMetricResults } = useValues(experimentLogic({ experimentId })) + const targetResults = secondaryMetricResults && secondaryMetricResults[metricIdx] + + return ( + + Close + + } + > + + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx new file mode 100644 index 0000000000000..14a8304b973e2 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx @@ -0,0 +1,137 @@ +import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { FEATURE_FLAGS } from 'lib/constants' + +import { Experiment, InsightType } from '~/types' + +import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic' +import { SecondaryGoalFunnels } from './SecondaryGoalFunnels' +import { SecondaryGoalTrends } from './SecondaryGoalTrends' + +export function SecondaryMetricModal({ + experimentId, + metricIdx, + isOpen, + onClose, +}: { + experimentId: Experiment['id'] + metricIdx: number + isOpen: boolean + onClose: () => void +}): JSX.Element { + const { experiment, experimentLoading, getSecondaryMetricType, featureFlags } = useValues( + experimentLogic({ experimentId }) + ) + const { setExperiment, updateExperiment } = useActions(experimentLogic({ experimentId })) + const metricType = getSecondaryMetricType(metricIdx) + + return ( + + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const newMetricsSecondary = experiment.metrics_secondary.filter( + (_, idx) => idx !== metricIdx + ) + setExperiment({ + metrics_secondary: newMetricsSecondary, + }) + updateExperiment({ + metrics_secondary: newMetricsSecondary, + }) + } else { + const newSecondaryMetrics = experiment.secondary_metrics.filter( + (_, idx) => idx !== metricIdx + ) + setExperiment({ + secondary_metrics: newSecondaryMetrics, + }) + updateExperiment({ + secondary_metrics: newSecondaryMetrics, + }) + } + }} + > + Delete + +
    + + Cancel + + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + updateExperiment({ + metrics_secondary: experiment.metrics_secondary, + }) + } else { + updateExperiment({ + secondary_metrics: experiment.secondary_metrics, + }) + } + }} + type="primary" + loading={experimentLoading} + data-attr="create-annotation-submit" + > + Save + +
    +
    + } + > +
    + Metric type + { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + setExperiment({ + ...experiment, + metrics_secondary: [ + ...experiment.metrics_secondary.slice(0, metricIdx), + newMetricType === InsightType.TRENDS + ? getDefaultTrendsMetric() + : getDefaultFunnelsMetric(), + ...experiment.metrics_secondary.slice(metricIdx + 1), + ], + }) + } else { + setExperiment({ + ...experiment, + secondary_metrics: [ + ...experiment.secondary_metrics.slice(0, metricIdx), + newMetricType === InsightType.TRENDS + ? { name: '', filters: getDefaultFilters(InsightType.TRENDS, undefined) } + : { name: '', filters: getDefaultFilters(InsightType.FUNNELS, undefined) }, + ...experiment.secondary_metrics.slice(metricIdx + 1), + ], + }) + } + }} + options={[ + { value: InsightType.TRENDS, label: Trends }, + { value: InsightType.FUNNELS, label: Funnels }, + ]} + /> +
    + {metricType === InsightType.TRENDS ? ( + + ) : ( + + )} + + ) +} diff --git a/frontend/src/scenes/experiments/Metrics/Selectors.tsx b/frontend/src/scenes/experiments/Metrics/Selectors.tsx new file mode 100644 index 0000000000000..49e11eba7adde --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/Selectors.tsx @@ -0,0 +1,253 @@ +import { IconInfo } from '@posthog/icons' +import { LemonInput, LemonSelect, LemonSelectOption, LemonSelectSection, Link } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { HogQLEditor } from 'lib/components/HogQLEditor/HogQLEditor' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { groupsAccessLogic } from 'lib/introductions/groupsAccessLogic' +import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { capitalizeFirstLetter, pluralize } from 'lib/utils' +import { GroupIntroductionFooter } from 'scenes/groups/GroupsIntroduction' +import { FUNNEL_STEP_COUNT_LIMIT } from 'scenes/insights/EditorFilters/FunnelsQuerySteps' +import { TIME_INTERVAL_BOUNDS } from 'scenes/insights/views/Funnels/FunnelConversionWindowFilter' + +import { groupsModel } from '~/models/groupsModel' +import { BreakdownAttributionType, FunnelConversionWindowTimeUnit, StepOrderValue } from '~/types' + +export const commonActionFilterProps = { + actionsTaxonomicGroupTypes: [ + TaxonomicFilterGroupType.Events, + TaxonomicFilterGroupType.Actions, + TaxonomicFilterGroupType.DataWarehouse, + ], + propertiesTaxonomicGroupTypes: [ + TaxonomicFilterGroupType.EventProperties, + TaxonomicFilterGroupType.PersonProperties, + TaxonomicFilterGroupType.EventFeatureFlags, + TaxonomicFilterGroupType.Cohorts, + TaxonomicFilterGroupType.Elements, + TaxonomicFilterGroupType.SessionProperties, + TaxonomicFilterGroupType.HogQLExpression, + TaxonomicFilterGroupType.DataWarehouseProperties, + TaxonomicFilterGroupType.DataWarehousePersonProperties, + ], +} + +// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/filters/AggregationSelect.tsx +export function FunnelAggregationSelect({ + value, + onChange, +}: { + value: string + onChange: (value: string) => void +}): JSX.Element { + const { groupTypes, aggregationLabel } = useValues(groupsModel) + const { needsUpgradeForGroups, canStartUsingGroups } = useValues(groupsAccessLogic) + + const UNIQUE_USERS = 'person_id' + const baseValues = [UNIQUE_USERS] + const optionSections: LemonSelectSection[] = [ + { + title: 'Event Aggregation', + options: [ + { + value: UNIQUE_USERS, + label: 'Unique users', + }, + ], + }, + ] + if (needsUpgradeForGroups || canStartUsingGroups) { + // if (false) { + optionSections[0].footer = + } else { + Array.from(groupTypes.values()).forEach((groupType) => { + baseValues.push(`$group_${groupType.group_type_index}`) + optionSections[0].options.push({ + value: `$group_${groupType.group_type_index}`, + label: `Unique ${aggregationLabel(groupType.group_type_index).plural}`, + }) + }) + } + + baseValues.push(`properties.$session_id`) + optionSections[0].options.push({ + value: 'properties.$session_id', + label: `Unique sessions`, + }) + optionSections[0].options.push({ + label: 'Custom HogQL expression', + options: [ + { + // This is a bit of a hack so that the HogQL option is only highlighted as active when the user has + // set a custom value (because actually _all_ the options are HogQL) + value: !value || baseValues.includes(value) ? '' : value, + label: {value}, + labelInMenu: function CustomHogQLOptionWrapped({ onSelect }) { + return ( + // eslint-disable-next-line react/forbid-dom-props +
    + +
    + ) + }, + }, + ], + }) + + return ( +
    + Aggregating by + +
    + ) +} + +// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx +export function FunnelConversionWindowFilter({ + funnelWindowInterval, + funnelWindowIntervalUnit, + onFunnelWindowIntervalChange, + onFunnelWindowIntervalUnitChange, +}: { + funnelWindowInterval: number | undefined + funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit | undefined + onFunnelWindowIntervalChange: (funnelWindowInterval: number | undefined) => void + onFunnelWindowIntervalUnitChange: (funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit) => void +}): JSX.Element { + const options: LemonSelectOption[] = Object.keys(TIME_INTERVAL_BOUNDS).map( + (unit) => ({ + label: capitalizeFirstLetter(pluralize(funnelWindowInterval ?? 7, unit, `${unit}s`, false)), + value: unit as FunnelConversionWindowTimeUnit, + }) + ) + const intervalBounds = TIME_INTERVAL_BOUNDS[funnelWindowIntervalUnit ?? FunnelConversionWindowTimeUnit.Day] + + return ( +
    + + Conversion window limit + + Recommended! Limit to participants that converted within a specific time frame. + Participants that do not convert in this time frame will be considered as drop-offs. + + } + > + + + +
    + + +
    +
    + ) +} + +// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/EditorFilters/AttributionFilter.tsx +export function FunnelAttributionSelect({ + value, + onChange, + stepsLength, +}: { + value: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` + onChange: (value: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}`) => void + stepsLength: number +}): JSX.Element { + const funnelOrderType = undefined + + return ( +
    +
    + Attribution type + +
    + When breaking down funnels, it's possible that the same properties don't exist on every + event. For example, if you want to break down by browser on a funnel that contains both + frontend and backend events. +
    +
    + In this case, you can choose from which step the properties should be selected from by + modifying the attribution type. There are four modes to choose from: +
    +
      +
    • First touchpoint: the first property value seen in any of the steps is chosen.
    • +
    • Last touchpoint: the last property value seen from all steps is chosen.
    • +
    • + All steps: the property value must be seen in all steps to be considered in the + funnel. +
    • +
    • Specific step: only the property value seen at the selected step is chosen.
    • +
    +
    + Read more in the{' '} + + documentation. + +
    +
    + } + > + + +
    + ({ + value: `${BreakdownAttributionType.Step}/${stepIndex}` as const, + label: `Step ${stepIndex + 1}`, + hidden: stepIndex >= stepsLength, + })), + hidden: funnelOrderType === StepOrderValue.UNORDERED, + }, + ]} + onChange={onChange} + dropdownMaxContentWidth={true} + data-attr="breakdown-attributions" + /> +
    + ) +} diff --git a/frontend/src/scenes/experiments/constants.ts b/frontend/src/scenes/experiments/constants.ts index 4c40c381ea4df..7ce7c3a49957f 100644 --- a/frontend/src/scenes/experiments/constants.ts +++ b/frontend/src/scenes/experiments/constants.ts @@ -2,6 +2,12 @@ import { InsightShortId } from '~/types' // :TRICKY: `new-` prefix indicates an unsaved insight and slightly alters // behaviour of insight related logics -export const EXPERIMENT_INSIGHT_ID = 'new-experiment-insight' as InsightShortId -export const EXPERIMENT_EXPOSURE_INSIGHT_ID = 'new-experiment-exposure-insight' as InsightShortId export const SECONDARY_METRIC_INSIGHT_ID = 'new-secondary-metric-insight' as InsightShortId + +export enum MetricInsightId { + Trends = 'new-experiment-trends-metric', + TrendsExposure = 'new-experiment-trends-exposure', + Funnels = 'new-experiment-funnels-metric', + SecondaryTrends = 'new-experiment-secondary-trends', + SecondaryFunnels = 'new-experiment-secondary-funnels', +} diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index 5ec2c1a5fe246..230c97ec654f0 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -16,7 +16,6 @@ import { ReactElement } from 'react' import { validateFeatureFlagKey } from 'scenes/feature-flags/featureFlagLogic' import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic' import { insightDataLogic } from 'scenes/insights/insightDataLogic' -import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilters' import { sceneLogic } from 'scenes/sceneLogic' import { Scene } from 'scenes/sceneTypes' @@ -26,41 +25,41 @@ import { urls } from 'scenes/urls' import { cohortsModel } from '~/models/cohortsModel' import { groupsModel } from '~/models/groupsModel' -import { filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' -import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' +import { performQuery } from '~/queries/query' import { CachedExperimentFunnelsQueryResponse, CachedExperimentTrendsQueryResponse, + ExperimentFunnelsQuery, ExperimentTrendsQuery, - FunnelsQuery, - InsightVizNode, NodeKind, - TrendsQuery, } from '~/queries/schema' -import { isFunnelsQuery } from '~/queries/utils' import { ActionFilter as ActionFilterType, Breadcrumb, + BreakdownAttributionType, + ChartDisplayType, CohortType, CountPerActorMathType, + EntityTypes, Experiment, ExperimentResults, FeatureFlagType, FilterType, + FunnelConversionWindowTimeUnit, FunnelExperimentVariant, FunnelStep, FunnelVizType, InsightType, MultivariateFlagVariant, PropertyMathType, - SecondaryExperimentMetric, SecondaryMetricResults, SignificanceCode, TrendExperimentVariant, TrendResult, + TrendsFilterType, } from '~/types' -import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from './constants' +import { MetricInsightId } from './constants' import type { experimentLogicType } from './experimentLogicType' import { experimentsLogic } from './experimentsLogic' import { holdoutsLogic } from './holdoutsLogic' @@ -73,6 +72,7 @@ const NEW_EXPERIMENT: Experiment = { feature_flag_key: '', filters: {}, metrics: [], + metrics_secondary: [], parameters: { feature_flag_variants: [ { key: 'control', rollout_percentage: 50 }, @@ -105,12 +105,14 @@ export interface ExperimentResultCalculationError { statusCode: number } +// :FLAG: CLEAN UP AFTER MIGRATION export interface CachedSecondaryMetricExperimentFunnelsQueryResponse extends CachedExperimentFunnelsQueryResponse { filters?: { insight?: InsightType } } +// :FLAG: CLEAN UP AFTER MIGRATION export interface CachedSecondaryMetricExperimentTrendsQueryResponse extends CachedExperimentTrendsQueryResponse { filters?: { insight?: InsightType @@ -129,16 +131,20 @@ export const experimentLogic = kea([ ['aggregationLabel', 'groupTypes', 'showGroupsOptions'], sceneLogic, ['activeScene'], - funnelDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }), - ['results as funnelResults', 'conversionMetrics'], - trendsDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }), - ['results as trendResults'], - insightDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }), - ['insightDataLoading as goalInsightDataLoading'], featureFlagLogic, ['featureFlags'], holdoutsLogic, ['holdouts'], + // Hook the insight state to get the results for the sample size estimation + funnelDataLogic({ dashboardItemId: MetricInsightId.Funnels }), + ['results as funnelResults', 'conversionMetrics'], + trendsDataLogic({ dashboardItemId: MetricInsightId.Trends }), + ['results as trendResults'], + // Hook into the loading state of the metric insight + insightDataLogic({ dashboardItemId: MetricInsightId.Trends }), + ['insightDataLoading as trendMetricInsightLoading'], + insightDataLogic({ dashboardItemId: MetricInsightId.Funnels }), + ['insightDataLoading as funnelMetricInsightLoading'], ], actions: [ experimentsLogic, @@ -156,25 +162,15 @@ export const experimentLogic = kea([ 'reportExperimentVariantScreenshotUploaded', 'reportExperimentResultsLoadingTimeout', 'reportExperimentReleaseConditionsViewed', + 'reportExperimentHoldoutAssigned', ], - insightDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }), - ['setQuery'], - insightVizDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }), - ['updateQuerySource'], - insightDataLogic({ dashboardItemId: EXPERIMENT_EXPOSURE_INSIGHT_ID }), - ['setQuery as setExposureQuery'], - insightVizDataLogic({ dashboardItemId: EXPERIMENT_EXPOSURE_INSIGHT_ID }), - ['updateQuerySource as updateExposureQuerySource'], ], })), actions({ setExperimentMissing: true, setExperiment: (experiment: Partial) => ({ experiment }), createExperiment: (draft?: boolean) => ({ draft }), - setExperimentFeatureFlagKeyFromName: true, - setNewExperimentInsight: (filters?: Partial) => ({ filters }), setExperimentType: (type?: string) => ({ type }), - setExperimentExposureInsight: (filters?: Partial) => ({ filters }), removeExperimentGroup: (idx: number) => ({ idx }), setEditExperiment: (editing: boolean) => ({ editing }), setExperimentResultCalculationError: (error: ExperimentResultCalculationError) => ({ error }), @@ -183,7 +179,6 @@ export const experimentLogic = kea([ updateExperimentGoal: (filters: Partial) => ({ filters }), updateExperimentCollectionGoal: true, updateExperimentExposure: (filters: Partial | null) => ({ filters }), - updateExperimentSecondaryMetrics: (metrics: SecondaryExperimentMetric[]) => ({ metrics }), changeExperimentStartDate: (startDate: string) => ({ startDate }), launchExperiment: true, endExperiment: true, @@ -191,10 +186,6 @@ export const experimentLogic = kea([ archiveExperiment: true, resetRunningExperiment: true, checkFlagImplementationWarning: true, - openExperimentGoalModal: true, - closeExperimentGoalModal: true, - openExperimentExposureModal: true, - closeExperimentExposureModal: true, openExperimentCollectionGoalModal: true, closeExperimentCollectionGoalModal: true, openShipVariantModal: true, @@ -203,7 +194,70 @@ export const experimentLogic = kea([ closeDistributionModal: true, openReleaseConditionsModal: true, closeReleaseConditionsModal: true, - updateExperimentVariantImages: (variantPreviewMediaIds: Record) => ({ variantPreviewMediaIds }), + updateExperimentVariantImages: (variantPreviewMediaIds: Record) => ({ + variantPreviewMediaIds, + }), + setTrendsMetric: ({ + metricIdx, + name, + series, + filterTestAccounts, + isSecondary = false, + }: { + metricIdx: number + name?: string + series?: any[] + filterTestAccounts?: boolean + isSecondary?: boolean + }) => ({ metricIdx, name, series, filterTestAccounts, isSecondary }), + setTrendsExposureMetric: ({ + metricIdx, + name, + series, + filterTestAccounts, + }: { + metricIdx: number + name?: string + series?: any[] + filterTestAccounts?: boolean + }) => ({ metricIdx, name, series, filterTestAccounts }), + setFunnelsMetric: ({ + metricIdx, + name, + series, + filterTestAccounts, + breakdownAttributionType, + breakdownAttributionValue, + funnelWindowInterval, + funnelWindowIntervalUnit, + aggregation_group_type_index, + funnelAggregateByHogQL, + isSecondary = false, + }: { + metricIdx: number + name?: string + series?: any[] + filterTestAccounts?: boolean + breakdownAttributionType?: BreakdownAttributionType + breakdownAttributionValue?: number + funnelWindowInterval?: number + funnelWindowIntervalUnit?: string + aggregation_group_type_index?: number + funnelAggregateByHogQL?: string + isSecondary?: boolean + }) => ({ + metricIdx, + name, + series, + filterTestAccounts, + breakdownAttributionType, + breakdownAttributionValue, + funnelWindowInterval, + funnelWindowIntervalUnit, + aggregation_group_type_index, + funnelAggregateByHogQL, + isSecondary, + }), setTabKey: (tabKey: string) => ({ tabKey }), }), reducers({ @@ -211,15 +265,6 @@ export const experimentLogic = kea([ { ...NEW_EXPERIMENT } as Experiment, { setExperiment: (state, { experiment }) => { - if (experiment.filters) { - return { ...state, ...experiment, filters: experiment.filters } - } - - // assuming setExperiment isn't called with new filters & parameters at the same time - if (experiment.parameters) { - const newParameters = { ...state?.parameters, ...experiment.parameters } - return { ...state, ...experiment, parameters: newParameters } - } return { ...state, ...experiment } }, addExperimentGroup: (state) => { @@ -271,6 +316,89 @@ export const experimentLogic = kea([ }, } }, + setTrendsMetric: (state, { metricIdx, name, series, filterTestAccounts, isSecondary }) => { + const metricsKey = isSecondary ? 'metrics_secondary' : 'metrics' + const metrics = [...(state?.[metricsKey] || [])] + const metric = metrics[metricIdx] + + metrics[metricIdx] = { + ...metric, + ...(name !== undefined && { name }), + count_query: { + ...(metric as ExperimentTrendsQuery).count_query, + ...(series && { series }), + ...(filterTestAccounts !== undefined && { filterTestAccounts }), + }, + } as ExperimentTrendsQuery + + return { + ...state, + [metricsKey]: metrics, + } + }, + setTrendsExposureMetric: (state, { metricIdx, name, series, filterTestAccounts }) => { + const metrics = [...(state?.metrics || [])] + const metric = metrics[metricIdx] + + metrics[metricIdx] = { + ...metric, + ...(name !== undefined && { name }), + exposure_query: { + ...(metric as ExperimentTrendsQuery).exposure_query, + ...(series && { series }), + ...(filterTestAccounts !== undefined && { filterTestAccounts }), + }, + } as ExperimentTrendsQuery + + return { + ...state, + metrics, + } + }, + setFunnelsMetric: ( + state, + { + metricIdx, + name, + series, + filterTestAccounts, + breakdownAttributionType, + breakdownAttributionValue, + funnelWindowInterval, + funnelWindowIntervalUnit, + aggregation_group_type_index, + funnelAggregateByHogQL, + isSecondary, + } + ) => { + const metricsKey = isSecondary ? 'metrics_secondary' : 'metrics' + const metrics = [...(state?.[metricsKey] || [])] + const metric = metrics[metricIdx] + + metrics[metricIdx] = { + ...metric, + ...(name !== undefined && { name }), + funnels_query: { + ...(metric as ExperimentFunnelsQuery).funnels_query, + ...(series && { series }), + ...(filterTestAccounts !== undefined && { filterTestAccounts }), + ...(aggregation_group_type_index !== undefined && { aggregation_group_type_index }), + funnelsFilter: { + ...(metric as ExperimentFunnelsQuery).funnels_query.funnelsFilter, + ...(breakdownAttributionType && { breakdownAttributionType }), + ...(breakdownAttributionValue !== undefined && { breakdownAttributionValue }), + ...(funnelWindowInterval !== undefined && { funnelWindowInterval }), + ...(funnelWindowIntervalUnit && { funnelWindowIntervalUnit }), + ...(funnelAggregateByHogQL !== undefined && { funnelAggregateByHogQL }), + }, + }, + } as ExperimentFunnelsQuery + + return { + ...state, + [metricsKey]: metrics, + } + }, }, ], experimentMissing: [ @@ -285,22 +413,6 @@ export const experimentLogic = kea([ setEditExperiment: (_, { editing }) => editing, }, ], - changingGoalMetric: [ - false, - { - updateExperimentGoal: () => true, - updateExperimentExposure: () => true, - changeExperimentStartDate: () => true, - loadExperimentResults: () => false, - }, - ], - changingSecondaryMetrics: [ - false, - { - updateExperimentSecondaryMetrics: () => true, - loadSecondaryMetricResults: () => false, - }, - ], experimentResultCalculationError: [ null as ExperimentResultCalculationError | null, { @@ -313,27 +425,6 @@ export const experimentLogic = kea([ setFlagImplementationWarning: (_, { warning }) => warning, }, ], - // TODO: delete with the old UI - exposureAndSampleSize: [ - { exposure: 0, sampleSize: 0 } as { exposure: number; sampleSize: number }, - { - setExposureAndSampleSize: (_, { exposure, sampleSize }) => ({ exposure, sampleSize }), - }, - ], - isExperimentGoalModalOpen: [ - false, - { - openExperimentGoalModal: () => true, - closeExperimentGoalModal: () => false, - }, - ], - isExperimentExposureModalOpen: [ - false, - { - openExperimentExposureModal: () => true, - closeExperimentExposureModal: () => false, - }, - ], isExperimentCollectionGoalModalOpen: [ false, { @@ -467,79 +558,6 @@ export const experimentLogic = kea([ setExperimentType: async ({ type }) => { actions.setExperiment({ type: type }) }, - setNewExperimentInsight: async ({ filters }) => { - let newInsightFilters - const aggregationGroupTypeIndex = values.experiment.parameters?.aggregation_group_type_index - if (filters?.insight === InsightType.TRENDS) { - const groupAggregation = - aggregationGroupTypeIndex !== undefined - ? { math: 'unique_group', math_group_type_index: aggregationGroupTypeIndex } - : {} - const eventAddition = - filters?.actions || filters?.events - ? {} - : { events: [{ ...getDefaultEvent(), ...groupAggregation }] } - newInsightFilters = cleanFilters({ - insight: InsightType.TRENDS, - date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), - date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), - ...eventAddition, - ...filters, - }) - } else { - newInsightFilters = cleanFilters({ - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), - date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), - layout: FunnelLayout.horizontal, - aggregation_group_type_index: aggregationGroupTypeIndex, - ...filters, - }) - } - - // This allows switching between insight types. It's necessary as `updateQuerySource` merges - // the new query with any existing query and that causes validation problems when there are - // unsupported properties in the now merged query. - const newQuery = filtersToQueryNode(newInsightFilters) - if (newInsightFilters?.insight === InsightType.FUNNELS) { - ;(newQuery as TrendsQuery).trendsFilter = undefined - } else { - ;(newQuery as FunnelsQuery).funnelsFilter = undefined - } - - // TRICKY: We always know what the group type index should be for funnel queries, so we don't care - // what the previous value was. Hence, instead of a partial update with `updateQuerySource`, we always - // explicitly set it to what it should be - if (isFunnelsQuery(newQuery)) { - newQuery.aggregation_group_type_index = aggregationGroupTypeIndex - } - - actions.updateQuerySource(newQuery) - }, - // sync form value `filters` with query - setQuery: ({ query }) => { - actions.setExperiment({ filters: queryNodeToFilter((query as InsightVizNode).source) }) - }, - setExperimentExposureInsight: async ({ filters }) => { - const newInsightFilters = cleanFilters({ - insight: InsightType.TRENDS, - date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), - date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), - ...filters, - }) - - actions.updateExposureQuerySource(filtersToQueryNode(newInsightFilters)) - }, - // sync form value `filters` with query - setExposureQuery: ({ query }) => { - actions.setExperiment({ - parameters: { - custom_exposure_filter: queryNodeToFilter((query as InsightVizNode).source), - feature_flag_variants: values.experiment?.parameters?.feature_flag_variants, - }, - }) - }, loadExperimentSuccess: async ({ experiment }) => { experiment && actions.reportExperimentViewed(experiment) @@ -578,18 +596,13 @@ export const experimentLogic = kea([ }) const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values - if (!minimumDetectableEffect) { - eventUsageLogic.actions.reportExperimentInsightLoadFailed() - return lemonToast.error( - 'Failed to load insight. Experiment cannot be saved without this value. Try changing the experiment goal.' - ) - } const filtersToUpdate = { ...filters } delete filtersToUpdate.properties actions.updateExperiment({ filters: filtersToUpdate, + metrics: values.experiment.metrics, parameters: { ...values.experiment?.parameters, recommended_running_time: recommendedRunningTime, @@ -597,7 +610,6 @@ export const experimentLogic = kea([ minimum_detectable_effect: minimumDetectableEffect, }, }) - actions.closeExperimentGoalModal() }, updateExperimentCollectionGoal: async () => { const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values @@ -614,25 +626,12 @@ export const experimentLogic = kea([ }, updateExperimentExposure: async ({ filters }) => { actions.updateExperiment({ + metrics: values.experiment.metrics, parameters: { custom_exposure_filter: filters ?? undefined, feature_flag_variants: values.experiment?.parameters?.feature_flag_variants, }, }) - actions.closeExperimentExposureModal() - }, - updateExperimentSecondaryMetrics: async ({ metrics }) => { - actions.updateExperiment({ secondary_metrics: metrics }) - }, - closeExperimentGoalModal: () => { - if (values.experimentValuesChangedLocally) { - actions.loadExperiment() - } - }, - closeExperimentExposureModal: () => { - if (values.experimentValuesChangedLocally) { - actions.loadExperiment() - } }, closeExperimentCollectionGoalModal: () => { if (values.experimentValuesChangedLocally) { @@ -648,15 +647,8 @@ export const experimentLogic = kea([ }, updateExperimentSuccess: async ({ experiment }) => { actions.updateExperiments(experiment) - if (values.changingGoalMetric) { - actions.loadExperimentResults() - } - if (values.changingSecondaryMetrics && values.experiment?.start_date) { - actions.loadSecondaryMetricResults() - } - if (values.experiment?.start_date) { - actions.loadExperimentResults() - } + actions.loadExperimentResults() + actions.loadSecondaryMetricResults() }, setExperiment: async ({ experiment }) => { const experimentEntitiesChanged = @@ -729,12 +721,6 @@ export const experimentLogic = kea([ } } }, - openExperimentGoalModal: async () => { - actions.setNewExperimentInsight(values.experiment?.filters) - }, - openExperimentExposureModal: async () => { - actions.setExperimentExposureInsight(values.experiment?.parameters?.custom_exposure_filter) - }, createExposureCohortSuccess: ({ exposureCohort }) => { if (exposureCohort && exposureCohort.id !== 'new') { cohortsModel.actions.cohortCreated(exposureCohort) @@ -821,18 +807,19 @@ export const experimentLogic = kea([ | null > => { try { + // :FLAG: CLEAN UP AFTER MIGRATION if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { - const query = values.experiment.metrics[0].query + // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment + const queryWithExperimentId = { + ...values.experiment.metrics[0], + experiment_id: values.experimentId, + } - const response: ExperimentResults = await api.create( - `api/projects/${values.currentTeamId}/query`, - { query } - ) + const response = await performQuery(queryWithExperimentId, undefined, refresh) return { ...response, fakeInsightId: Math.random().toString(36).substring(2, 15), - last_refresh: response.last_refresh || '', } as unknown as CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse } @@ -846,7 +833,13 @@ export const experimentLogic = kea([ last_refresh: response.last_refresh, } } catch (error: any) { - actions.setExperimentResultCalculationError({ detail: error.detail, statusCode: error.status }) + let errorDetail = error.detail + // :HANDLE FLAG: CLEAN UP AFTER MIGRATION + if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const errorDetailMatch = error.detail.match(/\{.*\}/) + errorDetail = errorDetailMatch[0] + } + actions.setExperimentResultCalculationError({ detail: errorDetail, statusCode: error.status }) if (error.status === 504) { actions.reportExperimentResultsLoadingTimeout(values.experimentId) } @@ -869,15 +862,17 @@ export const experimentLogic = kea([ | null > => { if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { - const secondaryMetrics = - values.experiment?.metrics?.filter((metric) => metric.type === 'secondary') || [] - return (await Promise.all( - secondaryMetrics.map(async (metric) => { + values.experiment?.metrics_secondary.map(async (metric) => { try { + // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment + const queryWithExperimentId = { + ...metric, + experiment_id: values.experimentId, + } const response: ExperimentResults = await api.create( `api/projects/${values.currentTeamId}/query`, - { query: metric.query } + { query: queryWithExperimentId, refresh: 'lazy_async' } ) return { @@ -970,16 +965,29 @@ export const experimentLogic = kea([ () => [(_, props) => props.experimentId ?? 'new'], (experimentId): Experiment['id'] => experimentId, ], - experimentInsightType: [ + getMetricType: [ (s) => [s.experiment, s.featureFlags], - (experiment, featureFlags): InsightType => { - if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { - const query = experiment?.metrics?.[0]?.query - return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS - } + (experiment, featureFlags) => + (metricIdx: number = 0) => { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const query = experiment?.metrics?.[metricIdx] + return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS + } - return experiment?.filters?.insight || InsightType.FUNNELS - }, + return experiment?.filters?.insight || InsightType.FUNNELS + }, + ], + getSecondaryMetricType: [ + (s) => [s.experiment, s.featureFlags], + (experiment, featureFlags) => + (metricIdx: number = 0) => { + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + const query = experiment?.metrics_secondary?.[metricIdx] + return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS + } + + return experiment?.secondary_metrics?.[metricIdx]?.filters?.insight || InsightType.FUNNELS + }, ], isExperimentRunning: [ (s) => [s.experiment], @@ -1028,7 +1036,7 @@ export const experimentLogic = kea([ let entities: { math?: string }[] = [] if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { - const query = experiment?.metrics?.[0]?.query as ExperimentTrendsQuery + const query = experiment?.metrics?.[0] as ExperimentTrendsQuery if (!query) { return undefined } @@ -1059,12 +1067,12 @@ export const experimentLogic = kea([ }, ], minimumDetectableEffect: [ - (s) => [s.experiment, s.experimentInsightType, s.conversionMetrics, s.trendResults], - (newExperiment, experimentInsightType, conversionMetrics, trendResults): number => { + (s) => [s.experiment, s.getMetricType, s.conversionMetrics, s.trendResults], + (newExperiment, getMetricType, conversionMetrics, trendResults): number => { return ( newExperiment?.parameters?.minimum_detectable_effect || // :KLUDGE: extracted the method due to difficulties with logic tests - getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults) || + getMinimumDetectableEffect(getMetricType(0), conversionMetrics, trendResults) || 0 ) }, @@ -1175,7 +1183,7 @@ export const experimentLogic = kea([ (s) => [ s.experiment, s.variants, - s.experimentInsightType, + s.getMetricType, s.funnelResults, s.conversionMetrics, s.expectedRunningTime, @@ -1186,7 +1194,7 @@ export const experimentLogic = kea([ ( experiment, variants, - experimentInsightType, + getMetricType, funnelResults, conversionMetrics, expectedRunningTime, @@ -1194,7 +1202,7 @@ export const experimentLogic = kea([ minimumSampleSizePerVariant, recommendedExposureForCountData ): number => { - if (experimentInsightType === InsightType.FUNNELS) { + if (getMetricType(0) === InsightType.FUNNELS) { const currentDuration = dayjs().diff(dayjs(experiment?.start_date), 'hour') const funnelEntrants = funnelResults?.[0]?.count @@ -1282,14 +1290,15 @@ export const experimentLogic = kea([ | CachedSecondaryMetricExperimentFunnelsQueryResponse | CachedSecondaryMetricExperimentTrendsQueryResponse | null, - variantKey: string + variantKey: string, + metricType: InsightType ): [number, number] | null => { const credibleInterval = experimentResults?.credible_intervals?.[variantKey] if (!credibleInterval) { return null } - if (experimentResults.filters?.insight === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { const controlVariant = (experimentResults.variants as FunnelExperimentVariant[]).find( ({ key }) => key === 'control' ) as FunnelExperimentVariant @@ -1321,8 +1330,8 @@ export const experimentLogic = kea([ }, ], getIndexForVariant: [ - (s) => [s.experimentInsightType], - (experimentInsightType) => + (s) => [s.getMetricType], + (getMetricType) => ( experimentResults: | Partial @@ -1338,7 +1347,7 @@ export const experimentLogic = kea([ } let index = -1 - if (experimentInsightType === InsightType.FUNNELS) { + if (getMetricType(0) === InsightType.FUNNELS) { // Funnel Insight is displayed in order of decreasing count index = (Array.isArray(experimentResults.insight) ? [...experimentResults.insight] : []) .sort((a, b) => { @@ -1360,7 +1369,7 @@ export const experimentLogic = kea([ } const result = index === -1 ? null : index - if (result !== null && experimentInsightType === InsightType.FUNNELS) { + if (result !== null && getMetricType(0) === InsightType.FUNNELS) { return result + 1 } return result @@ -1479,16 +1488,17 @@ export const experimentLogic = kea([ }, ], tabularExperimentResults: [ - (s) => [s.experiment, s.experimentResults, s.experimentInsightType], - (experiment, experimentResults, experimentInsightType): any => { + (s) => [s.experiment, s.experimentResults, s.getMetricType], + (experiment, experimentResults, getMetricType): any => { const tabularResults = [] + const metricType = getMetricType(0) if (experimentResults) { for (const variantObj of experimentResults.variants) { - if (experimentInsightType === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { const { key, success_count, failure_count } = variantObj as FunnelExperimentVariant tabularResults.push({ key, success_count, failure_count }) - } else if (experimentInsightType === InsightType.TRENDS) { + } else if (metricType === InsightType.TRENDS) { const { key, count, exposure, absolute_exposure } = variantObj as TrendExperimentVariant tabularResults.push({ key, count, exposure, absolute_exposure }) } @@ -1501,9 +1511,9 @@ export const experimentLogic = kea([ continue } - if (experimentInsightType === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { tabularResults.push({ key, success_count: null, failure_count: null }) - } else if (experimentInsightType === InsightType.TRENDS) { + } else if (metricType === InsightType.TRENDS) { tabularResults.push({ key, count: null, exposure: null, absolute_exposure: null }) } } @@ -1569,9 +1579,9 @@ export const experimentLogic = kea([ }, ], funnelResultsPersonsTotal: [ - (s) => [s.experimentResults, s.experimentInsightType], - (experimentResults: ExperimentResults['result'], experimentInsightType: InsightType): number => { - if (experimentInsightType !== InsightType.FUNNELS || !experimentResults?.insight) { + (s) => [s.experimentResults, s.getMetricType], + (experimentResults, getMetricType): number => { + if (getMetricType(0) !== InsightType.FUNNELS || !experimentResults?.insight) { return 0 } @@ -1614,8 +1624,13 @@ export const experimentLogic = kea([ }, ], hasGoalSet: [ - (s) => [s.experiment], - (experiment): boolean => { + (s) => [s.experiment, s.featureFlags], + (experiment, featureFlags): boolean => { + // :FLAG: CLEAN UP AFTER MIGRATION + if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) { + return !!experiment.metrics[0] + } + const filters = experiment?.filters return !!( (filters?.actions && filters.actions.length > 0) || @@ -1671,3 +1686,110 @@ function percentageDistribution(variantCount: number): number[] { percentages[variantCount - 1] = percentageRounded - delta return percentages } + +export function getDefaultFilters(insightType: InsightType, aggregationGroupTypeIndex: number | undefined): FilterType { + let newInsightFilters + if (insightType === InsightType.TRENDS) { + const groupAggregation = + aggregationGroupTypeIndex !== undefined + ? { math: 'unique_group', math_group_type_index: aggregationGroupTypeIndex } + : {} + + newInsightFilters = cleanFilters({ + insight: InsightType.TRENDS, + events: [{ ...getDefaultEvent(), ...groupAggregation }], + date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + display: ChartDisplayType.ActionsLineGraph, + entity: EntityTypes.EVENTS, + filter_test_accounts: true, + } as TrendsFilterType) + } else { + newInsightFilters = cleanFilters({ + insight: InsightType.FUNNELS, + events: [ + { + id: '$pageview', + name: '$pageview', + type: 'events', + order: 0, + }, + { + id: '$pageview', + name: 'Pageview', + type: 'events', + order: 1, + }, + ], + funnel_viz_type: FunnelVizType.Steps, + date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + layout: FunnelLayout.horizontal, + aggregation_group_type_index: aggregationGroupTypeIndex, + funnel_window_interval: 14, + funnel_window_interval_unit: FunnelConversionWindowTimeUnit.Day, + filter_test_accounts: true, + }) + } + + return newInsightFilters +} + +export function getDefaultTrendsMetric(): ExperimentTrendsQuery { + return { + kind: NodeKind.ExperimentTrendsQuery, + count_query: { + kind: NodeKind.TrendsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$pageview', + event: '$pageview', + }, + ], + interval: 'day', + dateRange: { + date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + explicitDate: true, + }, + trendsFilter: { + display: ChartDisplayType.ActionsLineGraph, + }, + filterTestAccounts: true, + }, + } +} + +export function getDefaultFunnelsMetric(): ExperimentFunnelsQuery { + return { + kind: NodeKind.ExperimentFunnelsQuery, + funnels_query: { + kind: NodeKind.FunnelsQuery, + filterTestAccounts: true, + dateRange: { + date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'), + date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), + explicitDate: true, + }, + series: [ + { + kind: NodeKind.EventsNode, + event: '$pageview', + name: '$pageview', + }, + { + kind: NodeKind.EventsNode, + event: '$pageview', + name: '$pageview', + }, + ], + funnelsFilter: { + funnelVizType: FunnelVizType.Steps, + funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit.Day, + funnelWindowInterval: 14, + layout: FunnelLayout.horizontal, + }, + }, + } +} diff --git a/frontend/src/scenes/experiments/holdoutsLogic.tsx b/frontend/src/scenes/experiments/holdoutsLogic.tsx index 3f70a30d61216..f90b3e437899f 100644 --- a/frontend/src/scenes/experiments/holdoutsLogic.tsx +++ b/frontend/src/scenes/experiments/holdoutsLogic.tsx @@ -1,7 +1,8 @@ -import { actions, events, kea, listeners, path, reducers } from 'kea' +import { actions, connect, events, kea, listeners, path, reducers } from 'kea' import { loaders } from 'kea-loaders' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { UserBasicType } from '~/types' @@ -42,6 +43,9 @@ export const holdoutsLogic = kea([ deleteHoldout: (id: number | null) => ({ id }), loadHoldout: (id: number | null) => ({ id }), }), + connect({ + actions: [eventUsageLogic, ['reportExperimentHoldoutCreated']], + }), reducers({ holdout: [ NEW_HOLDOUT, @@ -50,7 +54,7 @@ export const holdoutsLogic = kea([ }, ], }), - loaders(({ values }) => ({ + loaders(({ actions, values }) => ({ holdouts: [ [] as Holdout[], { @@ -60,6 +64,7 @@ export const holdoutsLogic = kea([ }, createHoldout: async () => { const response = await api.create(`api/projects/@current/experiment_holdouts/`, values.holdout) + actions.reportExperimentHoldoutCreated(response) return [...values.holdouts, response] as Holdout[] }, updateHoldout: async ({ id, holdout }) => { diff --git a/frontend/src/scenes/experiments/secondaryMetricsLogic.ts b/frontend/src/scenes/experiments/secondaryMetricsLogic.ts deleted file mode 100644 index 852d7f17a4721..0000000000000 --- a/frontend/src/scenes/experiments/secondaryMetricsLogic.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { actions, connect, kea, key, listeners, path, props, reducers } from 'kea' -import { forms } from 'kea-forms' -import { FunnelLayout } from 'lib/constants' -import { dayjs } from 'lib/dayjs' -import { insightDataLogic } from 'scenes/insights/insightDataLogic' -import { insightLogic } from 'scenes/insights/insightLogic' -import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' -import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilters' -import { teamLogic } from 'scenes/teamLogic' - -import { filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode' -import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' -import { FunnelsQuery, InsightVizNode, TrendsQuery } from '~/queries/schema' -import { Experiment, FilterType, FunnelVizType, InsightType, SecondaryExperimentMetric } from '~/types' - -import { SECONDARY_METRIC_INSIGHT_ID } from './constants' -import { experimentLogic } from './experimentLogic' -import type { secondaryMetricsLogicType } from './secondaryMetricsLogicType' - -const DEFAULT_DURATION = 14 - -export const MAX_SECONDARY_METRICS = 10 - -export interface SecondaryMetricsProps { - onMetricsChange: (metrics: SecondaryExperimentMetric[]) => void - initialMetrics: SecondaryExperimentMetric[] - experimentId: Experiment['id'] - defaultAggregationType?: number -} - -export interface SecondaryMetricForm { - name: string - filters: Partial -} - -const defaultFormValuesGenerator: ( - aggregationType?: number, - disableAddEventToDefault?: boolean, - cohortIdToFilter?: number -) => SecondaryMetricForm = (aggregationType, disableAddEventToDefault, cohortIdToFilter) => { - const groupAggregation = - aggregationType !== undefined ? { math: 'unique_group', math_group_type_index: aggregationType } : {} - - const cohortFilter = cohortIdToFilter - ? { properties: [{ key: 'id', type: 'cohort', value: cohortIdToFilter }] } - : {} - const eventAddition = disableAddEventToDefault - ? {} - : { events: [{ ...getDefaultEvent(), ...groupAggregation, ...cohortFilter }] } - - return { - name: '', - filters: { - insight: InsightType.TRENDS, - ...eventAddition, - }, - } -} - -export const secondaryMetricsLogic = kea([ - props({} as SecondaryMetricsProps), - key((props) => `${props.experimentId || 'new'}-${props.defaultAggregationType}`), - path((key) => ['scenes', 'experiment', 'secondaryMetricsLogic', key]), - connect((props: SecondaryMetricsProps) => ({ - logic: [insightLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID, syncWithUrl: false })], - values: [teamLogic, ['currentTeamId'], experimentLogic({ experimentId: props.experimentId }), ['experiment']], - actions: [ - insightDataLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID }), - ['setQuery'], - insightVizDataLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID }), - ['updateQuerySource'], - ], - })), - actions({ - // modal - openModalToCreateSecondaryMetric: true, - openModalToEditSecondaryMetric: ( - metric: SecondaryExperimentMetric, - metricIdx: number, - showResults: boolean = false - ) => ({ - metric, - metricIdx, - showResults, - }), - saveSecondaryMetric: true, - closeModal: true, - - // metrics - setMetricId: (metricIdx: number) => ({ metricIdx }), - addNewMetric: (metric: SecondaryExperimentMetric) => ({ metric }), - updateMetric: (metric: SecondaryExperimentMetric, metricIdx: number) => ({ metric, metricIdx }), - deleteMetric: (metricIdx: number) => ({ metricIdx }), - - // preview insight - setPreviewInsight: (filters?: Partial) => ({ filters }), - }), - reducers(({ props }) => ({ - isModalOpen: [ - false, - { - openModalToCreateSecondaryMetric: () => true, - openModalToEditSecondaryMetric: () => true, - closeModal: () => false, - }, - ], - showResults: [ - false, - { - openModalToEditSecondaryMetric: (_, { showResults }) => showResults, - closeModal: () => false, - }, - ], - existingModalSecondaryMetric: [ - null as SecondaryExperimentMetric | null, - { - openModalToCreateSecondaryMetric: () => null, - openModalToEditSecondaryMetric: (_, { metric }) => metric, - }, - ], - metrics: [ - props.initialMetrics, - { - addNewMetric: (metrics, { metric }) => { - return [...metrics, { ...metric }] - }, - updateMetric: (metrics, { metric, metricIdx }) => { - const metricsCopy = [...metrics] - metricsCopy[metricIdx] = metric - return metricsCopy - }, - deleteMetric: (metrics, { metricIdx }) => metrics.filter((_, idx) => idx !== metricIdx), - }, - ], - metricIdx: [ - 0 as number, - { - setMetricId: (_, { metricIdx }) => metricIdx, - }, - ], - })), - forms(({ props, values }) => ({ - secondaryMetricModal: { - defaults: defaultFormValuesGenerator( - props.defaultAggregationType, - false, - values.experiment?.exposure_cohort - ), - errors: () => ({}), - submit: async () => { - // We don't use the form submit anymore - }, - }, - })), - listeners(({ props, actions, values }) => ({ - openModalToCreateSecondaryMetric: () => { - actions.resetSecondaryMetricModal() - actions.setPreviewInsight( - defaultFormValuesGenerator(props.defaultAggregationType, false, values.experiment?.exposure_cohort) - .filters - ) - }, - openModalToEditSecondaryMetric: ({ metric: { name, filters }, metricIdx }) => { - actions.setSecondaryMetricModalValue('name', name) - actions.setPreviewInsight(filters) - actions.setMetricId(metricIdx) - }, - setPreviewInsight: async ({ filters }) => { - let newInsightFilters - if (filters?.insight === InsightType.FUNNELS) { - newInsightFilters = cleanFilters({ - insight: InsightType.FUNNELS, - funnel_viz_type: FunnelVizType.Steps, - date_from: dayjs().subtract(DEFAULT_DURATION, 'day').format('YYYY-MM-DD'), - date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), - layout: FunnelLayout.horizontal, - aggregation_group_type_index: props.defaultAggregationType, - ...filters, - }) - } else { - newInsightFilters = cleanFilters({ - insight: InsightType.TRENDS, - date_from: dayjs().subtract(DEFAULT_DURATION, 'day').format('YYYY-MM-DD'), - date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'), - ...defaultFormValuesGenerator( - props.defaultAggregationType, - (filters?.actions?.length || 0) + (filters?.events?.length || 0) > 0 - ).filters, - ...filters, - }) - } - - // This allows switching between insight types. It's necessary as `updateQuerySource` merges - // the new query with any existing query and that causes validation problems when there are - // unsupported properties in the now merged query. - const newQuery = filtersToQueryNode(newInsightFilters) - if (filters?.insight === InsightType.FUNNELS) { - ;(newQuery as TrendsQuery).trendsFilter = undefined - } else { - ;(newQuery as FunnelsQuery).funnelsFilter = undefined - } - actions.updateQuerySource(newQuery) - }, - // sync form value `filters` with query - setQuery: ({ query }) => { - actions.setSecondaryMetricModalValue('filters', queryNodeToFilter((query as InsightVizNode).source)) - }, - saveSecondaryMetric: () => { - if (values.existingModalSecondaryMetric) { - actions.updateMetric(values.secondaryMetricModal, values.metricIdx) - } else { - actions.addNewMetric(values.secondaryMetricModal) - } - props.onMetricsChange(values.metrics) - actions.closeModal() - }, - deleteMetric: () => { - props.onMetricsChange(values.metrics) - }, - })), -]) diff --git a/frontend/src/scenes/experiments/utils.test.ts b/frontend/src/scenes/experiments/utils.test.ts index 5748cfae0e704..e3a73c89cbb4b 100644 --- a/frontend/src/scenes/experiments/utils.test.ts +++ b/frontend/src/scenes/experiments/utils.test.ts @@ -4,7 +4,7 @@ import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from '. describe('utils', () => { it('Funnel experiment returns correct MDE', async () => { - const experimentInsightType = InsightType.FUNNELS + const metricType = InsightType.FUNNELS const trendResults = [ { action: { @@ -26,36 +26,36 @@ describe('utils', () => { ] let conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 1 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.01 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.99 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.1 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.9 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.3 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(3) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(3) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.7 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(3) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(3) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.2 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(4) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(4) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.8 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(4) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(4) conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.5 } - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5) }) it('Trend experiment returns correct MDE', async () => { - const experimentInsightType = InsightType.TRENDS + const metricType = InsightType.TRENDS const conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0 } const trendResults = [ { @@ -78,19 +78,19 @@ describe('utils', () => { ] trendResults[0].count = 0 - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(100) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(100) trendResults[0].count = 200 - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(100) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(100) trendResults[0].count = 201 - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(20) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(20) trendResults[0].count = 1001 - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5) trendResults[0].count = 20000 - expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5) + expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5) }) it('transforms filters for a winning variant', async () => { diff --git a/frontend/src/scenes/experiments/utils.ts b/frontend/src/scenes/experiments/utils.ts index bdc7427062065..4126a90479518 100644 --- a/frontend/src/scenes/experiments/utils.ts +++ b/frontend/src/scenes/experiments/utils.ts @@ -31,11 +31,11 @@ export function formatUnitByQuantity(value: number, unit: string): string { } export function getMinimumDetectableEffect( - experimentInsightType: InsightType, + metricType: InsightType, conversionMetrics: FunnelTimeConversionMetrics, trendResults: TrendResult[] ): number | null { - if (experimentInsightType === InsightType.FUNNELS) { + if (metricType === InsightType.FUNNELS) { // FUNNELS // Given current CR, find a realistic target CR increase and return MDE based on it if (!conversionMetrics) { diff --git a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsLogic.ts b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsLogic.ts index 88ae79a8fa1f8..9c247bf0d6c03 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsLogic.ts +++ b/frontend/src/scenes/feature-flags/FeatureFlagReleaseConditionsLogic.ts @@ -164,7 +164,7 @@ export const featureFlagReleaseConditionsLogic = kea { - actions.setAffectedUsers(values.filters.groups.length - 1, -1) + actions.setAffectedUsers(values.filters.groups.length - 1, values.totalUsers || -1) }, removeConditionSet: ({ index }) => { const previousLength = Object.keys(values.affectedUsers).length @@ -183,9 +183,20 @@ export const featureFlagReleaseConditionsLogic = kea, }, ]} + data-attr="feature-flags-tab-navigation" />
    ) diff --git a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts index 1ea91f0d9f072..9ee90f5307ca2 100644 --- a/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts +++ b/frontend/src/scenes/feature-flags/featureFlagReleaseConditionsLogic.test.ts @@ -128,6 +128,7 @@ describe('the feature flag release conditions logic', () => { .mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 })) .mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2002 })) .mockReturnValueOnce(Promise.resolve({ users_affected: 500, total_users: 2000 })) + .mockReturnValueOnce(Promise.resolve({ users_affected: 750, total_users: 2001 })) logic.mount() }) @@ -138,30 +139,44 @@ describe('the feature flag release conditions logic', () => { }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: -1, 1: undefined, 2: undefined, 3: undefined }, + affectedUsers: { 0: 140, 1: undefined, 2: undefined, 3: undefined }, totalUsers: null, }) .toDispatchActions(['setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: -1, 1: 140 }, - totalUsers: 2000, + affectedUsers: { 0: 140, 1: 240 }, + totalUsers: 2002, }) .toDispatchActions(['setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: -1, 1: 140, 2: 240 }, - totalUsers: 2002, + affectedUsers: { 0: 140, 1: 240, 2: 500 }, + totalUsers: 2000, }) .toDispatchActions(['setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: -1, 1: 140, 2: 240, 3: 500 }, - totalUsers: 2000, + affectedUsers: { 0: 140, 1: 240, 2: 500, 3: 750 }, + totalUsers: 2001, }) }) it('updates when adding conditions to a flag', async () => { jest.spyOn(api, 'create') - .mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 })) - .mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2000 })) + .mockReturnValueOnce(Promise.resolve({ users_affected: 124, total_users: 2000 })) + .mockReturnValueOnce(Promise.resolve({ users_affected: 248, total_users: 2000 })) + .mockReturnValueOnce(Promise.resolve({ users_affected: 496, total_users: 2000 })) + + logic?.unmount() + logic = featureFlagReleaseConditionsLogic({ + id: '5678', + filters: generateFeatureFlagFilters([ + { + properties: [], + rollout_percentage: 50, + variant: null, + }, + ]), + }) + logic.mount() await expectLogic(logic, () => { logic.actions.updateConditionSet(0, 20, [ @@ -176,12 +191,11 @@ describe('the feature flag release conditions logic', () => { // first call is to clear the affected users on mount // second call is to set the affected users for mount logic conditions // third call is to set the affected users for the updateConditionSet action - .toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers']) + .toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: undefined }, - totalUsers: null, + affectedUsers: { 0: 124 }, + totalUsers: 2000, }) - .toNotHaveDispatchedActions(['setTotalUsers']) await expectLogic(logic, () => { logic.actions.updateConditionSet(0, 20, [ @@ -196,11 +210,11 @@ describe('the feature flag release conditions logic', () => { .toDispatchActions(['setAffectedUsers']) .toMatchValues({ affectedUsers: { 0: undefined }, - totalUsers: null, + totalUsers: 2000, }) .toDispatchActions(['setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: 140 }, + affectedUsers: { 0: 248 }, totalUsers: 2000, }) @@ -210,7 +224,8 @@ describe('the feature flag release conditions logic', () => { }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: 140, 1: -1 }, + // expect the new empty condition set to initialize affected users to be same as total users + affectedUsers: { 0: 248, 1: 2000 }, totalUsers: 2000, }) .toNotHaveDispatchedActions(['setTotalUsers']) @@ -228,7 +243,7 @@ describe('the feature flag release conditions logic', () => { }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: 140, 1: undefined }, + affectedUsers: { 0: 248, 1: undefined }, totalUsers: 2000, }) .toNotHaveDispatchedActions(['setTotalUsers']) @@ -246,12 +261,12 @@ describe('the feature flag release conditions logic', () => { }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: 140, 1: undefined }, + affectedUsers: { 0: 248, 1: undefined }, totalUsers: 2000, }) .toDispatchActions(['setAffectedUsers', 'setTotalUsers']) .toMatchValues({ - affectedUsers: { 0: 140, 1: 240 }, + affectedUsers: { 0: 248, 1: 496 }, totalUsers: 2000, }) @@ -261,11 +276,11 @@ describe('the feature flag release conditions logic', () => { }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: 240, 1: 240 }, + affectedUsers: { 0: 496, 1: 496 }, }) .toDispatchActions(['setAffectedUsers']) .toMatchValues({ - affectedUsers: { 0: 240, 1: undefined }, + affectedUsers: { 0: 496, 1: undefined }, }) }) @@ -313,7 +328,6 @@ describe('the feature flag release conditions logic', () => { jest.spyOn(api, 'create') logic?.unmount() - logic = featureFlagReleaseConditionsLogic({ id: '12345', filters: generateFeatureFlagFilters([ @@ -359,11 +373,11 @@ describe('the feature flag release conditions logic', () => { 'setTotalUsers', ]) .toMatchValues({ - affectedUsers: { 0: -1, 1: 120, 2: 120 }, + affectedUsers: { 0: 120, 1: 120, 2: 120 }, totalUsers: 2000, }) - expect(api.create).toHaveBeenCalledTimes(2) + expect(api.create).toHaveBeenCalledTimes(4) await expectLogic(logic, () => { logic.actions.updateConditionSet(0, 20, undefined, undefined) @@ -378,7 +392,7 @@ describe('the feature flag release conditions logic', () => { }).toNotHaveDispatchedActions(['setAffectedUsers', 'setTotalUsers']) // no extra calls when changing rollout percentage - expect(api.create).toHaveBeenCalledTimes(2) + expect(api.create).toHaveBeenCalledTimes(4) }) }) }) diff --git a/frontend/src/scenes/feature-flags/featureFlagsLogic.ts b/frontend/src/scenes/feature-flags/featureFlagsLogic.ts index b39b7d052d242..9ad6e03a4fb0c 100644 --- a/frontend/src/scenes/feature-flags/featureFlagsLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagsLogic.ts @@ -180,6 +180,10 @@ export const featureFlagsLogic = kea([ await breakpoint(300) actions.loadFeatureFlags() }, + setActiveTab: () => { + // Don't carry over pagination from previous tab + actions.setFeatureFlagsFilters({ page: 1 }, true) + }, })), actionToUrl(({ values }) => { const changeUrl = (): @@ -231,13 +235,8 @@ export const featureFlagsLogic = kea([ order, } - if (active !== undefined) { - pageFiltersFromUrl.active = String(active) - } - - if (page !== undefined) { - pageFiltersFromUrl.page = parseInt(page) - } + pageFiltersFromUrl.active = active !== undefined ? String(active) : undefined + pageFiltersFromUrl.page = page !== undefined ? parseInt(page) : undefined actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl }) }, diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx index b4d3fc1642172..15586e766a310 100644 --- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx +++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx @@ -40,6 +40,7 @@ import { } from 'scenes/trends/mathsLogic' import { actionsModel } from '~/models/actionsModel' +import { NodeKind } from '~/queries/schema' import { isInsightVizNode, isStickinessQuery } from '~/queries/utils' import { ActionFilter, @@ -596,9 +597,20 @@ export function ActionFilterRow({ onChange={(properties) => updateFilterProperty({ properties, index })} showNestedArrow={showNestedArrow} disablePopover={!propertyFiltersPopover} + metadataSource={ + filter.type == TaxonomicFilterGroupType.DataWarehouse + ? { + kind: NodeKind.HogQLQuery, + query: `select ${filter.distinct_id_field} from ${filter.table_name}`, + } + : undefined + } taxonomicGroupTypes={ filter.type == TaxonomicFilterGroupType.DataWarehouse - ? [TaxonomicFilterGroupType.DataWarehouseProperties] + ? [ + TaxonomicFilterGroupType.DataWarehouseProperties, + TaxonomicFilterGroupType.HogQLExpression, + ] : propertiesTaxonomicGroupTypes } eventNames={ diff --git a/frontend/src/scenes/insights/filters/AggregationSelect.tsx b/frontend/src/scenes/insights/filters/AggregationSelect.tsx index 1aae9ea390e9b..4086bc02831b4 100644 --- a/frontend/src/scenes/insights/filters/AggregationSelect.tsx +++ b/frontend/src/scenes/insights/filters/AggregationSelect.tsx @@ -10,7 +10,7 @@ import { FunnelsQuery } from '~/queries/schema' import { isFunnelsQuery, isInsightQueryNode, isStickinessQuery } from '~/queries/utils' import { InsightLogicProps } from '~/types' -function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string { +export function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string { if (groupIndex !== undefined) { return `$group_${groupIndex}` } else if (aggregationQuery) { @@ -19,7 +19,7 @@ function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string { return UNIQUE_USERS } -function hogQLToFilterValue(value?: string): { groupIndex?: number; aggregationQuery?: string } { +export function hogQLToFilterValue(value?: string): { groupIndex?: number; aggregationQuery?: string } { if (value?.match(/^\$group_[0-9]+$/)) { return { groupIndex: parseInt(value.replace('$group_', '')) } } else if (value === 'person_id') { diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx index 7233638856cf7..54f28a4fd281f 100644 --- a/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx +++ b/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx @@ -10,7 +10,7 @@ import { useDebouncedCallback } from 'use-debounce' import { FunnelsFilter } from '~/queries/schema' import { EditorFilterProps, FunnelConversionWindow, FunnelConversionWindowTimeUnit } from '~/types' -const TIME_INTERVAL_BOUNDS: Record = { +export const TIME_INTERVAL_BOUNDS: Record = { [FunnelConversionWindowTimeUnit.Second]: [1, 3600], [FunnelConversionWindowTimeUnit.Minute]: [1, 1440], [FunnelConversionWindowTimeUnit.Hour]: [1, 24], diff --git a/frontend/src/scenes/max/Intro.tsx b/frontend/src/scenes/max/Intro.tsx index b2f3de90cfceb..c43cd86b53d2a 100644 --- a/frontend/src/scenes/max/Intro.tsx +++ b/frontend/src/scenes/max/Intro.tsx @@ -1,8 +1,11 @@ -import { useValues } from 'kea' +import { offset } from '@floating-ui/react' +import { LemonButton, Popover } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' import { HedgehogBuddy } from 'lib/components/HedgehogBuddy/HedgehogBuddy' import { hedgehogBuddyLogic } from 'lib/components/HedgehogBuddy/hedgehogBuddyLogic' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import { maxGlobalLogic } from './maxGlobalLogic' import { maxLogic } from './maxLogic' const HEADLINES = [ @@ -14,8 +17,12 @@ const HEADLINES = [ export function Intro(): JSX.Element { const { hedgehogConfig } = useValues(hedgehogBuddyLogic) + const { acceptDataProcessing } = useActions(maxGlobalLogic) + const { dataProcessingAccepted } = useValues(maxGlobalLogic) const { sessionId } = useValues(maxLogic) + const [hedgehogDirection, setHedgehogDirection] = useState<'left' | 'right'>('right') + const headline = useMemo(() => { return HEADLINES[parseInt(sessionId.split('-').at(-1) as string, 16) % HEADLINES.length] }, []) @@ -23,22 +30,52 @@ export function Intro(): JSX.Element { return ( <>
    - { - if (Math.random() < 0.01) { - actor.setOnFire() - } else { - actor.setRandomAnimation() + +

    + Hi! I use OpenAI services to analyze your data, +
    + so that you can focus on building. This can include +
    + personal data of your users, if you're capturing it. +
    + Your data won't be used for training models. +

    + acceptDataProcessing()}> + Got it, I accept OpenAI processing data + +
    + } + placement={`${hedgehogDirection}-end`} + middleware={[offset(-12)]} + showArrow + visible={!dataProcessingAccepted} + > + { + if (Math.random() < 0.01) { + actor.setOnFire() + } else { + actor.setRandomAnimation() + } + }} + onActorLoaded={(actor) => + setTimeout(() => { + actor.setAnimation('wave') + // Always start out facing right so that the data processing popover is more readable + actor.direction = 'right' + }, 100) } - }} - onActorLoaded={(actor) => setTimeout(() => actor.setAnimation('wave'), 100)} - /> + onPositionChange={(actor) => setHedgehogDirection(actor.direction)} + /> +

    {headline}

    diff --git a/frontend/src/scenes/max/Max.stories.tsx b/frontend/src/scenes/max/Max.stories.tsx index 73f1ce19a4117..d5aa836f7bcbc 100644 --- a/frontend/src/scenes/max/Max.stories.tsx +++ b/frontend/src/scenes/max/Max.stories.tsx @@ -8,6 +8,7 @@ import { mswDecorator, useStorybookMocks } from '~/mocks/browser' import { chatResponseChunk, failureChunk, generationFailureChunk } from './__mocks__/chatResponse.mocks' import { MaxInstance } from './Max' +import { maxGlobalLogic } from './maxGlobalLogic' import { maxLogic } from './maxLogic' const meta: Meta = { @@ -31,6 +32,12 @@ export default meta const SESSION_ID = 'b1b4b3b4-1b3b-4b3b-1b3b4b3b4b3b' const Template = ({ sessionId: SESSION_ID }: { sessionId: string }): JSX.Element => { + const { acceptDataProcessing } = useActions(maxGlobalLogic) + + useEffect(() => { + acceptDataProcessing() + }, []) + return (
    @@ -56,6 +63,11 @@ export const Welcome: StoryFn = () => { ], }, }) + const { acceptDataProcessing } = useActions(maxGlobalLogic) + useEffect(() => { + // We override data processing opt-in to false, so that wee see the welcome screen as a first-time user would + acceptDataProcessing(false) + }, []) return