0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

Merge branch 'master' of https://github.com/PostHog/posthog into detect-stale-flags

This commit is contained in:
Haven Barnes 2024-11-18 14:32:59 -08:00
commit 8cabf20299
271 changed files with 13447 additions and 2852 deletions

2
.vscode/launch.json vendored
View File

@ -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"

View File

@ -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 <file.hog> [output.hoge] compile .hog into .hoge"
echo " bin/hoge <file.hog> <output.js> compile .hog into .js"
echo " bin/hog <file.hog> run .hog source code"
echo " bin/hog <file.hoge> run compiled .hoge bytecode"
exit 1

View File

@ -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()
}

View File

@ -308,7 +308,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 +329,15 @@ 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`)
})
it('Renders flags in FlagSelector', () => {
// Create flag name
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')

View File

@ -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', () => {

View File

@ -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

View File

@ -31,7 +31,6 @@ from posthog.clickhouse.query_tagging import tag_queries
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 ExperimentSerializer(serializers.ModelSerializer):
"updated_at",
"type",
"metrics",
"metrics_secondary",
]
read_only_fields = [
"id",
@ -235,36 +235,7 @@ class ExperimentSerializer(serializers.ModelSerializer):
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 @@ class ExperimentSerializer(serializers.ModelSerializer):
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 @@ class ExperimentSerializer(serializers.ModelSerializer):
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 @@ class ExperimentSerializer(serializers.ModelSerializer):
{"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,8 +292,9 @@ class ExperimentSerializer(serializers.ModelSerializer):
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",
},
context=self.context,
)
@ -369,13 +336,13 @@ class ExperimentSerializer(serializers.ModelSerializer):
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 []
@ -408,6 +375,8 @@ class ExperimentSerializer(serializers.ModelSerializer):
"archived",
"secondary_metrics",
"holdout",
"metrics",
"metrics_secondary",
}
given_keys = set(validated_data.keys())
extra_keys = given_keys - expected_keys

View File

@ -1235,42 +1235,6 @@ class TestExperimentCRUD(APILicensedTest):
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 @@ class TestExperimentCRUD(APILicensedTest):
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):

View File

@ -5,6 +5,7 @@ from langchain_core.messages import AIMessageChunk
from langfuse.callback import CallbackHandler
from langgraph.graph.state import 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.funnels.nodes import (
)
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.models.team.team import Team
from posthog.schema import (
AssistantGenerationStatusEvent,
AssistantGenerationStatusType,
AssistantMessage,
FailureMessage,
VisualizationMessage,
)
@ -123,7 +127,7 @@ class Assistant:
generate_trends_node.router,
path_map={
"tools": AssistantNodeName.TRENDS_GENERATOR_TOOLS,
"next": AssistantNodeName.END,
"next": AssistantNodeName.SUMMARIZER,
},
)
@ -160,10 +164,14 @@ class Assistant:
generate_trends_node.router,
path_map={
"tools": AssistantNodeName.FUNNEL_GENERATOR_TOOLS,
"next": AssistantNodeName.END,
"next": AssistantNodeName.SUMMARIZER,
},
)
summarizer_node = SummarizerNode(self._team)
builder.add_node(AssistantNodeName.SUMMARIZER, summarizer_node.run)
builder.add_edge(AssistantNodeName.SUMMARIZER, AssistantNodeName.END)
return builder.compile()
def stream(self, conversation: Conversation) -> Generator[BaseModel, None, None]:
@ -185,33 +193,47 @@ class Assistant:
# 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
try:
for update in generator:
if is_state_update(update):
_, new_state = update
state = new_state
elif is_value_update(update):
_, state_update = update
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="")
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
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

View File

@ -33,7 +33,9 @@ class TestFunnelsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
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,
},
)

View File

@ -101,6 +101,7 @@ class SchemaGeneratorNode(AssistantNode, Generic[Q]):
plan=generated_plan,
reasoning_steps=message.reasoning_steps,
answer=message.answer,
done=True,
)
],
"intermediate_steps": None,

View File

@ -54,7 +54,9 @@ class TestSchemaGeneratorNode(ClickhouseTestMixin, APIBaseTest):
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,
},
)

View File

View File

@ -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

View File

@ -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.
"""

View File

View File

@ -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),
],
)

View File

@ -75,10 +75,8 @@ class TaxonomyAgentPlannerNode(AssistantNode):
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 @@ class TaxonomyAgentPlannerNode(AssistantNode):
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(

View File

@ -22,7 +22,7 @@ from posthog.schema import (
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 @@ class TestTaxonomyAgentPlannerNode(ClickhouseTestMixin, APIBaseTest):
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 @@ class TestTaxonomyAgentPlannerNode(ClickhouseTestMixin, APIBaseTest):
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)

View File

@ -14,6 +14,8 @@ from posthog.test.base import APIBaseTest, ClickhouseTestMixin
@override_settings(IN_UNIT_TESTING=True)
class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
maxDiff = None
def setUp(self):
self.schema = AssistantTrendsQuery(series=[])
@ -33,7 +35,9 @@ class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
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,
},
)

View File

@ -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):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -719,6 +719,10 @@ class ApiRequest {
return this.errorTracking().addPathComponent('upload_source_maps')
}
public errorTrackingStackFrames(ids: string[]): ApiRequest {
return this.errorTracking().addPathComponent('stack_frames').withQueryString({ ids })
}
// # Warehouse
public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('warehouse_tables')
@ -1857,6 +1861,10 @@ const api = {
async uploadSourceMaps(data: FormData): Promise<{ content: string }> {
return await new ApiRequest().errorTrackingUploadSourceMaps().create({ data })
},
async fetchStackFrames(ids: string[]): Promise<{ content: string }> {
return await new ApiRequest().errorTrackingStackFrames(ids).get()
},
},
recordings: {

View File

@ -0,0 +1,6 @@
.ErrorDisplay__stacktrace {
.LemonCollapsePanel__header {
min-height: 2.375rem !important;
padding: 0.25rem !important;
}
}

View File

@ -1,71 +1,67 @@
import './ErrorDisplay.scss'
import { IconFlag } from '@posthog/icons'
import { LemonCollapse } from '@posthog/lemon-ui'
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 { EventType } from '~/types'
interface StackFrame {
filename: string
lineno: number
colno: number
function: string
context_line?: string
in_app?: boolean
import { StackFrame } from './stackFrameLogic'
interface RawStackTrace {
type: 'raw'
frames: StackFrame[]
}
interface ResolvedStackTrace {
type: 'resolved'
frames: StackFrame[]
}
interface ExceptionTrace {
stacktrace: {
frames: StackFrame[]
}
interface Exception {
stacktrace: ResolvedStackTrace | RawStackTrace
module: string
type: string
value: string
}
function parseToFrames(rawTrace: string): StackFrame[] {
return JSON.parse(rawTrace)
function StackTrace({ frames, showAllFrames }: { frames: StackFrame[]; showAllFrames: boolean }): JSX.Element | null {
const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app)
const panels = displayFrames.map(({ filename, lineno, colno, function: functionName }, index) => {
return {
key: index,
header: (
<div className="flex flex-wrap space-x-0.5">
<span>{filename}</span>
{functionName ? (
<div className="flex space-x-0.5">
<span className="text-muted">in</span>
<span>{functionName}</span>
</div>
) : null}
{lineno && colno ? (
<div className="flex space-x-0.5">
<span className="text-muted">at line</span>
<span>
{lineno}:{colno}
</span>
</div>
) : null}
</div>
),
content: null,
}
})
return <LemonCollapse defaultActiveKeys={[]} multiple panels={panels} size="xsmall" />
}
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 ? (
<TitledSnack
key={index}
title={functionName}
value={
<>
{filename}:{lineno}:{colno}
{context_line ? `:${context_line}` : ''}
</>
}
/>
) : null
})
) : (
<LemonTag>Empty stack trace</LemonTag>
)}
</>
)
} catch (e: any) {
//very meta
posthog.capture('Cannot parse stack trace in Exception event', { tag: 'error-display-stack-trace', e })
return <LemonTag type="caution">Error parsing stack trace</LemonTag>
}
}
function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[] }): JSX.Element {
function ChainedStackTraces({ exceptionList }: { exceptionList: Exception[] }): JSX.Element {
const [showAllFrames, setShowAllFrames] = useState(false)
return (
@ -89,9 +85,9 @@ function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[]
}
return (
<div key={index} className="flex flex-col gap-1 mt-6">
<div key={index} className="ErrorDisplay__stacktrace flex flex-col gap-1 mt-6">
<h3 className="mb-0">{value}</h3>
<StackTrace rawTrace={JSON.stringify(frames || [])} showAllFrames={showAllFrames} />
<StackTrace frames={frames || []} showAllFrames={showAllFrames} />
</div>
)
})}

View File

@ -0,0 +1,30 @@
import { kea, path } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import type { stackFrameLogicType } from './stackFrameLogicType'
export interface StackFrame {
filename: string
lineno: number
colno: number
function: string
in_app?: boolean
}
export const stackFrameLogic = kea<stackFrameLogicType>([
path(['components', 'Errors', 'stackFrameLogic']),
loaders(({ values }) => ({
stackFrames: [
{} as Record<string, StackFrame>,
{
loadFrames: async ({ frameIds }: { frameIds: string[] }) => {
const loadedFrameIds = Object.keys(values.stackFrames)
const ids = frameIds.filter((id) => loadedFrameIds.includes(id))
await api.errorTracking.fetchStackFrames(ids)
return {}
},
},
],
})),
])

View File

@ -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

View File

@ -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<File[]>([])
const uploadInProgressRef = useRef(false)
useEffect(() => {
const uploadFiles = async (): Promise<void> => {
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([])
}

View File

@ -32,6 +32,11 @@
&.LemonButton:active {
transform: inherit;
}
&--disabled:hover {
cursor: default;
background-color: var(--bg-light) !important;
}
}
.LemonCollapsePanel__body {

View File

@ -116,18 +116,28 @@ function LemonCollapsePanel({
return (
<div className="LemonCollapsePanel" aria-expanded={isExpanded}>
<LemonButton
onClick={() => {
onHeaderClick && onHeaderClick()
onChange(!isExpanded)
}}
icon={isExpanded ? <IconCollapse /> : <IconExpand />}
className="LemonCollapsePanel__header"
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
size={size}
>
{header}
</LemonButton>
{content ? (
<LemonButton
onClick={() => {
onHeaderClick && onHeaderClick()
onChange(!isExpanded)
}}
icon={isExpanded ? <IconCollapse /> : <IconExpand />}
className="LemonCollapsePanel__header"
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
size={size}
>
{header}
</LemonButton>
) : (
<LemonButton
className="LemonCollapsePanel__header LemonCollapsePanel__header--disabled"
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
size={size}
>
{header}
</LemonButton>
)}
<Transition in={isExpanded} timeout={200} mountOnEnter unmountOnExit>
{(status) => (
<div

View File

@ -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: [

View File

@ -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"

View File

@ -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"
},

View File

@ -2020,17 +2020,19 @@ export type CachedExperimentFunnelsQueryResponse = CachedQueryResponse<Experimen
export interface ExperimentFunnelsQuery extends DataNode<ExperimentFunnelsQueryResponse> {
kind: NodeKind.ExperimentFunnelsQuery
name?: string
experiment_id?: integer
funnels_query: FunnelsQuery
experiment_id: integer
}
export interface ExperimentTrendsQuery extends DataNode<ExperimentTrendsQueryResponse> {
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 =

View File

@ -67,7 +67,7 @@ export const signupLogic = kea<signupLogicType>([
password: !values.preflight?.demo
? !password
? 'Please enter your password to continue'
: values.validatedPassword.feedback
: values.validatedPassword.feedback || undefined
: undefined,
}),
submit: async () => {

View File

@ -21,6 +21,7 @@ import { BillingCTAHero } from './BillingCTAHero'
import { billingLogic } from './billingLogic'
import { BillingProduct } from './BillingProduct'
import { CreditCTAHero } from './CreditCTAHero'
import { PaymentEntryModal } from './PaymentEntryModal'
import { UnsubscribeCard } from './UnsubscribeCard'
export const scene: SceneExport = {
@ -82,6 +83,8 @@ export function Billing(): JSX.Element {
const platformAndSupportProduct = products?.find((product) => product.type === 'platform_and_support')
return (
<div ref={ref}>
<PaymentEntryModal />
{showLicenseDirectInput && (
<>
<Form logic={billingLogic} formKey="activateLicense" enableFormOnSubmit className="space-y-4">

View File

@ -1,12 +1,13 @@
import { LemonButton, LemonModal, Spinner } from '@posthog/lemon-ui'
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { useActions, useValues } from 'kea'
import { useEffect } from 'react'
import { WavingHog } from 'lib/components/hedgehogs'
import { useEffect, useState } from 'react'
import { urls } from 'scenes/urls'
import { paymentEntryLogic } from './paymentEntryLogic'
const stripePromise = loadStripe(window.STRIPE_PUBLIC_KEY!)
const stripeJs = async (): Promise<typeof import('@stripe/stripe-js')> => await import('@stripe/stripe-js')
export const PaymentForm = (): JSX.Element => {
const { error, isLoading } = useValues(paymentEntryLogic)
@ -34,13 +35,17 @@ export const PaymentForm = (): JSX.Element => {
setLoading(false)
setError(result.error.message)
} else {
pollAuthorizationStatus()
pollAuthorizationStatus(result.paymentIntent.id)
}
}
return (
<div>
<PaymentElement />
<p className="text-xs text-muted mt-0.5">
Your card will not be charged but we place a $0.50 hold on it to verify your card that will be released
in 7 days.
</p>
{error && <div className="error">{error}</div>}
<div className="flex justify-end space-x-2 mt-2">
<LemonButton disabled={isLoading} type="secondary" onClick={hidePaymentEntryModal}>
@ -58,21 +63,38 @@ interface PaymentEntryModalProps {
redirectPath?: string | null
}
export const PaymentEntryModal = ({ redirectPath = null }: PaymentEntryModalProps): JSX.Element | null => {
export const PaymentEntryModal = ({
redirectPath = urls.organizationBilling(),
}: PaymentEntryModalProps): JSX.Element => {
const { clientSecret, paymentEntryModalOpen } = useValues(paymentEntryLogic)
const { hidePaymentEntryModal, initiateAuthorization } = useActions(paymentEntryLogic)
const [stripePromise, setStripePromise] = useState<any>(null)
useEffect(() => {
initiateAuthorization(redirectPath)
}, [redirectPath])
// Only load Stripe.js when the modal is opened
if (paymentEntryModalOpen && !stripePromise) {
const loadStripeJs = async (): Promise<void> => {
const { loadStripe } = await stripeJs()
const publicKey = window.STRIPE_PUBLIC_KEY!
setStripePromise(await loadStripe(publicKey))
}
void loadStripeJs()
}
}, [paymentEntryModalOpen, stripePromise])
useEffect(() => {
if (paymentEntryModalOpen) {
initiateAuthorization(redirectPath)
}
}, [paymentEntryModalOpen, initiateAuthorization, redirectPath])
return (
<LemonModal
onClose={hidePaymentEntryModal}
width="max(44vw)"
isOpen={paymentEntryModalOpen}
title="Add your payment details"
description="Your card will not be charged."
title="Add your payment details to subscribe"
description=""
>
<div>
{clientSecret ? (
@ -80,9 +102,13 @@ export const PaymentEntryModal = ({ redirectPath = null }: PaymentEntryModalProp
<PaymentForm />
</Elements>
) : (
<div className="min-h-40 flex justify-center items-center">
<div className="text-4xl">
<Spinner />
<div className="min-h-80 flex flex-col justify-center items-center">
<p className="text-muted text-md mt-4">We're contacting the Hedgehogs for approval.</p>
<div className="flex items-center space-x-2">
<div className="text-4xl">
<Spinner />
</div>
<WavingHog className="w-18 h-18" />
</div>
</div>
)}

View File

@ -12,7 +12,7 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
setLoading: (loading) => ({ loading }),
setError: (error) => ({ error }),
initiateAuthorization: (redirectPath: string | null) => ({ redirectPath }),
pollAuthorizationStatus: true,
pollAuthorizationStatus: (paymentIntentId?: string) => ({ paymentIntentId }),
setAuthorizationStatus: (status: string | null) => ({ status }),
showPaymentEntryModal: true,
hidePaymentEntryModal: true,
@ -73,7 +73,7 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
}
},
pollAuthorizationStatus: async () => {
pollAuthorizationStatus: async ({ paymentIntentId }) => {
const pollInterval = 2000 // Poll every 2 seconds
const maxAttempts = 30 // Max 1 minute of polling (30 * 2 seconds)
let attempts = 0
@ -81,9 +81,9 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
const poll = async (): Promise<void> => {
try {
const urlParams = new URLSearchParams(window.location.search)
const paymentIntentId = urlParams.get('payment_intent')
const searchPaymentIntentId = urlParams.get('payment_intent')
const response = await api.create('api/billing/activate/authorize/status', {
payment_intent_id: paymentIntentId,
payment_intent_id: paymentIntentId || searchPaymentIntentId,
})
const status = response.status

View File

@ -60,7 +60,7 @@ export function QueryWindow(): JSX.Element {
onQueryInputChange={runQuery}
onSave={saveAsView}
saveDisabledReason={
hasErrors ? error ?? 'Query has errors' : !isValidView ? 'All fields must have an alias' : ''
hasErrors ? error ?? 'Query has errors' : !isValidView ? 'Some fields may need an alias' : ''
}
/>
</div>

View File

@ -117,6 +117,7 @@ const MOCK_FUNNEL_EXPERIMENT: Experiment = {
filter_test_accounts: true,
},
metrics: [],
metrics_secondary: [],
archived: false,
created_by: {
id: 1,
@ -174,6 +175,7 @@ const MOCK_TREND_EXPERIMENT: Experiment = {
},
},
metrics: [],
metrics_secondary: [],
parameters: {
feature_flag_variants: [
{
@ -281,6 +283,7 @@ const MOCK_WEB_EXPERIMENT_MANY_VARIANTS: Experiment = {
},
},
metrics: [],
metrics_secondary: [],
parameters: {
feature_flag_variants: [
{
@ -403,6 +406,7 @@ const MOCK_TREND_EXPERIMENT_MANY_VARIANTS: Experiment = {
},
},
metrics: [],
metrics_secondary: [],
parameters: {
feature_flag_variants: [
{

View File

@ -17,14 +17,8 @@ import { experimentLogic } from './experimentLogic'
const ExperimentFormFields = (): JSX.Element => {
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 (
@ -130,7 +124,6 @@ const ExperimentFormFields = (): JSX.Element => {
aggregation_group_type_index: groupTypeIndex ?? undefined,
},
})
setNewExperimentInsight()
}}
options={[
{ value: -1, label: 'Persons' },

View File

@ -1,17 +1,17 @@
import { IconInfo } from '@posthog/icons'
import { Tooltip } from '@posthog/lemon-ui'
import { useValues } from 'kea'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { InsightEmptyState } from 'scenes/insights/EmptyStates'
import { InsightViz } from '~/queries/nodes/InsightViz/InsightViz'
import { queryFromFilters } from '~/queries/nodes/InsightViz/utils'
import { InsightQueryNode, InsightVizNode, NodeKind } from '~/queries/schema'
import { CachedExperimentTrendsQueryResponse, InsightQueryNode, InsightVizNode, NodeKind } from '~/queries/schema'
import {
_TrendsExperimentResults,
BaseMathType,
ChartDisplayType,
Experiment,
ExperimentResults,
InsightType,
PropertyFilterType,
PropertyOperator,
@ -20,68 +20,113 @@ import {
import { experimentLogic } from '../experimentLogic'
import { transformResultFilters } from '../utils'
const getCumulativeExposuresQuery = (
experiment: Experiment,
experimentResults: ExperimentResults['result']
): InsightVizNode<InsightQueryNode> => {
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: {
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: [
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: [
{
key: `$feature/${experiment.feature_flag_key}`,
value: variants,
operator: PropertyOperator.Exact,
type: PropertyFilterType.Event,
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',
},
},
],
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,
} 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: [
{
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',
},
},
}
}
}
}
export function CumulativeExposuresChart(): JSX.Element {
const { experiment, experimentResults } = useValues(experimentLogic)
return (
<div>
@ -94,7 +139,7 @@ export function CumulativeExposuresChart(): JSX.Element {
{experiment.start_date ? (
<InsightViz
query={{
...getCumulativeExposuresQuery(experiment, experimentResults as ExperimentResults['result']),
...(query as InsightVizNode<InsightQueryNode>),
showTable: true,
}}
setQuery={() => {}}

View File

@ -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 && (
<div className="flex justify-between mt-0">
<span className="flex items-center text-xs">
Completed&nbsp;
@ -103,7 +105,7 @@ export function DataCollection(): JSX.Element {
</span>
</div>
)}
{experimentInsightType === InsightType.FUNNELS && (
{metricType === InsightType.FUNNELS && (
<div className="flex justify-between mt-0">
<div className="space-x-1 flex items-center text-xs">
<span>
@ -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 (
<LemonModal
isOpen={isExperimentCollectionGoalModalOpen}
@ -201,7 +211,7 @@ export function DataCollectionGoalModal({ experimentId }: { experimentId: Experi
</div>
}
>
{goalInsightDataLoading ? (
{isInsightLoading ? (
<div className="flex flex-col flex-1 justify-center items-center mb-6">
<Animation type={AnimationType.LaptopHog} />
<div className="text-xs text-muted w-60">

View File

@ -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.
</LemonBanner>
{experimentInsightType === InsightType.TRENDS ? (
{getMetricType(0) === InsightType.TRENDS ? (
<TrendCalculation experimentId={experimentId} />
) : (
<FunnelCalculation experimentId={experimentId} />

View File

@ -17,7 +17,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 +26,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 +49,7 @@ const ResultsTab = (): JSX.Element => {
)}
</>
)}
<SecondaryMetricsTable
experimentId={experiment.id}
onMetricsChange={(metrics) => updateExperimentSecondaryMetrics(metrics)}
initialMetrics={experiment.secondary_metrics}
defaultAggregationType={experiment.parameters?.aggregation_group_type_index}
/>
<SecondaryMetricsTable experimentId={experiment.id} />
</div>
)
}
@ -126,8 +120,6 @@ export function ExperimentView(): JSX.Element {
/>
</>
)}
<ExperimentGoalModal experimentId={experimentId} />
<ExperimentExposureModal experimentId={experimentId} />
<DistributionModal experimentId={experimentId} />
<ReleaseConditionsModal experimentId={experimentId} />
</>

View File

@ -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 { ActionFilter as ActionFilterType, AnyPropertyFilter, Experiment, FilterType, InsightType } from '~/types'
import { ExperimentFunnelsQuery, ExperimentTrendsQuery, FunnelsQuery, NodeKind, TrendsQuery } from '~/queries/schema'
import { ActionFilter, AnyPropertyFilter, ChartDisplayType, Experiment, FilterType, InsightType } from '~/types'
import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from '../constants'
import { experimentLogic } from '../experimentLogic'
import { MetricSelector } from '../MetricSelector'
import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric } from '../experimentLogic'
import { PrimaryMetricModal } from '../Metrics/PrimaryMetricModal'
import { PrimaryTrendsExposureModal } from '../Metrics/PrimaryTrendsExposureModal'
export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Element {
const experimentInsightType = filters?.insight || InsightType.TRENDS
export function MetricDisplayTrends({ query }: { query: TrendsQuery | undefined }): JSX.Element {
const event = query?.series?.[0] as unknown as ActionFilter
if (!event) {
return <></>
}
return (
<>
{([...(filters?.events || []), ...(filters?.actions || [])] as ActionFilterType[])
<div className="mb-2">
<div className="flex mb-1">
<b>
<InsightLabel action={event} showCountedByTag={true} hideIcon showEventName />
</b>
</div>
<div className="space-y-1">
{event.properties?.map((prop: AnyPropertyFilter) => (
<PropertyFilterButton key={prop.key} item={prop} />
))}
</div>
</div>
</>
)
}
export function MetricDisplayFunnels({ query }: { query: FunnelsQuery }): JSX.Element {
return (
<>
{(query.series || []).map((event: any, idx: number) => (
<div key={idx} className="mb-2">
<div className="flex mb-1">
<div
className="shrink-0 w-6 h-6 mr-2 font-bold text-center text-primary-alt border rounded"
// eslint-disable-next-line react/forbid-dom-props
style={{ backgroundColor: 'var(--bg-table)' }}
>
{idx + 1}
</div>
<b>
<InsightLabel action={event} hideIcon showEventName />
</b>
</div>
<div className="space-y-1">
{event.properties?.map((prop: AnyPropertyFilter) => (
<PropertyFilterButton key={prop.key} item={prop} />
))}
</div>
</div>
))}
</>
)
}
// :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) => (
<div key={idx} className="mb-2">
<div className="flex mb-1">
{experimentInsightType === InsightType.FUNNELS && (
{metricType === InsightType.FUNNELS && (
<div
className="shrink-0 w-6 h-6 mr-2 font-bold text-center text-primary-alt border rounded"
// eslint-disable-next-line react/forbid-dom-props
@ -35,7 +90,7 @@ export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Elemen
<b>
<InsightLabel
action={event}
showCountedByTag={experimentInsightType === InsightType.TRENDS}
showCountedByTag={metricType === InsightType.TRENDS}
hideIcon
showEventName
/>
@ -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'
<IconInfo className="ml-1 text-muted text-sm" />
</Tooltip>
</div>
{experiment.parameters?.custom_exposure_filter ? (
<MetricDisplay filters={experiment.parameters.custom_exposure_filter} />
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? (
hasCustomExposure ? (
<MetricDisplayTrends query={(experiment.metrics[0] as ExperimentTrendsQuery).exposure_query} />
) : (
<span className="description">Default via $feature_flag_called events</span>
)
) : hasCustomExposure ? (
<MetricDisplayOld filters={experiment.parameters.custom_exposure_filter} />
) : (
<span className="description">Default via $feature_flag_called events</span>
)}
<div className="mb-2 mt-2">
<span className="flex">
<LemonButton type="secondary" size="xsmall" onClick={openExperimentExposureModal} className="mr-2">
<LemonButton
type="secondary"
size="xsmall"
onClick={() => {
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
</LemonButton>
{experiment.parameters?.custom_exposure_filter && (
{hasCustomExposure && (
<LemonButton
type="secondary"
status="danger"
size="xsmall"
onClick={() => 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
</LemonButton>
)}
</span>
</div>
<PrimaryTrendsExposureModal
experimentId={experimentId}
isOpen={isModalOpen}
onClose={() => {
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 (
<LemonModal
isOpen={isExperimentGoalModalOpen}
onClose={closeExperimentGoalModal}
width={1000}
title="Change experiment goal"
footer={
<div className="flex items-center gap-2">
<LemonButton form="edit-experiment-goal-form" type="secondary" onClick={closeExperimentGoalModal}>
Cancel
</LemonButton>
<LemonButton
disabledReason={
(goalInsightDataLoading && 'The insight needs to be loaded before saving the goal.') ||
(experimentInsightType === InsightType.FUNNELS &&
experimentFiltersLength < 2 &&
'The experiment needs at least two funnel steps.')
}
form="edit-experiment-goal-form"
onClick={() => {
updateExperimentGoal(experiment.filters)
}}
type="primary"
loading={experimentLoading}
data-attr="create-annotation-submit"
>
Save
</LemonButton>
</div>
}
>
<Form
logic={experimentLogic}
props={{ experimentId }}
formKey="experiment"
id="edit-experiment-goal-form"
className="space-y-4"
>
<Field name="filters">
<MetricSelector
dashboardItemId={EXPERIMENT_INSIGHT_ID}
setPreviewInsight={setNewExperimentInsight}
showDateRangeBanner
/>
</Field>
</Form>
</LemonModal>
)
}
export function ExperimentExposureModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
const { experiment, isExperimentExposureModalOpen, experimentLoading } = useValues(
experimentLogic({ experimentId })
)
const { closeExperimentExposureModal, updateExperimentExposure, setExperimentExposureInsight } = useActions(
experimentLogic({ experimentId })
)
return (
<LemonModal
isOpen={isExperimentExposureModalOpen}
onClose={closeExperimentExposureModal}
width={1000}
title="Change experiment exposure"
footer={
<div className="flex items-center gap-2">
<LemonButton
form="edit-experiment-exposure-form"
type="secondary"
onClick={closeExperimentExposureModal}
>
Cancel
</LemonButton>
<LemonButton
form="edit-experiment-exposure-form"
onClick={() => {
if (experiment.parameters.custom_exposure_filter) {
updateExperimentExposure(experiment.parameters.custom_exposure_filter)
}
}}
type="primary"
loading={experimentLoading}
data-attr="create-annotation-submit"
>
Save
</LemonButton>
</div>
}
>
<Form
logic={experimentLogic}
props={{ experimentId }}
formKey="experiment"
id="edit-experiment-exposure-form"
className="space-y-4"
>
<Field name="filters">
<MetricSelector
dashboardItemId={EXPERIMENT_EXPOSURE_INSIGHT_ID}
setPreviewInsight={setExperimentExposureInsight}
forceTrendExposureMetric
/>
</Field>
</Form>
</LemonModal>
)
}
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 (
<div>
@ -224,8 +253,8 @@ export function Goal(): JSX.Element {
title={
<>
{' '}
This <b>{experimentInsightType === InsightType.FUNNELS ? 'funnel' : 'trend'}</b>{' '}
{experimentInsightType === InsightType.FUNNELS
This <b>{metricType === InsightType.FUNNELS ? 'funnel' : 'trend'}</b>{' '}
{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
</LemonButton>
@ -254,14 +296,27 @@ export function Goal(): JSX.Element {
<div className="inline-flex space-x-6">
<div>
<div className="card-secondary mb-2 mt-2">
{experimentInsightType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'}
{metricType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'}
</div>
<MetricDisplay filters={experiment.filters} />
<LemonButton size="xsmall" type="secondary" onClick={openExperimentGoalModal}>
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? (
metricType === InsightType.FUNNELS ? (
<MetricDisplayFunnels
query={(experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query}
/>
) : (
<MetricDisplayTrends
query={(experiment.metrics[0] as ExperimentTrendsQuery).count_query}
/>
)
) : (
<MetricDisplayOld filters={experiment.filters} />
)}
<LemonButton size="xsmall" type="secondary" onClick={() => setIsModalOpen(true)}>
Change goal
</LemonButton>
</div>
{experimentInsightType === InsightType.TRENDS && !experimentMathAggregationForTrends() && (
{metricType === InsightType.TRENDS && !experimentMathAggregationForTrends() && (
<>
<LemonDivider className="" vertical />
<div className="">
@ -273,6 +328,14 @@ export function Goal(): JSX.Element {
)}
</div>
)}
<PrimaryMetricModal
experimentId={experimentId}
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
loadExperiment()
}}
/>
</div>
)
}

View File

@ -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 (
<LemonModal
isOpen={isModalOpen}
onClose={closeModal}
width={1000}
title={
showResults
? secondaryMetricModal.name
: existingModalSecondaryMetric
? 'Edit secondary metric'
: 'New secondary metric'
}
footer={
showResults ? (
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={closeModal}>
Close
</LemonButton>
) : (
<>
{existingModalSecondaryMetric && (
<LemonButton
className="mr-auto"
form="secondary-metric-modal-form"
type="secondary"
status="danger"
onClick={() => deleteMetric(metricIdx)}
>
Delete
</LemonButton>
)}
<div className="flex items-center gap-2">
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={closeModal}>
Cancel
</LemonButton>
<LemonButton
form="secondary-metric-modal-form"
onClick={saveSecondaryMetric}
type="primary"
loading={isSecondaryMetricModalSubmitting}
data-attr="create-annotation-submit"
>
{existingModalSecondaryMetric ? 'Save' : 'Create'}
</LemonButton>
</div>
</>
)
}
>
{showResults ? (
<ResultsQuery targetResults={targetResults} showTable={false} />
) : (
<Form
logic={secondaryMetricsLogic}
props={{ onMetricsChange, initialMetrics, experimentId, defaultAggregationType }}
formKey="secondaryMetricModal"
id="secondary-metric-modal-form"
className="space-y-4"
>
<LemonField name="name" label="Name">
<LemonInput data-attr="secondary-metric-name" />
</LemonField>
<LemonField name="filters" label="Query">
<MetricSelector
dashboardItemId={SECONDARY_METRIC_INSIGHT_ID}
setPreviewInsight={setPreviewInsight}
showDateRangeBanner={isExperimentRunning}
/>
</LemonField>
</Form>
)}
</LemonModal>
)
}
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<number | null>(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<any> = [
{
@ -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 => (
<div className="">
<div className="flex">
<div className="w-3/4 truncate">{capitalizeFirstLetter(metric.name)}</div>
<div className="w-3/4 truncate">{capitalizeFirstLetter(metric.name || '')}</div>
<div className="w-1/4 flex flex-col justify-end">
<div className="ml-auto space-x-2 pb-1 inline-flex">
<LemonButton
@ -171,7 +108,7 @@ export function SecondaryMetricsTable({
type="secondary"
size="xsmall"
icon={<IconAreaChart />}
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={<IconPencil />}
onClick={() => openModalToEditSecondaryMetric(metric, idx, false)}
onClick={() => openEditModal(idx)}
/>
</div>
</div>
@ -191,7 +128,7 @@ export function SecondaryMetricsTable({
</div>
)
if (metric.filters.insight === InsightType.TRENDS) {
if (metricType === InsightType.TRENDS) {
columns.push({
title: <Header />,
children: [
@ -230,7 +167,11 @@ export function SecondaryMetricsTable({
if (item.variant === 'control') {
return <em>Baseline</em>
}
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 <em>Baseline</em>
}
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({
<div className="ml-auto">
{metrics && metrics.length > 0 && (
<div className="mb-2 mt-4 justify-end">
<LemonButton
type="secondary"
size="small"
onClick={openModalToCreateSecondaryMetric}
disabledReason={
metrics.length >= MAX_SECONDARY_METRICS
? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.`
: undefined
}
>
Add metric
</LemonButton>
<AddSecondaryMetricButton
experimentId={experimentId}
metrics={metrics}
openEditModal={openEditModal}
/>
</div>
)}
</div>
@ -365,24 +303,76 @@ export function SecondaryMetricsTable({
Add up to {MAX_SECONDARY_METRICS} secondary metrics to monitor side effects of your
experiment.
</div>
<LemonButton
icon={<IconPlus />}
type="secondary"
size="small"
onClick={openModalToCreateSecondaryMetric}
>
Add metric
</LemonButton>
<AddSecondaryMetricButton
experimentId={experimentId}
metrics={metrics}
openEditModal={openEditModal}
/>
</div>
</div>
)}
</div>
<SecondaryMetricsModal
onMetricsChange={onMetricsChange}
initialMetrics={initialMetrics}
<SecondaryMetricModal
metricIdx={modalMetricIdx ?? 0}
isOpen={isEditModalOpen}
onClose={closeEditModal}
experimentId={experimentId}
defaultAggregationType={defaultAggregationType}
/>
<SecondaryMetricChartModal
experimentId={experimentId}
metricIdx={modalMetricIdx ?? 0}
isOpen={isChartModalOpen}
onClose={closeChartModal}
/>
</>
)
}
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 (
<LemonButton
icon={<IconPlus />}
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
</LemonButton>
)
}

View File

@ -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 <em>Baseline</em>
}
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 (
<div className="font-semibold">{`[${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 <em>Baseline</em>
}
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 (
<div className="font-semibold">{`[${lowerBound > 0 ? '+' : ''}${lowerBound.toFixed(2)}%, ${

View File

@ -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<string[]>(getInitialMediaIds())
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({})
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(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 (
<div className="space-y-4">
{!mediaId ? (
<LemonFileInput
accept="image/*"
multiple={false}
onChange={setFilesToUpload}
loading={uploading}
value={filesToUpload}
callToAction={
<>
<IconUpload className="text-2xl" />
<span>Upload a preview of this variant's UI</span>
</>
}
/>
) : (
<div className="relative">
<div className="text-muted inline-flex flow-row items-center gap-1 cursor-pointer">
<div onClick={() => setIsModalOpen(true)} className="cursor-zoom-in relative">
<div className="relative flex overflow-hidden select-none size-20 w-full rounded before:absolute before:inset-0 before:border before:rounded">
{isLoadingImage && <LemonSkeleton className="absolute inset-0" />}
<img
className="size-full object-cover"
src={mediaId.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
onError={() => setIsLoadingImage(false)}
onLoad={() => setIsLoadingImage(false)}
/>
</div>
<div className="absolute -inset-2 group">
<LemonButton
icon={<IconX />}
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"
/>
<div className="flex gap-4 items-start">
{mediaIds.map((mediaId, index) => (
<div key={mediaId} className="relative">
<div className="text-muted inline-flex flow-row items-center gap-1 cursor-pointer">
<div onClick={() => setSelectedImageIndex(index)} className="cursor-zoom-in relative">
<div
className={`relative flex overflow-hidden select-none ${widthClass} h-16 rounded before:absolute before:inset-0 before:border before:rounded`}
>
{loadingImages[mediaId] && <LemonSkeleton className="absolute inset-0" />}
<img
className="w-full h-full object-cover"
src={mediaId.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
onError={() => handleImageError(mediaId)}
onLoad={() => handleImageLoad(mediaId)}
/>
</div>
<div className="absolute -inset-2 group">
<LemonButton
icon={<IconX />}
onClick={(e) => {
e.stopPropagation()
handleDelete(index)
}}
size="small"
tooltip="Remove"
tooltipPlacement="right"
noPadding
className="group-hover:flex hidden absolute right-0 top-0"
/>
</div>
</div>
</div>
</div>
</div>
)}
))}
{mediaIds.length < 5 && (
<div className={`relative ${widthClass} h-16`}>
<LemonFileInput
accept="image/*"
multiple={false}
onChange={setFilesToUpload}
loading={uploading}
value={filesToUpload}
callToAction={
<div className="flex items-center justify-center w-full h-16 border border-dashed rounded cursor-pointer hover:border-[var(--primary)]">
<span className="text-2xl text-muted">+</span>
</div>
}
/>
</div>
)}
</div>
<LemonModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
isOpen={selectedImageIndex !== null}
onClose={() => setSelectedImageIndex(null)}
title={
<div className="flex items-center gap-2">
<span>Screenshot</span>
<span>Screenshot {selectedImageIndex !== null ? selectedImageIndex + 1 : ''}</span>
<LemonDivider className="my-0 mx-1" vertical />
<VariantTag experimentId={experiment.id} variantKey={variantKey} />
{rolloutPercentage !== undefined && (
@ -104,12 +161,20 @@ export function VariantScreenshot({
</div>
}
>
<img
src={mediaId?.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
alt={`Screenshot: ${variantKey}`}
className="max-w-full max-h-[80vh] overflow-auto"
/>
{selectedImageIndex !== null && mediaIds[selectedImageIndex] && (
<img
src={
mediaIds[selectedImageIndex]?.startsWith('data:')
? mediaIds[selectedImageIndex]
: `/uploaded_media/${mediaIds[selectedImageIndex]}`
}
alt={`Screenshot ${selectedImageIndex + 1}: ${variantKey}`}
className="max-w-full max-h-[80vh] overflow-auto"
/>
)}
</LemonModal>
</div>
)
}
export default VariantScreenshot

View File

@ -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 = <IconAreaChart /> }: { icon?: JSX.Element
}
export function ResultsHeader(): JSX.Element {
const { experimentResults } = useValues(experimentLogic)
return (
<div className="flex">
<div className="w-1/2">
@ -275,9 +276,7 @@ export function ResultsHeader(): JSX.Element {
</div>
<div className="w-1/2 flex flex-col justify-end">
<div className="ml-auto">
<ExploreButton />
</div>
<div className="ml-auto">{experimentResults && <ExploreButton />}</div>
</div>
</div>
)
@ -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 {
</LemonBanner>
)
}
if (experimentInsightType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) {
if (metricType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) {
return (
<LemonBanner type="warning" className="mt-4">
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
) {

View File

@ -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 (
<>
<div className="border rounded">
<div className="card-secondary p-4 border-b">Experiment workflow</div>
<div className="p-6 space-y-1.5">
<div className="flex">
<div className="exp-workflow-step rounded p-2 bg-primary-highlight w-full">
<div className="flex items-center">
<IconCheckCircle className="text-xl text-primary" />
<b className="ml-2">Create experiment</b>
</div>
<div className="ml-8">Set variants, select participants, and add secondary metrics</div>
</div>
</div>
<div className="flex">
<div
className={clsx(
'w-full exp-workflow-step rounded p-2 cursor-pointer',
workflowValidateStepCompleted && 'bg-primary-highlight'
)}
onClick={() => setWorkflowValidateStepCompleted(!workflowValidateStepCompleted)}
>
<div className="flex items-center">
{workflowValidateStepCompleted ? (
<IconCheckCircle className="text-xl text-primary" />
) : (
<IconRadioButtonUnchecked className="text-xl" />
)}
<b className="ml-2">Validate experiment</b>
</div>
<div className="ml-8">
Once you've written your code, it's a good idea to test that each variant behaves as
you'd expect.
</div>
</div>
</div>
<div className="flex">
<div
className={clsx(
'w-full exp-workflow-step rounded p-2 cursor-pointer',
workflowLaunchStepCompleted && 'bg-primary-highlight'
)}
onClick={() => setWorkflowLaunchStepCompleted(!workflowLaunchStepCompleted)}
>
<div className="flex items-center">
{workflowLaunchStepCompleted ? (
<IconCheckCircle className="text-xl text-primary" />
) : (
<IconRadioButtonUnchecked className="text-xl" />
)}
<b className="ml-2">Launch experiment</b>
</div>
<div className="ml-8">
Run your experiment, monitor results, and decide when to terminate your experiment.
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@ -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<FilterType>) => 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 (
<>
<div className="flex items-center w-full gap-2 mb-4">
<span>Insight Type</span>
<LemonSelect
data-attr="metrics-selector"
value={isTrends ? InsightType.TRENDS : InsightType.FUNNELS}
onChange={(val) => {
val && setPreviewInsight({ insight: val })
}}
options={[
{ value: InsightType.TRENDS, label: <b>Trends</b> },
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
]}
disabledReason={forceTrendExposureMetric ? 'Exposure metric can only be a trend graph' : undefined}
/>
</div>
<div>
<SamplingFilter
insightProps={insightProps}
infoTooltipContent="Sampling on experiment goals is an Alpha feature to enable faster computation of experiment results."
/>
<br />
</div>
<ExperimentInsightCreator insightProps={insightProps} />
{showDateRangeBanner && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
<BindLogic logic={insightLogic} props={insightProps}>
<Query query={query} context={{ insightProps }} readOnly />
</BindLogic>
</div>
</>
)
}
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 (
<>
<ActionFilter
bordered
filters={queryNodeToFilter(querySource as InsightQueryNode)}
setFilters={(payload: Partial<FilterType>): 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,
]}
/>
<div className="mt-4 space-y-4">
{!isTrends && (
<>
<div className="flex items-center w-full gap-2">
<span>Aggregating by</span>
<AggregationSelect insightProps={insightProps} hogqlAvailable />
</div>
<FunnelConversionWindowFilter insightProps={insightProps} />
<AttributionSelect insightProps={insightProps} />
</>
)}
<InsightTestAccountFilter query={querySource as InsightQueryNode} setQuery={updateQuerySource} />
</div>
</>
)
}
export function AttributionSelect({ insightProps }: EditorFilterProps): JSX.Element {
return (
<div className="flex items-center w-full gap-2">
<div className="flex">
<span>Attribution type</span>
<Tooltip
closeDelayMs={200}
title={
<div className="space-y-2">
<div>
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.
</div>
<div>
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:
</div>
<ul className="list-disc pl-4">
<li>First touchpoint: the first property value seen in any of the steps is chosen.</li>
<li>Last touchpoint: the last property value seen from all steps is chosen.</li>
<li>
All steps: the property value must be seen in all steps to be considered in the
funnel.
</li>
<li>Specific step: only the property value seen at the selected step is chosen.</li>
</ul>
<div>
Read more in the{' '}
<Link to="https://posthog.com/docs/product-analytics/funnels#attribution-types">
documentation.
</Link>
</div>
</div>
}
>
<IconInfo className="text-xl text-muted-alt shrink-0 ml-1" />
</Tooltip>
</div>
<Attribution insightProps={insightProps} />
</div>
)
}

View File

@ -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 (
<>
<div className="mb-4">
<LemonLabel>Name (optional)</LemonLabel>
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
<LemonInput
value={currentMetric.name}
onChange={(newName) => {
setFunnelsMetric({
metricIdx,
name: newName,
})
}}
/>
)}
</div>
<ActionFilter
bordered
filters={(() => {
// :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<FilterType>): 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}
/>
<div className="mt-4 space-y-4">
<FunnelAggregationSelect
value={(() => {
// :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,
},
})
}
}}
/>
<FunnelConversionWindowFilter
funnelWindowInterval={(() => {
// :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,
},
})
}
}}
/>
<FunnelAttributionSelect
value={(() => {
// :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
)
})()}
/>
<TestAccountFilterSwitch
checked={(() => {
// :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
/>
</div>
{isExperimentRunning && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
<Query
query={{
kind: NodeKind.InsightVizNode,
source: (() => {
// :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
/>
</div>
</>
)
}

View File

@ -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 (
<>
<div className="mb-4">
<LemonLabel>Name (optional)</LemonLabel>
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
<LemonInput
value={currentMetric.name}
onChange={(newName) => {
setTrendsMetric({
metricIdx,
name: newName,
})
}}
/>
)}
</div>
<ActionFilter
bordered
filters={(() => {
// :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<FilterType>): 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}
/>
<div className="mt-4 space-y-4">
<TestAccountFilterSwitch
checked={(() => {
// :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
/>
</div>
{isExperimentRunning && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
<Query
query={{
kind: NodeKind.InsightVizNode,
source: (() => {
// :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
/>
</div>
</>
)
}

View File

@ -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 (
<>
<ActionFilter
bordered
filters={(() => {
// :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<FilterType>): 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}
/>
<div className="mt-4 space-y-4">
<TestAccountFilterSwitch
checked={(() => {
// :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
/>
</div>
{isExperimentRunning && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
<Query
query={{
kind: NodeKind.InsightVizNode,
source: (() => {
// :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
/>
</div>
</>
)
}

View File

@ -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 (
<LemonModal
isOpen={isOpen}
onClose={onClose}
width={1000}
title="Change experiment goal"
footer={
<div className="flex items-center gap-2">
<LemonButton form="edit-experiment-goal-form" type="secondary" onClick={onClose}>
Cancel
</LemonButton>
<LemonButton
disabledReason={
metricType === InsightType.FUNNELS &&
funnelStepsLength < 2 &&
'The experiment needs at least two funnel steps.'
}
form="edit-experiment-goal-form"
onClick={() => {
updateExperimentGoal(experiment.filters)
}}
type="primary"
loading={experimentLoading}
data-attr="create-annotation-submit"
>
Save
</LemonButton>
</div>
}
>
<div className="flex items-center w-full gap-2 mb-4">
<span>Metric type</span>
<LemonSelect
data-attr="metrics-selector"
value={metricType}
onChange={(newMetricType) => {
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: <b>Trends</b> },
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
]}
/>
</div>
{metricType === InsightType.TRENDS ? <PrimaryGoalTrends /> : <PrimaryGoalFunnels />}
</LemonModal>
)
}

View File

@ -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 (
<LemonModal
isOpen={isOpen}
onClose={onClose}
width={1000}
title="Change experiment exposure"
footer={
<div className="flex items-center gap-2">
<LemonButton form="edit-experiment-exposure-form" type="secondary" onClick={onClose}>
Cancel
</LemonButton>
<LemonButton
form="edit-experiment-exposure-form"
onClick={() => {
// :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
</LemonButton>
</div>
}
>
<PrimaryGoalTrendsExposure />
</LemonModal>
)
}

View File

@ -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 (
<>
<div className="mb-4">
<LemonLabel>Name (optional)</LemonLabel>
<LemonInput
value={(() => {
// :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
),
})
}
}}
/>
</div>
<ActionFilter
bordered
filters={(() => {
// :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<FilterType>): 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}
/>
<div className="mt-4 space-y-4">
<FunnelAggregationSelect
value={(() => {
// :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
),
})
}
}}
/>
<FunnelConversionWindowFilter
funnelWindowInterval={(() => {
// :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
),
})
}
}}
/>
<FunnelAttributionSelect
value={(() => {
// :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
)
})()}
/>
<TestAccountFilterSwitch
checked={(() => {
// :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
/>
</div>
{isExperimentRunning && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
<Query
query={{
kind: NodeKind.InsightVizNode,
source: (() => {
// :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
/>
</div>
</>
)
}

View File

@ -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 (
<>
<div className="mb-4">
<LemonLabel>Name (optional)</LemonLabel>
<LemonInput
value={(() => {
// :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
),
})
}
}}
/>
</div>
<ActionFilter
bordered
filters={(() => {
// :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<FilterType>): 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}
/>
<div className="mt-4 space-y-4">
<TestAccountFilterSwitch
checked={(() => {
// :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
/>
</div>
{isExperimentRunning && (
<LemonBanner type="info" className="mt-3 mb-3">
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
mismatch between the preview and the actual results.
</LemonBanner>
)}
<div className="mt-4">
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
<Query
query={{
kind: NodeKind.InsightVizNode,
source: (() => {
// :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
/>
</div>
</>
)
}

View File

@ -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 (
<LemonModal
isOpen={isOpen}
onClose={onClose}
width={1000}
title="Results"
footer={
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={onClose}>
Close
</LemonButton>
}
>
<ResultsQuery targetResults={targetResults} showTable={false} />
</LemonModal>
)
}

View File

@ -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 (
<LemonModal
isOpen={isOpen}
onClose={onClose}
width={1000}
title="Change secondary metric"
footer={
<div className="flex items-center w-full">
<LemonButton
type="secondary"
status="danger"
onClick={() => {
// :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
</LemonButton>
<div className="flex items-center gap-2 ml-auto">
<LemonButton type="secondary" onClick={onClose}>
Cancel
</LemonButton>
<LemonButton
onClick={() => {
// :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
</LemonButton>
</div>
</div>
}
>
<div className="flex items-center w-full gap-2 mb-4">
<span>Metric type</span>
<LemonSelect
data-attr="metrics-selector"
value={metricType}
onChange={(newMetricType) => {
// :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: <b>Trends</b> },
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
]}
/>
</div>
{metricType === InsightType.TRENDS ? (
<SecondaryGoalTrends metricIdx={metricIdx} />
) : (
<SecondaryGoalFunnels metricIdx={metricIdx} />
)}
</LemonModal>
)
}

View File

@ -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<string>[] = [
{
title: 'Event Aggregation',
options: [
{
value: UNIQUE_USERS,
label: 'Unique users',
},
],
},
]
if (needsUpgradeForGroups || canStartUsingGroups) {
// if (false) {
optionSections[0].footer = <GroupIntroductionFooter needsUpgrade={needsUpgradeForGroups} />
} 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: <span className="font-mono">{value}</span>,
labelInMenu: function CustomHogQLOptionWrapped({ onSelect }) {
return (
// eslint-disable-next-line react/forbid-dom-props
<div className="w-120" style={{ maxWidth: 'max(60vw, 20rem)' }}>
<HogQLEditor
onChange={onSelect}
value={value}
placeholder={
"Enter HogQL expression, such as:\n- distinct_id\n- properties.$session_id\n- concat(distinct_id, ' ', properties.$session_id)\n- if(1 < 2, 'one', 'two')"
}
/>
</div>
)
},
},
],
})
return (
<div className="flex items-center w-full gap-2">
<span>Aggregating by</span>
<LemonSelect
className="flex-1"
value={value}
onChange={onChange}
options={optionSections}
dropdownMatchSelectWidth={false}
/>
</div>
)
}
// 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<FunnelConversionWindowTimeUnit>[] = 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 (
<div className="flex items-center gap-2">
<span className="flex whitespace-nowrap">
Conversion window limit
<Tooltip
title={
<>
<b>Recommended!</b> 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.
</>
}
>
<IconInfo className="w-4 info-indicator" />
</Tooltip>
</span>
<div className="flex items-center gap-2">
<LemonInput
type="number"
className="max-w-20"
fullWidth={false}
min={intervalBounds[0]}
max={intervalBounds[1]}
value={funnelWindowInterval}
onChange={onFunnelWindowIntervalChange}
/>
<LemonSelect
dropdownMatchSelectWidth={false}
value={funnelWindowIntervalUnit}
onChange={onFunnelWindowIntervalUnitChange}
options={options}
/>
</div>
</div>
)
}
// 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 (
<div className="flex items-center w-full gap-2">
<div className="flex">
<span>Attribution type</span>
<Tooltip
closeDelayMs={200}
title={
<div className="space-y-2">
<div>
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.
</div>
<div>
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:
</div>
<ul className="list-disc pl-4">
<li>First touchpoint: the first property value seen in any of the steps is chosen.</li>
<li>Last touchpoint: the last property value seen from all steps is chosen.</li>
<li>
All steps: the property value must be seen in all steps to be considered in the
funnel.
</li>
<li>Specific step: only the property value seen at the selected step is chosen.</li>
</ul>
<div>
Read more in the{' '}
<Link to="https://posthog.com/docs/product-analytics/funnels#attribution-types">
documentation.
</Link>
</div>
</div>
}
>
<IconInfo className="text-xl text-muted-alt shrink-0 ml-1" />
</Tooltip>
</div>
<LemonSelect
value={value}
placeholder="Attribution"
options={[
{ value: BreakdownAttributionType.FirstTouch, label: 'First touchpoint' },
{ value: BreakdownAttributionType.LastTouch, label: 'Last touchpoint' },
{ value: BreakdownAttributionType.AllSteps, label: 'All steps' },
{
value: BreakdownAttributionType.Step,
label: 'Any step',
hidden: funnelOrderType !== StepOrderValue.UNORDERED,
},
{
label: 'Specific step',
options: Array(FUNNEL_STEP_COUNT_LIMIT)
.fill(null)
.map((_, stepIndex) => ({
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"
/>
</div>
)
}

View File

@ -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',
}

View File

@ -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<experimentLogicType>([
['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,
@ -157,24 +163,13 @@ export const experimentLogic = kea<experimentLogicType>([
'reportExperimentResultsLoadingTimeout',
'reportExperimentReleaseConditionsViewed',
],
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>) => ({ experiment }),
createExperiment: (draft?: boolean) => ({ draft }),
setExperimentFeatureFlagKeyFromName: true,
setNewExperimentInsight: (filters?: Partial<FilterType>) => ({ filters }),
setExperimentType: (type?: string) => ({ type }),
setExperimentExposureInsight: (filters?: Partial<FilterType>) => ({ filters }),
removeExperimentGroup: (idx: number) => ({ idx }),
setEditExperiment: (editing: boolean) => ({ editing }),
setExperimentResultCalculationError: (error: ExperimentResultCalculationError) => ({ error }),
@ -183,7 +178,6 @@ export const experimentLogic = kea<experimentLogicType>([
updateExperimentGoal: (filters: Partial<FilterType>) => ({ filters }),
updateExperimentCollectionGoal: true,
updateExperimentExposure: (filters: Partial<FilterType> | null) => ({ filters }),
updateExperimentSecondaryMetrics: (metrics: SecondaryExperimentMetric[]) => ({ metrics }),
changeExperimentStartDate: (startDate: string) => ({ startDate }),
launchExperiment: true,
endExperiment: true,
@ -191,10 +185,6 @@ export const experimentLogic = kea<experimentLogicType>([
archiveExperiment: true,
resetRunningExperiment: true,
checkFlagImplementationWarning: true,
openExperimentGoalModal: true,
closeExperimentGoalModal: true,
openExperimentExposureModal: true,
closeExperimentExposureModal: true,
openExperimentCollectionGoalModal: true,
closeExperimentCollectionGoalModal: true,
openShipVariantModal: true,
@ -203,7 +193,70 @@ export const experimentLogic = kea<experimentLogicType>([
closeDistributionModal: true,
openReleaseConditionsModal: true,
closeReleaseConditionsModal: true,
updateExperimentVariantImages: (variantPreviewMediaIds: Record<string, string>) => ({ variantPreviewMediaIds }),
updateExperimentVariantImages: (variantPreviewMediaIds: Record<string, string[]>) => ({
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 +264,6 @@ export const experimentLogic = kea<experimentLogicType>([
{ ...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 +315,89 @@ export const experimentLogic = kea<experimentLogicType>([
},
}
},
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 +412,6 @@ export const experimentLogic = kea<experimentLogicType>([
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 +424,6 @@ export const experimentLogic = kea<experimentLogicType>([
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 +557,6 @@ export const experimentLogic = kea<experimentLogicType>([
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 +595,13 @@ export const experimentLogic = kea<experimentLogicType>([
})
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 +609,6 @@ export const experimentLogic = kea<experimentLogicType>([
minimum_detectable_effect: minimumDetectableEffect,
},
})
actions.closeExperimentGoalModal()
},
updateExperimentCollectionGoal: async () => {
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
@ -614,25 +625,12 @@ export const experimentLogic = kea<experimentLogicType>([
},
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 +646,8 @@ export const experimentLogic = kea<experimentLogicType>([
},
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 +720,6 @@ export const experimentLogic = kea<experimentLogicType>([
}
}
},
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 +806,19 @@ export const experimentLogic = kea<experimentLogicType>([
| 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 +832,13 @@ export const experimentLogic = kea<experimentLogicType>([
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 +861,17 @@ export const experimentLogic = kea<experimentLogicType>([
| 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 +964,29 @@ export const experimentLogic = kea<experimentLogicType>([
() => [(_, 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 +1035,7 @@ export const experimentLogic = kea<experimentLogicType>([
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 +1066,12 @@ export const experimentLogic = kea<experimentLogicType>([
},
],
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 +1182,7 @@ export const experimentLogic = kea<experimentLogicType>([
(s) => [
s.experiment,
s.variants,
s.experimentInsightType,
s.getMetricType,
s.funnelResults,
s.conversionMetrics,
s.expectedRunningTime,
@ -1186,7 +1193,7 @@ export const experimentLogic = kea<experimentLogicType>([
(
experiment,
variants,
experimentInsightType,
getMetricType,
funnelResults,
conversionMetrics,
expectedRunningTime,
@ -1194,7 +1201,7 @@ export const experimentLogic = kea<experimentLogicType>([
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 +1289,15 @@ export const experimentLogic = kea<experimentLogicType>([
| 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 +1329,8 @@ export const experimentLogic = kea<experimentLogicType>([
},
],
getIndexForVariant: [
(s) => [s.experimentInsightType],
(experimentInsightType) =>
(s) => [s.getMetricType],
(getMetricType) =>
(
experimentResults:
| Partial<ExperimentResults['result']>
@ -1338,7 +1346,7 @@ export const experimentLogic = kea<experimentLogicType>([
}
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 +1368,7 @@ export const experimentLogic = kea<experimentLogicType>([
}
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 +1487,17 @@ export const experimentLogic = kea<experimentLogicType>([
},
],
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 +1510,9 @@ export const experimentLogic = kea<experimentLogicType>([
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 +1578,9 @@ export const experimentLogic = kea<experimentLogicType>([
},
],
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 +1623,13 @@ export const experimentLogic = kea<experimentLogicType>([
},
],
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 +1685,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,
},
},
}
}

View File

@ -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<FilterType>
}
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<secondaryMetricsLogicType>([
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<FilterType>) => ({ 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)
},
})),
])

View File

@ -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 () => {

View File

@ -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) {

View File

@ -164,7 +164,7 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
actions.setTotalUsers(response.total_users)
},
addConditionSet: () => {
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<featureFlagReleaseCondition
actions.setAffectedUsers(index, undefined)
const properties = condition.properties
if (!properties || properties?.length === 0 || properties.some(isEmptyProperty)) {
// don't compute for full rollouts or empty conditions
if (!properties || properties.some(isEmptyProperty)) {
// don't compute for incomplete conditions
usersAffected.push(Promise.resolve({ users_affected: -1, total_users: -1 }))
} else if (properties.length === 0) {
// Request total users for empty condition sets
const responsePromise = api.create(
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,
{
condition: { properties: [] },
group_type_index: values.filters?.aggregation_group_type_index ?? null,
}
)
usersAffected.push(responsePromise)
} else {
const responsePromise = api.create(
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,

View File

@ -436,6 +436,7 @@ export function FeatureFlags(): JSX.Element {
content: <ActivityLog scope={ActivityScope.FEATURE_FLAG} />,
},
]}
data-attr="feature-flags-tab-navigation"
/>
</div>
)

View File

@ -302,7 +302,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
}),
forms(({ actions, values }) => ({
featureFlag: {
defaults: { ...NEW_FLAG } as FeatureFlagType,
defaults: { ...NEW_FLAG },
errors: ({ key, filters }) => {
return {
key: validateFeatureFlagKey(key),

View File

@ -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,
})
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
.toMatchValues({
affectedUsers: { 0: -1, 1: 140, 2: 240 },
affectedUsers: { 0: 140, 1: 240 },
totalUsers: 2002,
})
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
.toMatchValues({
affectedUsers: { 0: -1, 1: 140, 2: 240, 3: 500 },
affectedUsers: { 0: 140, 1: 240, 2: 500 },
totalUsers: 2000,
})
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
.toMatchValues({
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)
})
})
})

View File

@ -239,7 +239,17 @@ export const featureFlagsLogic = kea<featureFlagsLogicType>([
pageFiltersFromUrl.page = parseInt(page)
}
actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl })
// Initialize filters with the URL params if none are set
const isInitializingFilters =
objectsEqual(DEFAULT_FILTERS, values.filters) && !objectsEqual(DEFAULT_FILTERS, pageFiltersFromUrl)
/**
* Pagination search param in the URL is modified directly by the LemonTable component,
* so let's update filter state if it changes
*/
const isChangingPage = page !== undefined && page !== values.filters.page
if (isInitializingFilters || isChangingPage) {
actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl })
}
},
})),
events(({ actions }) => ({

View File

@ -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={

View File

@ -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') {

View File

@ -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<FunnelConversionWindowTimeUnit, number[]> = {
export const TIME_INTERVAL_BOUNDS: Record<FunnelConversionWindowTimeUnit, number[]> = {
[FunnelConversionWindowTimeUnit.Second]: [1, 3600],
[FunnelConversionWindowTimeUnit.Minute]: [1, 1440],
[FunnelConversionWindowTimeUnit.Hour]: [1, 24],

View File

@ -25,7 +25,7 @@ export function QuestionInput(): JSX.Element {
className={clsx(
!isFloating
? 'w-[min(44rem,100%)] relative'
: 'w-full max-w-200 sticky z-10 self-center p-1 mx-3 mb-3 bottom-3 border border-[var(--glass-border-3000)] rounded-[0.625rem] backdrop-blur bg-[var(--glass-bg-3000)]'
: 'w-full max-w-192 sticky z-10 self-center p-1 mx-4 mb-3 bottom-3 border border-[var(--glass-border-3000)] rounded-[0.625rem] backdrop-blur bg-[var(--glass-bg-3000)]'
)}
>
<LemonTextArea

View File

@ -54,6 +54,8 @@ export function QuestionSuggestions(): JSX.Element {
size="xsmall"
type="secondary"
sideIcon={<IconArrowUpRight />}
center
className="shrink"
>
{suggestion}
</LemonButton>

Some files were not shown because too many files have changed in this diff Show More