diff --git a/ee/clickhouse/views/experiment_saved_metrics.py b/ee/clickhouse/views/experiment_saved_metrics.py index 9dc2fcd94e073..911a34530c0b2 100644 --- a/ee/clickhouse/views/experiment_saved_metrics.py +++ b/ee/clickhouse/views/experiment_saved_metrics.py @@ -6,10 +6,13 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer from posthog.models.experiment import ExperimentSavedMetric, ExperimentToSavedMetric -from posthog.schema import FunnelsQuery, TrendsQuery +from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery class ExperimentToSavedMetricSerializer(serializers.ModelSerializer): + query = serializers.JSONField(source="saved_metric.query", read_only=True) + name = serializers.CharField(source="saved_metric.name", read_only=True) + class Meta: model = ExperimentToSavedMetric fields = [ @@ -18,6 +21,8 @@ class Meta: "saved_metric", "metadata", "created_at", + "query", + "name", ] read_only_fields = [ "id", @@ -52,15 +57,15 @@ def validate_query(self, value): metric_query = value - if metric_query.get("kind") not in ["TrendsQuery", "FunnelsQuery"]: - raise ValidationError("Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + 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 try: - if metric_query["kind"] == "TrendsQuery": - TrendsQuery(**metric_query) + if metric_query["kind"] == "ExperimentTrendsQuery": + ExperimentTrendsQuery(**metric_query) else: - FunnelsQuery(**metric_query) + ExperimentFunnelsQuery(**metric_query) except pydantic.ValidationError as e: raise ValidationError(str(e.errors())) from e diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py index b8444d819bafa..273e6d1f612f2 100644 --- a/ee/clickhouse/views/experiments.py +++ b/ee/clickhouse/views/experiments.py @@ -165,9 +165,7 @@ class ExperimentSerializer(serializers.ModelSerializer): queryset=ExperimentHoldout.objects.all(), source="holdout", required=False, allow_null=True ) saved_metrics = ExperimentToSavedMetricSerializer(many=True, source="experimenttosavedmetric_set", read_only=True) - saved_metrics_ids = serializers.ListField( - child=serializers.JSONField(), write_only=True, required=False, allow_null=True - ) + saved_metrics_ids = serializers.ListField(child=serializers.JSONField(), required=False, allow_null=True) class Meta: model = Experiment diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py index a4c8bf9f3eb13..4501301e3befd 100644 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py @@ -370,7 +370,13 @@ def test_saved_metrics(self): { "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": { + "kind": "TrendsQuery", + "series": [{"kind": "EventsNode", "event": "$pageview"}], + }, + }, }, ) @@ -380,7 +386,10 @@ def test_saved_metrics(self): self.assertEqual(response.json()["description"], "Test description") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) self.assertEqual(response.json()["created_by"]["id"], self.user.pk) @@ -418,7 +427,11 @@ def test_saved_metrics(self): saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( - saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]} + saved_metric.query, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) # Now try updating experiment with new saved metric @@ -427,7 +440,10 @@ def test_saved_metrics(self): { "name": "Test Experiment saved metric 2", "description": "Test description 2", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, }, ) @@ -513,7 +529,10 @@ def test_validate_saved_metrics_payload(self): { "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, ) diff --git a/ee/clickhouse/views/test/test_experiment_saved_metrics.py b/ee/clickhouse/views/test/test_experiment_saved_metrics.py index 51ef8242614ac..90575cbba074d 100644 --- a/ee/clickhouse/views/test/test_experiment_saved_metrics.py +++ b/ee/clickhouse/views/test/test_experiment_saved_metrics.py @@ -34,7 +34,9 @@ def test_validation_of_query_metric(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", @@ -47,40 +49,46 @@ def test_validation_of_query_metric(self): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "ExperimentTrendsQuery"}, + "query": {"kind": "TrendsQuery"}, }, format="json", ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.json()["detail"], "Metric query kind must be 'TrendsQuery' or 'FunnelsQuery'") + self.assertEqual( + response.json()["detail"], "Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'" + ) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery"}, + "query": {"kind": "ExperimentTrendsQuery"}, }, format="json", ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertTrue("'loc': ('series',), 'msg': 'Field required'" in response.json()["detail"]) + self.assertTrue("'loc': ('count_query',), 'msg': 'Field required'" in response.json()["detail"]) response = self.client.post( f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, format="json", ) @@ -93,7 +101,13 @@ def test_create_update_experiment_saved_metrics(self) -> None: data={ "name": "Test Experiment saved metric", "description": "Test description", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": { + "kind": "TrendsQuery", + "series": [{"kind": "EventsNode", "event": "$pageview"}], + }, + }, }, format="json", ) @@ -104,7 +118,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(response.json()["description"], "Test description") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) self.assertEqual(response.json()["created_by"]["id"], self.user.pk) @@ -142,7 +159,11 @@ def test_create_update_experiment_saved_metrics(self) -> None: saved_metric = Experiment.objects.get(pk=exp_id).saved_metrics.first() self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( - saved_metric.query, {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]} + saved_metric.query, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, ) # Now try updating saved metric @@ -151,7 +172,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: { "name": "Test Experiment saved metric 2", "description": "Test description 2", - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, }, ) @@ -159,7 +183,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(response.json()["name"], "Test Experiment saved metric 2") self.assertEqual( response.json()["query"], - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, ) # make sure experiment in question was updated as well @@ -168,7 +195,10 @@ def test_create_update_experiment_saved_metrics(self) -> None: self.assertEqual(saved_metric.id, saved_metric_id) self.assertEqual( saved_metric.query, - {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageleave"}]}, + }, ) self.assertEqual(saved_metric.name, "Test Experiment saved metric 2") self.assertEqual(saved_metric.description, "Test description 2") @@ -186,7 +216,10 @@ def test_invalid_create(self): f"/api/projects/{self.team.id}/experiment_saved_metrics/", data={ "name": None, # invalid - "query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + "query": { + "kind": "ExperimentTrendsQuery", + "count_query": {"kind": "TrendsQuery", "series": [{"kind": "EventsNode", "event": "$pageview"}]}, + }, }, format="json", ) diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--light.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--light.png index 7aa579b89ee04..4e4b6398651eb 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--light.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png index f4ee11e4c1d59..b86d6375ae469 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--trends-line--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--trends-line--light.png b/frontend/__snapshots__/scenes-app-insights--trends-line--light.png index 264c80096163f..4ee6c11e37374 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--trends-line--light.png and b/frontend/__snapshots__/scenes-app-insights--trends-line--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png index 58107bd37970b..28cba9cdadc1c 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page--light.png index 14d5a816fd326..c4557cebeb358 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-destinations-page--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-destination--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-destination--light.png index 1572b985e5539..292d822558b7e 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-destination--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-destination--light.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--dark.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--dark.png index d39232bc20b73..fc0e89a1e7681 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--dark.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png index a128cf65fde2e..6b7c15307555d 100644 Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png differ diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx index 21a52abe26936..9151d3ff03207 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx @@ -32,6 +32,7 @@ import { ActivityScope, AvailableFeature } from '~/types' import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader' import { SidePanelActivityMetalytics } from './SidePanelActivityMetalytics' +import { SidePanelActivitySubscriptions } from './SidePanelActivitySubscriptions' const SCROLL_TRIGGER_OFFSET = 100 @@ -152,6 +153,14 @@ export const SidePanelActivity = (): JSX.Element => { }, ] : []), + ...(featureFlags[FEATURE_FLAGS.CDP_ACTIVITY_LOG_NOTIFICATIONS] + ? [ + { + key: SidePanelActivityTab.Subscriptions, + label: 'Subscriptions', + }, + ] + : []), ]} /> @@ -280,6 +289,8 @@ export const SidePanelActivity = (): JSX.Element => { ) : activeTab === SidePanelActivityTab.Metalytics ? ( + ) : activeTab === SidePanelActivityTab.Subscriptions ? ( + ) : null} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx new file mode 100644 index 0000000000000..d450e2641e1f5 --- /dev/null +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx @@ -0,0 +1,22 @@ +import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions' + +export function SidePanelActivitySubscriptions(): JSX.Element { + return ( +
+

Get notified of your team's activity

+ + +
+ ) +} diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx index 079433affb717..83ab2604734bc 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx @@ -40,6 +40,7 @@ export enum SidePanelActivityTab { Unread = 'unread', All = 'all', Metalytics = 'metalytics', + Subscriptions = 'subscriptions', } export const sidePanelActivityLogic = kea([ @@ -65,6 +66,7 @@ export const sidePanelActivityLogic = kea([ reducers({ activeTab: [ SidePanelActivityTab.Unread as SidePanelActivityTab, + { persist: true }, { setActiveTab: (_, { tab }) => tab, }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f1497f937c334..2107ff837d0f3 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,6 +60,7 @@ import { GroupListParams, HogFunctionIconResponse, HogFunctionStatus, + HogFunctionSubTemplateIdType, HogFunctionTemplateType, HogFunctionType, HogFunctionTypeType, @@ -1824,13 +1825,15 @@ const api = { ): Promise { return await new ApiRequest().hogFunction(id).withAction('metrics/totals').withQueryString(params).get() }, - async listTemplates( - type?: HogFunctionTypeType | HogFunctionTypeType[] - ): Promise> { - return new ApiRequest() - .hogFunctionTemplates() - .withQueryString(Array.isArray(type) ? { types: type.join(',') } : { type: type ?? 'destination' }) - .get() + async listTemplates(params: { + types: HogFunctionTypeType[] + sub_template_id?: HogFunctionSubTemplateIdType + }): Promise> { + const finalParams = { + ...params, + types: params.types.join(','), + } + return new ApiRequest().hogFunctionTemplates().withQueryString(finalParams).get() }, async getTemplate(id: HogFunctionTemplateType['id']): Promise { return await new ApiRequest().hogFunctionTemplate(id).get() diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx index 70b61d4841019..b77c601a43154 100644 --- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx +++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx @@ -775,7 +775,7 @@ export class HedgehogActor { ref?.(r) } }} - className="HedgehogBuddy cursor-pointer m-0" + className="m-0 cursor-pointer HedgehogBuddy" data-content={preloadContent} onTouchStart={this.static ? undefined : () => onTouchOrMouseStart()} onMouseDown={this.static ? undefined : () => onTouchOrMouseStart()} @@ -835,10 +835,15 @@ export class HedgehogActor { {this.accessories().map((accessory, index) => (
+
-
+
Good bye! @@ -1032,7 +1039,7 @@ export function MyHedgehogBuddy({ hedgehogConfig={hedgehogConfig} tooltip={ hedgehogConfig.party_mode_enabled ? ( -
+
) : undefined @@ -1076,7 +1083,7 @@ export function MemberHedgehogBuddy({ member }: { member: OrganizationMemberType
-
+
+
} diff --git a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx index 07060bcd8698d..14e4f2e77c361 100644 --- a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx +++ b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx @@ -1,4 +1,4 @@ -import { IconInfo, IconOpenSidebar } from '@posthog/icons' +import { IconInfo, IconOpenSidebar, IconUnlock } from '@posthog/icons' import { LemonButton, Link, Tooltip } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' @@ -7,6 +7,7 @@ import { useEffect } from 'react' import { billingLogic } from 'scenes/billing/billingLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { getProductIcon } from 'scenes/products/Products' +import { userLogic } from 'scenes/userLogic' import { AvailableFeature, BillingFeatureType, BillingProductV2AddonType, BillingProductV2Type } from '~/types' @@ -41,10 +42,14 @@ export function PayGateMini({ isGrandfathered, docsLink, }: PayGateMiniProps): JSX.Element | null { - const { productWithFeature, featureInfo, gateVariant } = useValues(payGateMiniLogic({ feature, currentUsage })) + const { productWithFeature, featureInfo, gateVariant, bypassPaywall } = useValues( + payGateMiniLogic({ feature, currentUsage }) + ) + const { setBypassPaywall } = useActions(payGateMiniLogic({ feature, currentUsage })) const { preflight, isCloudOrDev } = useValues(preflightLogic) const { billingLoading } = useValues(billingLogic) const { hideUpgradeModal } = useActions(upgradeModalLogic) + const { user } = useValues(userLogic) useEffect(() => { if (gateVariant) { @@ -73,7 +78,7 @@ export function PayGateMini({ return null // Don't show anything if paid features are explicitly disabled } - if (gateVariant && productWithFeature && featureInfo && !overrideShouldShowGate) { + if (gateVariant && productWithFeature && featureInfo && !overrideShouldShowGate && !bypassPaywall) { return (
)} + + {user?.is_impersonated && ( + } + tooltip="Bypass this paywall - (UI only)" + onClick={() => setBypassPaywall(true)} + > + Bypass paywall + + )}
) @@ -142,7 +158,7 @@ function PayGateContent({ 'PayGateMini rounded flex flex-col items-center p-4 text-center' )} > -
+
{getProductIcon(productWithFeature.name, featureInfo.icon_key)}

{featureInfo.name}

@@ -184,7 +200,7 @@ const renderUsageLimitMessage = ( .

-

+

Your current plan limit:{' '} {featureAvailableOnOrg.limit} {featureAvailableOnOrg.unit} @@ -198,7 +214,7 @@ const renderUsageLimitMessage = ( {featureInfoOnNextPlan?.limit} projects.

)} -

+

Need unlimited projects? Check out the{' '} Teams addon @@ -244,9 +260,9 @@ const renderGateVariantMessage = ( const GrandfatheredMessage = (): JSX.Element => { return ( -

- -

+

+ +

Your plan does not include this feature, but previously set settings may remain. Please upgrade your plan to regain access.

diff --git a/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx b/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx index ba15f163fbbf6..6c86949164815 100644 --- a/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx +++ b/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx @@ -1,4 +1,4 @@ -import { actions, connect, kea, key, path, props, selectors } from 'kea' +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' import { billingLogic } from 'scenes/billing/billingLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { userLogic } from 'scenes/userLogic' @@ -29,8 +29,17 @@ export const payGateMiniLogic = kea([ ], actions: [], })), + reducers({ + bypassPaywall: [ + false, + { + setBypassPaywall: (_, { bypassPaywall }) => bypassPaywall, + }, + ], + }), actions({ setGateVariant: (gateVariant: GateVariantType) => ({ gateVariant }), + setBypassPaywall: (bypassPaywall: boolean) => ({ bypassPaywall }), }), selectors(({ values, props }) => ({ productWithFeature: [ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 148862540ea8a..a521039c23313 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -234,6 +234,7 @@ export const FEATURE_FLAGS = { EXPERIMENT_STATS_V2: 'experiment-stats-v2', // owner: @danielbachhuber #team-experiments WEB_ANALYTICS_PERIOD_COMPARISON: 'web-analytics-period-comparison', // owner: @rafaeelaudibert #team-web-analytics WEB_ANALYTICS_CONVERSION_GOAL_FILTERS: 'web-analytics-conversion-goal-filters', // owner: @rafaeelaudibert #team-web-analytics + CDP_ACTIVITY_LOG_NOTIFICATIONS: 'cdp-activity-log-notifications', // owner: #team-cdp COOKIELESS_SERVER_HASH_MODE_SETTING: 'cookieless-server-hash-mode-setting', // owner: @robbie-c #team-web-analytics } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/mocks/fixtures/_hogFunctionTemplates.json b/frontend/src/mocks/fixtures/_hogFunctionTemplates.json index 547c01d09ddc0..5a46141258384 100644 --- a/frontend/src/mocks/fixtures/_hogFunctionTemplates.json +++ b/frontend/src/mocks/fixtures/_hogFunctionTemplates.json @@ -6,7 +6,7 @@ { "sub_templates": [ { - "id": "early_access_feature_enrollment", + "id": "early-access-feature-enrollment", "name": "Post to Slack on feature enrollment", "description": "Posts a message to Slack when a user enrolls or un-enrolls in an early access feature", "filters": { "events": [{ "id": "$feature_enrollment_update", "type": "events" }] }, @@ -35,7 +35,7 @@ } }, { - "id": "survey_response", + "id": "survey-response", "name": "Post to Slack on survey response", "description": "Posts a message to Slack when a user responds to a survey", "filters": { @@ -171,7 +171,7 @@ { "sub_templates": [ { - "id": "early_access_feature_enrollment", + "id": "early-access-feature-enrollment", "name": "HTTP Webhook on feature enrollment", "description": null, "filters": { "events": [{ "id": "$feature_enrollment_update", "type": "events" }] }, @@ -179,7 +179,7 @@ "inputs": null }, { - "id": "survey_response", + "id": "survey-response", "name": "HTTP Webhook on survey response", "description": null, "filters": { diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index f6a646f64f7c4..f7dc7944da802 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -28,6 +28,8 @@ export const appScenes: Record any> = { [Scene.Group]: () => import('./groups/Group'), [Scene.Action]: () => import('./actions/Action'), [Scene.Experiments]: () => import('./experiments/Experiments'), + [Scene.ExperimentsSavedMetrics]: () => import('./experiments/SavedMetrics/SavedMetrics'), + [Scene.ExperimentsSavedMetric]: () => import('./experiments/SavedMetrics/SavedMetric'), [Scene.Experiment]: () => import('./experiments/Experiment'), [Scene.FeatureFlags]: () => import('./feature-flags/FeatureFlags'), [Scene.FeatureManagement]: () => import('./feature-flags/FeatureManagement'), diff --git a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx index fffe0a16abfdc..cf30eff83213b 100644 --- a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx +++ b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx @@ -276,7 +276,7 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element {
)}
-
+
{isEditingFeature || isNewEarlyAccessFeature ? ( @@ -333,14 +333,14 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element { )} {!isEditingFeature && !isNewEarlyAccessFeature && 'id' in earlyAccessFeature && ( <> -
+

Users

diff --git a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx index 6efdd992f5988..ef0bff8ac948a 100644 --- a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx @@ -10,10 +10,10 @@ import { BaseMathType, ChartDisplayType, InsightType, PropertyFilterType, Proper import { experimentLogic } from '../experimentLogic' export function CumulativeExposuresChart(): JSX.Element { - const { experiment, metricResults, getMetricType } = useValues(experimentLogic) + const { experiment, metricResults, _getMetricType } = useValues(experimentLogic) const metricIdx = 0 - const metricType = getMetricType(metricIdx) + const metricType = _getMetricType(experiment.metrics[metricIdx]) const result = metricResults?.[metricIdx] const variants = experiment.parameters?.feature_flag_variants?.map((variant) => variant.key) || [] if (experiment.holdout) { diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx index b22eb57b35d4b..5d0b7e1389a52 100644 --- a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx @@ -17,7 +17,7 @@ export function DataCollection(): JSX.Element { const { experimentId, experiment, - getMetricType, + _getMetricType, funnelResultsPersonsTotal, actualRunningTime, minimumDetectableEffect, @@ -25,7 +25,7 @@ export function DataCollection(): JSX.Element { const { openExperimentCollectionGoalModal } = useActions(experimentLogic) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1 const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100 @@ -80,7 +80,7 @@ export function DataCollection(): JSX.Element { {metricType === InsightType.TRENDS && ( @@ -172,7 +172,8 @@ export function DataCollection(): JSX.Element { export function DataCollectionGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element { const { isExperimentCollectionGoalModalOpen, - getMetricType, + experiment, + _getMetricType, trendMetricInsightLoading, funnelMetricInsightLoading, } = useValues(experimentLogic({ experimentId })) @@ -181,7 +182,9 @@ export function DataCollectionGoalModal({ experimentId }: { experimentId: Experi ) const isInsightLoading = - getMetricType(0) === InsightType.TRENDS ? trendMetricInsightLoading : funnelMetricInsightLoading + _getMetricType(experiment.metrics[0]) === InsightType.TRENDS + ? trendMetricInsightLoading + : funnelMetricInsightLoading return ( - {getMetricType(0) === InsightType.TRENDS ? ( + {_getMetricType(experiment.metrics[0]) === InsightType.TRENDS ? ( ) : ( diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx index b8e2ce7a0eab8..96d3dd3ff86e4 100644 --- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx @@ -7,6 +7,8 @@ import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperi import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails' import { experimentLogic } from '../experimentLogic' import { MetricModal } from '../Metrics/MetricModal' +import { MetricSourceModal } from '../Metrics/MetricSourceModal' +import { SavedMetricModal } from '../Metrics/SavedMetricModal' import { MetricsView } from '../MetricsView/MetricsView' import { ExperimentLoadingAnimation, @@ -141,9 +143,15 @@ export function ExperimentView(): JSX.Element { /> )} + + + + + + diff --git a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx index 0438835528246..e9bd49756b1de 100644 --- a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx @@ -240,10 +240,10 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id' } export function Goal(): JSX.Element { - const { experiment, experimentId, getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } = + const { experiment, experimentId, _getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } = useValues(experimentLogic) const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) // :FLAG: CLEAN UP AFTER MIGRATION const isDataWarehouseMetric = diff --git a/frontend/src/scenes/experiments/ExperimentView/Results.tsx b/frontend/src/scenes/experiments/ExperimentView/Results.tsx index 61574de1b3966..b0b291554ef90 100644 --- a/frontend/src/scenes/experiments/ExperimentView/Results.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/Results.tsx @@ -5,7 +5,7 @@ import { ResultsHeader, ResultsQuery } from './components' import { SummaryTable } from './SummaryTable' export function Results(): JSX.Element { - const { metricResults } = useValues(experimentLogic) + const { experiment, metricResults } = useValues(experimentLogic) const result = metricResults?.[0] if (!result) { return <> @@ -14,7 +14,7 @@ export function Results(): JSX.Element { return (

- +
) diff --git a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx index e8d5baf4eed0e..3b1db8847de0c 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx @@ -22,7 +22,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime metricResults, secondaryMetricResultsLoading, experiment, - getSecondaryMetricType, + _getMetricType, secondaryMetricResults, tabularSecondaryMetricResults, countDataForVariant, @@ -69,7 +69,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime metrics?.forEach((metric, idx) => { const targetResults = secondaryMetricResults?.[idx] const winningVariant = getHighestProbabilityVariant(targetResults || null) - const metricType = getSecondaryMetricType(idx) + const metricType = _getMetricType(metric) const Header = (): JSX.Element => (
diff --git a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx index 14ee1d3cbcf7a..536aaa75aa615 100644 --- a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx @@ -8,6 +8,7 @@ import { humanFriendlyNumber } from 'lib/utils' import posthog from 'posthog-js' import { urls } from 'scenes/urls' +import { ExperimentFunnelsQuery, ExperimentTrendsQuery } from '~/queries/schema' import { FilterLogicalOperator, InsightType, @@ -23,9 +24,11 @@ import { experimentLogic } from '../experimentLogic' import { VariantTag } from './components' export function SummaryTable({ + metric, metricIndex = 0, isSecondary = false, }: { + metric: ExperimentTrendsQuery | ExperimentFunnelsQuery metricIndex?: number isSecondary?: boolean }): JSX.Element { @@ -35,8 +38,7 @@ export function SummaryTable({ metricResults, secondaryMetricResults, tabularExperimentResults, - getMetricType, - getSecondaryMetricType, + _getMetricType, exposureCountDataForVariant, conversionRateForVariant, experimentMathAggregationForTrends, @@ -44,7 +46,7 @@ export function SummaryTable({ getHighestProbabilityVariant, credibleIntervalForVariant, } = useValues(experimentLogic) - const metricType = isSecondary ? getSecondaryMetricType(metricIndex) : getMetricType(metricIndex) + const metricType = _getMetricType(metric) const result = isSecondary ? secondaryMetricResults?.[metricIndex] : metricResults?.[metricIndex] if (!result) { return <> diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx index afa0e77054f99..cd252785b116c 100644 --- a/frontend/src/scenes/experiments/ExperimentView/components.tsx +++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx @@ -69,30 +69,32 @@ export function VariantTag({ if (experiment.holdout && variantKey === `holdout-${experiment.holdout_id}`) { return ( - +
- {experiment.holdout.name} + + {experiment.holdout.name} + ) } return ( - +
@@ -232,10 +234,11 @@ export function ExploreButton({ return ( } to={urls.insightNew(undefined, undefined, query)} + targetBlank > Explore as Insight @@ -659,7 +662,7 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i export function ActionBanner(): JSX.Element { const { experiment, - getMetricType, + _getMetricType, metricResults, experimentLoading, metricResultsLoading, @@ -678,7 +681,7 @@ export function ActionBanner(): JSX.Element { const { aggregationLabel } = useValues(groupsModel) - const metricType = getMetricType(0) + const metricType = _getMetricType(experiment.metrics[0]) const aggregationTargetName = experiment.filters.aggregation_group_type_index != null diff --git a/frontend/src/scenes/experiments/Experiments.tsx b/frontend/src/scenes/experiments/Experiments.tsx index 26c84171c6a8c..e31d1958000cd 100644 --- a/frontend/src/scenes/experiments/Experiments.tsx +++ b/frontend/src/scenes/experiments/Experiments.tsx @@ -5,6 +5,7 @@ import { ExperimentsHog } from 'lib/components/hedgehogs' import { MemberSelect } from 'lib/components/MemberSelect' import { PageHeader } from 'lib/components/PageHeader' import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' +import { FEATURE_FLAGS } from 'lib/constants' import { dayjs } from 'lib/dayjs' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { More } from 'lib/lemon-ui/LemonButton/More' @@ -23,6 +24,7 @@ import { Experiment, ExperimentsTabs, ProductKey, ProgressStatus } from '~/types import { experimentsLogic, getExperimentStatus } from './experimentsLogic' import { StatusTag } from './ExperimentView/components' import { Holdouts } from './Holdouts' +import { SavedMetrics } from './SavedMetrics/SavedMetrics' export const scene: SceneExport = { component: Experiments, @@ -30,8 +32,16 @@ export const scene: SceneExport = { } export function Experiments(): JSX.Element { - const { filteredExperiments, experimentsLoading, tab, searchTerm, shouldShowEmptyState, searchStatus, userFilter } = - useValues(experimentsLogic) + const { + filteredExperiments, + experimentsLoading, + tab, + searchTerm, + shouldShowEmptyState, + searchStatus, + userFilter, + featureFlags, + } = useValues(experimentsLogic) const { setExperimentsTab, deleteExperiment, archiveExperiment, setSearchStatus, setSearchTerm, setUserFilter } = useActions(experimentsLogic) @@ -211,11 +221,16 @@ export function Experiments(): JSX.Element { { key: ExperimentsTabs.Yours, label: 'Your experiments' }, { key: ExperimentsTabs.Archived, label: 'Archived experiments' }, { key: ExperimentsTabs.Holdouts, label: 'Holdout groups' }, + ...(featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] + ? [{ key: ExperimentsTabs.SavedMetrics, label: 'Shared metrics' }] + : []), ]} /> {tab === ExperimentsTabs.Holdouts ? ( + ) : tab === ExperimentsTabs.SavedMetrics && featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? ( + ) : ( <> {tab === ExperimentsTabs.Archived ? ( diff --git a/frontend/src/scenes/experiments/Metrics/MetricModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx index dde6c2e1b6d00..889d68ae4d31f 100644 --- a/frontend/src/scenes/experiments/Metrics/MetricModal.tsx +++ b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx @@ -1,4 +1,4 @@ -import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui' +import { LemonButton, LemonDialog, LemonModal, LemonSelect } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { ExperimentFunnelsQuery } from '~/queries/schema' @@ -18,8 +18,7 @@ export function MetricModal({ const { experiment, experimentLoading, - getMetricType, - getSecondaryMetricType, + _getMetricType, isPrimaryMetricModalOpen, isSecondaryMetricModalOpen, editingPrimaryMetricIndex, @@ -36,9 +35,9 @@ export function MetricModal({ return <> } - const metricType = isSecondary ? getSecondaryMetricType(metricIdx) : getMetricType(metricIdx) const metrics = experiment[metricsField] const metric = metrics[metricIdx] + const metricType = _getMetricType(metric) const funnelStepsLength = (metric as ExperimentFunnelsQuery)?.funnels_query?.series?.length || 0 return ( @@ -53,11 +52,27 @@ export function MetricModal({ type="secondary" status="danger" onClick={() => { - const newMetrics = metrics.filter((_, idx) => idx !== metricIdx) - setExperiment({ - [metricsField]: newMetrics, + LemonDialog.open({ + title: 'Delete this metric?', + content:
This action cannot be undone.
, + primaryButton: { + children: 'Delete', + type: 'primary', + onClick: () => { + const newMetrics = metrics.filter((_, idx) => idx !== metricIdx) + setExperiment({ + [metricsField]: newMetrics, + }) + updateExperimentGoal() + }, + size: 'small', + }, + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, }) - updateExperimentGoal() }} > Delete diff --git a/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx new file mode 100644 index 0000000000000..bd2134359d9f8 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx @@ -0,0 +1,73 @@ +import { LemonModal } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' + +import { Experiment } from '~/types' + +import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic' + +export function MetricSourceModal({ + experimentId, + isSecondary, +}: { + experimentId: Experiment['id'] + isSecondary?: boolean +}): JSX.Element { + const { experiment, isPrimaryMetricSourceModalOpen, isSecondaryMetricSourceModalOpen } = useValues( + experimentLogic({ experimentId }) + ) + const { + setExperiment, + closePrimaryMetricSourceModal, + closeSecondaryMetricSourceModal, + openPrimaryMetricModal, + openPrimarySavedMetricModal, + openSecondaryMetricModal, + openSecondarySavedMetricModal, + } = useActions(experimentLogic({ experimentId })) + + const metricsField = isSecondary ? 'metrics_secondary' : 'metrics' + const isOpen = isSecondary ? isSecondaryMetricSourceModalOpen : isPrimaryMetricSourceModalOpen + const closeCurrentModal = isSecondary ? closeSecondaryMetricSourceModal : closePrimaryMetricSourceModal + const openMetricModal = isSecondary ? openSecondaryMetricModal : openPrimaryMetricModal + const openSavedMetricModal = isSecondary ? openSecondarySavedMetricModal : openPrimarySavedMetricModal + + return ( + +
+
{ + closeCurrentModal() + + const newMetrics = [...experiment[metricsField], getDefaultFunnelsMetric()] + setExperiment({ + [metricsField]: newMetrics, + }) + openMetricModal(newMetrics.length - 1) + }} + > +
+ Custom +
+
+ Create a new metric specific to this experiment. +
+
+
{ + closeCurrentModal() + openSavedMetricModal(null) + }} + > +
+ Shared +
+
+ Use a pre-configured metric that can be reused across experiments. +
+
+
+
+ ) +} diff --git a/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx new file mode 100644 index 0000000000000..3f6cfbc01f2c9 --- /dev/null +++ b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx @@ -0,0 +1,143 @@ +import { LemonButton, LemonModal, LemonSelect, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { IconOpenInNew } from 'lib/lemon-ui/icons' +import { useEffect, useState } from 'react' +import { urls } from 'scenes/urls' + +import { Experiment } from '~/types' + +import { experimentLogic } from '../experimentLogic' +import { MetricDisplayFunnels, MetricDisplayTrends } from '../ExperimentView/Goal' +import { SavedMetric } from '../SavedMetrics/savedMetricLogic' + +export function SavedMetricModal({ + experimentId, + isSecondary, +}: { + experimentId: Experiment['id'] + isSecondary?: boolean +}): JSX.Element { + const { savedMetrics, isPrimarySavedMetricModalOpen, isSecondarySavedMetricModalOpen, editingSavedMetricId } = + useValues(experimentLogic({ experimentId })) + const { + closePrimarySavedMetricModal, + closeSecondarySavedMetricModal, + addSavedMetricToExperiment, + removeSavedMetricFromExperiment, + } = useActions(experimentLogic({ experimentId })) + + const [selectedMetricId, setSelectedMetricId] = useState(null) + const [mode, setMode] = useState<'create' | 'edit'>('create') + + useEffect(() => { + if (editingSavedMetricId) { + setSelectedMetricId(editingSavedMetricId) + setMode('edit') + } + }, [editingSavedMetricId]) + + if (!savedMetrics) { + return <> + } + + const isOpen = isSecondary ? isSecondarySavedMetricModalOpen : isPrimarySavedMetricModalOpen + const closeModal = isSecondary ? closeSecondarySavedMetricModal : closePrimarySavedMetricModal + + return ( + +
+ {editingSavedMetricId && ( + { + removeSavedMetricFromExperiment(editingSavedMetricId) + }} + type="secondary" + > + Remove from experiment + + )} +
+
+ + Cancel + + {/* Changing the existing metric is a pain because saved metrics are stored separately */} + {/* Only allow deletion for now */} + {mode === 'create' && ( + { + if (selectedMetricId) { + addSavedMetricToExperiment(selectedMetricId, { + type: isSecondary ? 'secondary' : 'primary', + }) + } + }} + type="primary" + disabledReason={!selectedMetricId ? 'Please select a metric' : undefined} + > + Add metric + + )} +
+
+ } + > + {mode === 'create' && ( +
+ ({ + label: metric.name, + value: metric.id, + }))} + placeholder="Select a saved metric" + loading={false} + value={selectedMetricId} + onSelect={(value) => { + setSelectedMetricId(value) + }} + /> +
+ )} + + {selectedMetricId && ( +
+ {(() => { + const metric = savedMetrics.find((m: SavedMetric) => m.id === selectedMetricId) + if (!metric) { + return <> + } + + return ( +
+
+

{metric.name}

+ + + +
+ {metric.description &&

{metric.description}

} + {metric.query.kind === 'ExperimentTrendsQuery' && ( + + )} + {metric.query.kind === 'ExperimentFunnelsQuery' && ( + + )} +
+ ) + })()} +
+ )} + + ) +} diff --git a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx index 2f7455288b68f..820b7b38acfd3 100644 --- a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx +++ b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx @@ -1,6 +1,7 @@ import { IconActivity, IconGraph, IconMinus, IconPencil, IconTrending } from '@posthog/icons' import { LemonBanner, LemonButton, LemonModal, LemonTag, LemonTagType, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { LemonProgress } from 'lib/lemon-ui/LemonProgress' import { humanFriendlyNumber } from 'lib/utils' import { useEffect, useRef, useState } from 'react' @@ -68,7 +69,12 @@ export function DeltaChart({ } = useValues(experimentLogic) const { experiment } = useValues(experimentLogic) - const { openPrimaryMetricModal, openSecondaryMetricModal } = useActions(experimentLogic) + const { + openPrimaryMetricModal, + openSecondaryMetricModal, + openPrimarySavedMetricModal, + openSecondarySavedMetricModal, + } = useActions(experimentLogic) const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null) const [emptyStateTooltipVisible, setEmptyStateTooltipVisible] = useState(true) const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }) @@ -100,8 +106,8 @@ export function DeltaChart({ TICK_TEXT_COLOR: 'var(--text-secondary-3000)', BOUNDARY_LINES: 'var(--border-3000)', ZERO_LINE: 'var(--border-bold)', - BAR_NEGATIVE: isDarkModeOn ? 'rgb(206 66 54)' : '#F44435', - BAR_BEST: isDarkModeOn ? 'rgb(49 145 51)' : '#4DAF4F', + BAR_NEGATIVE: isDarkModeOn ? '#c32f45' : '#f84257', + BAR_POSITIVE: isDarkModeOn ? '#12a461' : '#36cd6f', BAR_DEFAULT: isDarkModeOn ? 'rgb(121 121 121)' : 'rgb(217 217 217)', BAR_CONTROL: isDarkModeOn ? 'rgba(217, 217, 217, 0.2)' : 'rgba(217, 217, 217, 0.4)', BAR_MIDDLE_POINT: 'black', @@ -186,21 +192,76 @@ export function DeltaChart({ type="secondary" size="xsmall" icon={} - onClick={() => + onClick={() => { + if (metric.isSavedMetric) { + if (isSecondary) { + openSecondarySavedMetricModal(metric.savedMetricId) + } else { + openPrimarySavedMetricModal(metric.savedMetricId) + } + return + } isSecondary ? openSecondaryMetricModal(metricIndex) : openPrimaryMetricModal(metricIndex) - } + }} />
- - {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'} - +
+ + {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'} + + {metric.isSavedMetric && ( + + Shared + + )} +
- + {/* Detailed results panel */} +
+ {isFirstMetric && ( + + )} + {isFirstMetric &&
} + {result && ( +
+ +
+ } + onClick={() => setIsModalOpen(true)} + > + Detailed results + +
+
+ )} +
{/* Variants panel */} {/* eslint-disable-next-line react/forbid-dom-props */}
@@ -222,9 +283,23 @@ export function DeltaChart({ display: 'flex', alignItems: 'center', paddingLeft: '10px', + position: 'relative', + minWidth: 0, + overflow: 'hidden', }} > - +
+
+ +
))}
@@ -385,7 +460,7 @@ export function DeltaChart({ H ${valueToX(0)} V ${y} `} - fill={COLORS.BAR_BEST} + fill={COLORS.BAR_POSITIVE} /> ) : ( @@ -395,7 +470,7 @@ export function DeltaChart({ y={y} width={x2 - x1} height={BAR_HEIGHT} - fill={upper <= 0 ? COLORS.BAR_NEGATIVE : COLORS.BAR_BEST} + fill={upper <= 0 ? COLORS.BAR_NEGATIVE : COLORS.BAR_POSITIVE} rx={4} ry={4} /> @@ -530,6 +605,22 @@ export function DeltaChart({ >
+
+ Win probability: + {result?.probability?.[tooltipData.variant] !== undefined ? ( + + + + {(result.probability[tooltipData.variant] * 100).toFixed(2)}% + + + ) : ( + '—' + )} +
{metricType === InsightType.TRENDS ? ( <>
@@ -675,46 +766,6 @@ export function DeltaChart({
)}
- {/* Detailed results panel */} -
- {isFirstMetric && ( - - )} - {isFirstMetric &&
} - {result && ( -
- -
- } - onClick={() => setIsModalOpen(true)} - > - Detailed results - -
-
- )} -
- +
@@ -778,7 +829,7 @@ function SignificanceHighlight({ return details ? ( -
{inner}
+
{inner}
) : (
{inner}
diff --git a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx index 2628f353942f5..9a362d9961974 100644 --- a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx +++ b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx @@ -1,9 +1,9 @@ -import { IconPlus } from '@posthog/icons' -import { LemonButton } from '@posthog/lemon-ui' +import { IconInfo, IconPlus } from '@posthog/icons' +import { LemonButton, Tooltip } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { IconAreaChart } from 'lib/lemon-ui/icons' -import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic' +import { experimentLogic } from '../experimentLogic' import { MAX_PRIMARY_METRICS, MAX_SECONDARY_METRICS } from './const' import { DeltaChart } from './DeltaChart' @@ -44,7 +44,7 @@ export function getNiceTickValues(maxAbsValue: number): number[] { function AddPrimaryMetric(): JSX.Element { const { experiment } = useValues(experimentLogic) - const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic) + const { openPrimaryMetricSourceModal } = useActions(experimentLogic) return ( { - const newMetrics = [...experiment.metrics, getDefaultFunnelsMetric()] - setExperiment({ - metrics: newMetrics, - }) - openPrimaryMetricModal(newMetrics.length - 1) + openPrimaryMetricSourceModal() }} disabledReason={ experiment.metrics.length >= MAX_PRIMARY_METRICS @@ -71,18 +67,14 @@ function AddPrimaryMetric(): JSX.Element { export function AddSecondaryMetric(): JSX.Element { const { experiment } = useValues(experimentLogic) - const { setExperiment, openSecondaryMetricModal } = useActions(experimentLogic) + const { openSecondaryMetricSourceModal } = useActions(experimentLogic) return ( } type="secondary" size="xsmall" onClick={() => { - const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()] - setExperiment({ - metrics_secondary: newMetricsSecondary, - }) - openSecondaryMetricModal(newMetricsSecondary.length - 1) + openSecondaryMetricSourceModal() }} disabledReason={ experiment.metrics_secondary.length >= MAX_SECONDARY_METRICS @@ -98,8 +90,7 @@ export function AddSecondaryMetric(): JSX.Element { export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Element { const { experiment, - getMetricType, - getSecondaryMetricType, + _getMetricType, metricResults, secondaryMetricResults, primaryMetricsResultErrors, @@ -108,19 +99,32 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele } = useValues(experimentLogic) const variants = experiment.parameters.feature_flag_variants - const metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics const results = isSecondary ? secondaryMetricResults : metricResults const errors = isSecondary ? secondaryMetricsResultErrors : primaryMetricsResultErrors + let metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics + const savedMetrics = experiment.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === (isSecondary ? 'secondary' : 'primary')) + .map((savedMetric) => ({ + ...savedMetric.query, + name: savedMetric.name, + savedMetricId: savedMetric.saved_metric, + isSavedMetric: true, + })) + + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + // Calculate the maximum absolute value across ALL metrics const maxAbsValue = Math.max( - ...metrics.flatMap((_, metricIndex) => { + ...metrics.flatMap((metric, metricIndex) => { const result = results?.[metricIndex] if (!result) { return [] } return variants.flatMap((variant) => { - const metricType = isSecondary ? getSecondaryMetricType(metricIndex) : getMetricType(metricIndex) + const metricType = _getMetricType(metric) const interval = credibleIntervalForVariant(result, variant.key, metricType) return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : [] }) @@ -136,10 +140,21 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele
-
-

+
+

{isSecondary ? 'Secondary metrics' : 'Primary metrics'}

+ {metrics.length > 0 && ( + + + + )}

@@ -178,11 +193,7 @@ export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Ele result={result} error={errors?.[metricIndex]} variants={variants} - metricType={ - isSecondary - ? getSecondaryMetricType(metricIndex) - : getMetricType(metricIndex) - } + metricType={_getMetricType(metric)} metricIndex={metricIndex} isFirstMetric={isFirstMetric} metric={metric} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx new file mode 100644 index 0000000000000..6a69c279f7286 --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx @@ -0,0 +1,203 @@ +import { LemonBanner } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants' +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 } 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 } from '~/types' + +import { + commonActionFilterProps, + FunnelAggregationSelect, + FunnelAttributionSelect, + FunnelConversionWindowFilter, +} from '../Metrics/Selectors' +import { savedMetricLogic } from './savedMetricLogic' + +export function SavedFunnelsMetricForm(): JSX.Element { + const { savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric } = useActions(savedMetricLogic) + + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + + const actionFilterProps = { + ...commonActionFilterProps, + actionsTaxonomicGroupTypes: [TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions], + } + + if (!savedMetric?.query) { + return <> + } + + const savedMetricQuery = savedMetric.query as ExperimentFunnelsQuery + + return ( + <> + ): void => { + if (!savedMetric?.query) { + return + } + + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.None + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + mathAvailability={MathAvailability.None} + buttonCopy="Add funnel step" + showSeriesIndicator={true} + seriesIndicatorType="numeric" + sortable={true} + showNestedArrow={true} + {...actionFilterProps} + /> +
+ { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + aggregation_group_type_index: value, + }, + }, + }) + }} + /> + { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + // funnelWindowInterval: funnelWindowInterval, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + funnelWindowInterval: funnelWindowInterval, + }, + }, + }, + }) + }} + onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined, + }, + }, + }, + }) + }} + /> + { + const breakdownAttributionType = + savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionType + const breakdownAttributionValue = + savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionValue + + 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('/') + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + funnelsFilter: { + ...savedMetricQuery.funnels_query.funnelsFilter, + breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType, + breakdownAttributionValue: breakdownAttributionValue + ? parseInt(breakdownAttributionValue) + : undefined, + }, + }, + }, + }) + }} + stepsLength={savedMetricQuery.funnels_query?.series?.length} + /> + { + const val = savedMetricQuery.funnels_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + funnels_query: { + ...savedMetricQuery.funnels_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a + mismatch between the preview and the actual results. + + +
+ +
+ + ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx new file mode 100644 index 0000000000000..c56d9a94c616c --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx @@ -0,0 +1,154 @@ +import { IconCheckCircle } from '@posthog/icons' +import { LemonButton, LemonDialog, LemonInput, LemonLabel, Spinner } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { SceneExport } from 'scenes/sceneTypes' + +import { themeLogic } from '~/layout/navigation-3000/themeLogic' +import { NodeKind } from '~/queries/schema' + +import { getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic' +import { SavedFunnelsMetricForm } from './SavedFunnelsMetricForm' +import { savedMetricLogic } from './savedMetricLogic' +import { SavedTrendsMetricForm } from './SavedTrendsMetricForm' + +export const scene: SceneExport = { + component: SavedMetric, + logic: savedMetricLogic, + paramsToProps: ({ params: { id } }) => ({ + savedMetricId: id === 'new' ? 'new' : parseInt(id), + }), +} + +export function SavedMetric(): JSX.Element { + const { savedMetricId, savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric, createSavedMetric, updateSavedMetric, deleteSavedMetric } = useActions(savedMetricLogic) + const { isDarkModeOn } = useValues(themeLogic) + + if (!savedMetric || !savedMetric.query) { + return ( +
+ +
+ ) + } + + return ( +
+
+
{ + setSavedMetric({ + query: getDefaultTrendsMetric(), + }) + }} + > +
+ Trend + {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery && ( + + )} +
+
+ Track a single event, action or a property value. +
+
+
{ + setSavedMetric({ + query: getDefaultFunnelsMetric(), + }) + }} + > +
+ Funnel + {savedMetric.query.kind === NodeKind.ExperimentFunnelsQuery && ( + + )} +
+
+ Analyze conversion rates between sequential steps. +
+
+
+
+
+ Name + { + setSavedMetric({ + name: newName, + }) + }} + /> +
+
+ Description (optional) + { + setSavedMetric({ + description: newDescription, + }) + }} + /> +
+ {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery ? ( + + ) : ( + + )} +
+
+ { + LemonDialog.open({ + title: 'Delete this metric?', + content:
This action cannot be undone.
, + primaryButton: { + children: 'Delete', + type: 'primary', + onClick: () => deleteSavedMetric(), + size: 'small', + }, + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, + }) + }} + > + Delete +
+ { + if (savedMetricId === 'new') { + createSavedMetric() + } else { + updateSavedMetric() + } + }} + > + Save + +
+
+ ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx new file mode 100644 index 0000000000000..c75588b77688e --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx @@ -0,0 +1,84 @@ +import { IconArrowLeft, IconPencil } from '@posthog/icons' +import { LemonBanner, LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { router } from 'kea-router' +import { createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { createdAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { SavedMetric } from './savedMetricLogic' +import { savedMetricsLogic } from './savedMetricsLogic' + +export const scene: SceneExport = { + component: SavedMetrics, + logic: savedMetricsLogic, +} + +const columns: LemonTableColumns = [ + { + key: 'name', + title: 'Name', + render: (_, savedMetric) => { + return
{savedMetric.name}
+ }, + }, + { + key: 'description', + title: 'Description', + dataIndex: 'description', + }, + createdByColumn() as LemonTableColumn, + createdAtColumn() as LemonTableColumn, + { + key: 'actions', + title: 'Actions', + render: (_, savedMetric) => { + return ( + } + onClick={() => { + router.actions.push(urls.experimentsSavedMetric(savedMetric.id)) + }} + /> + ) + }, + }, +] + +export function SavedMetrics(): JSX.Element { + const { savedMetrics, savedMetricsLoading } = useValues(savedMetricsLogic) + + return ( +
+ } + size="small" + > + Back to experiments + + + Saved metrics let you create reusable metrics that you can quickly add to any experiment. They are ideal + for tracking key metrics like conversion rates or revenue across different experiments without having to + set them up each time. + +
+ + New saved metric + +
+ You haven't created any saved metrics yet.
} + /> +
+ ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx new file mode 100644 index 0000000000000..7b8068f945bbd --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx @@ -0,0 +1,275 @@ +import { IconCheckCircle } from '@posthog/icons' +import { LemonBanner, LemonTabs, LemonTag } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch' +import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants' +import { dayjs } from 'lib/dayjs' +import { useState } from 'react' +import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' +import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' +import { teamLogic } from 'scenes/teamLogic' + +import { actionsAndEventsToSeries } 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 { BaseMathType, ChartDisplayType, FilterType, PropertyMathType } from '~/types' + +import { commonActionFilterProps } from '../Metrics/Selectors' +import { savedMetricLogic } from './savedMetricLogic' + +export function SavedTrendsMetricForm(): JSX.Element { + const { savedMetric } = useValues(savedMetricLogic) + const { setSavedMetric } = useActions(savedMetricLogic) + const { currentTeam } = useValues(teamLogic) + const hasFilters = (currentTeam?.test_account_filters || []).length > 0 + const [activeTab, setActiveTab] = useState('main') + + if (!savedMetric?.query) { + return <> + } + + const savedMetricQuery = savedMetric.query as ExperimentTrendsQuery + + return ( + <> + setActiveTab(newKey)} + tabs={[ + { + key: 'main', + label: 'Main metric', + content: ( + <> + ): void => { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + count_query: { + ...savedMetricQuery.count_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + onlyPropertyMathDefinitions={[PropertyMathType.Average]} + {...commonActionFilterProps} + /> +
+ { + const val = savedMetricQuery.count_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + count_query: { + ...savedMetricQuery.count_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. + This can cause a mismatch between the preview and the actual results. + +
+ +
+ + ), + }, + { + key: 'exposure', + label: 'Exposure', + content: ( + <> +
+
{ + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: undefined, + }, + }) + }} + > +
+ Default + {!savedMetricQuery.exposure_query && ( + + )} +
+
+ Uses the number of unique users who trigger the{' '} + $feature_flag_called event as your exposure count. This + is the recommended setting for most experiments, as it accurately tracks + variant exposure. +
+
+
{ + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + kind: NodeKind.TrendsQuery, + series: [ + { + kind: NodeKind.EventsNode, + name: '$feature_flag_called', + event: '$feature_flag_called', + math: BaseMathType.UniqueUsers, + }, + ], + 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, + }, + }, + }) + }} + > +
+ Custom + {savedMetricQuery.exposure_query && ( + + )} +
+
+ Define your own exposure metric for specific use cases, such as counting by + sessions instead of users. This gives you full control but requires careful + configuration. +
+
+
+ {savedMetricQuery.exposure_query && ( + <> + ): void => { + const series = actionsAndEventsToSeries( + { actions, events, data_warehouse } as any, + true, + MathAvailability.All + ) + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + ...savedMetricQuery.exposure_query, + series, + }, + }, + }) + }} + typeKey="experiment-metric" + buttonCopy="Add graph series" + showSeriesIndicator={true} + entitiesLimit={1} + showNumericalPropsOnly={true} + {...commonActionFilterProps} + /> +
+ { + const val = savedMetricQuery.exposure_query?.filterTestAccounts + return hasFilters ? !!val : false + })()} + onChange={(checked: boolean) => { + setSavedMetric({ + query: { + ...savedMetricQuery, + exposure_query: { + ...savedMetricQuery.exposure_query, + filterTestAccounts: checked, + }, + }, + }) + }} + fullWidth + /> +
+ + Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days + of data. This can cause a mismatch between the preview and the actual + results. + +
+ +
+ + )} + + ), + }, + ]} + /> + + ) +} diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx new file mode 100644 index 0000000000000..38648d7e7ca89 --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx @@ -0,0 +1,127 @@ +import { lemonToast } from '@posthog/lemon-ui' +import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import { router, urlToAction } from 'kea-router' +import api from 'lib/api' + +import { UserBasicType } from '~/types' + +import { getDefaultTrendsMetric } from '../experimentLogic' +import type { savedMetricLogicType } from './savedMetricLogicType' +import { savedMetricsLogic } from './savedMetricsLogic' + +export interface SavedMetricLogicProps { + savedMetricId?: string | number +} + +export interface SavedMetric { + id: number + name: string + description?: string + query: Record + created_by: UserBasicType | null + created_at: string | null + updated_at: string | null +} + +export const NEW_SAVED_METRIC: Partial = { + name: '', + description: '', + query: getDefaultTrendsMetric(), +} + +export const savedMetricLogic = kea([ + props({} as SavedMetricLogicProps), + path((key) => ['scenes', 'experiments', 'savedMetricLogic', key]), + key((props) => props.savedMetricId || 'new'), + connect(() => ({ + actions: [savedMetricsLogic, ['loadSavedMetrics']], + })), + actions({ + setSavedMetric: (metric: Partial) => ({ metric }), + createSavedMetric: true, + updateSavedMetric: true, + deleteSavedMetric: true, + }), + + loaders(({ props }) => ({ + savedMetric: { + loadSavedMetric: async () => { + if (props.savedMetricId && props.savedMetricId !== 'new') { + const response = await api.get( + `api/projects/@current/experiment_saved_metrics/${props.savedMetricId}` + ) + return response as SavedMetric + } + return { ...NEW_SAVED_METRIC } + }, + }, + })), + + listeners(({ actions, values }) => ({ + createSavedMetric: async () => { + const response = await api.create(`api/projects/@current/experiment_saved_metrics/`, values.savedMetric) + if (response.id) { + lemonToast.success('Saved metric created successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } + }, + updateSavedMetric: async () => { + const response = await api.update( + `api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`, + values.savedMetric + ) + if (response.id) { + lemonToast.success('Saved metric updated successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } + }, + deleteSavedMetric: async () => { + try { + await api.delete(`api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`) + lemonToast.success('Saved metric deleted successfully') + actions.loadSavedMetrics() + router.actions.push('/experiments/saved-metrics') + } catch (error) { + lemonToast.error('Failed to delete saved metric') + console.error(error) + } + }, + })), + + reducers({ + savedMetric: [ + { ...NEW_SAVED_METRIC } as Partial, + { + setSavedMetric: (state, { metric }) => ({ ...state, ...metric }), + }, + ], + }), + + selectors({ + savedMetricId: [ + () => [(_, props) => props.savedMetricId ?? 'new'], + (savedMetricId): string | number => savedMetricId, + ], + isNew: [(s) => [s.savedMetricId], (savedMetricId) => savedMetricId === 'new'], + }), + + urlToAction(({ actions, values }) => ({ + '/experiments/saved-metrics/:id': ({ id }, _, __, currentLocation, previousLocation) => { + const didPathChange = currentLocation.initial || currentLocation.pathname !== previousLocation?.pathname + + if (id && didPathChange) { + const parsedId = id === 'new' ? 'new' : parseInt(id) + if (parsedId === 'new') { + actions.setSavedMetric({ ...NEW_SAVED_METRIC }) + } + + if (parsedId !== 'new' && parsedId === values.savedMetricId) { + actions.loadSavedMetric() + } + } + }, + })), +]) diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx new file mode 100644 index 0000000000000..f9044a4eb181c --- /dev/null +++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx @@ -0,0 +1,48 @@ +import { actions, events, kea, listeners, path, reducers } from 'kea' +import { loaders } from 'kea-loaders' +import { router } from 'kea-router' +import api from 'lib/api' + +import { SavedMetric } from './savedMetricLogic' +import type { savedMetricsLogicType } from './savedMetricsLogicType' + +export enum SavedMetricsTabs { + All = 'all', + Yours = 'yours', + Archived = 'archived', +} + +export const savedMetricsLogic = kea([ + path(['scenes', 'experiments', 'savedMetricsLogic']), + actions({ + setSavedMetricsTab: (tabKey: SavedMetricsTabs) => ({ tabKey }), + }), + + loaders({ + savedMetrics: { + loadSavedMetrics: async () => { + const response = await api.get('api/projects/@current/experiment_saved_metrics') + return response.results as SavedMetric[] + }, + }, + }), + + reducers({ + tab: [ + SavedMetricsTabs.All as SavedMetricsTabs, + { + setSavedMetricsTab: (_, { tabKey }) => tabKey, + }, + ], + }), + listeners(() => ({ + setSavedMetricsTab: () => { + router.actions.push('/experiments/saved-metrics') + }, + })), + events(({ actions }) => ({ + afterMount: () => { + actions.loadSavedMetrics() + }, + })), +]) diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index beb7356103c97..338237d903ad7 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -66,6 +66,8 @@ import { MetricInsightId } from './constants' import type { experimentLogicType } from './experimentLogicType' import { experimentsLogic } from './experimentsLogic' import { holdoutsLogic } from './holdoutsLogic' +import { SavedMetric } from './SavedMetrics/savedMetricLogic' +import { savedMetricsLogic } from './SavedMetrics/savedMetricsLogic' import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils' const NEW_EXPERIMENT: Experiment = { @@ -76,6 +78,8 @@ const NEW_EXPERIMENT: Experiment = { filters: {}, metrics: [], metrics_secondary: [], + saved_metrics_ids: [], + saved_metrics: [], parameters: { feature_flag_variants: [ { key: 'control', rollout_percentage: 50 }, @@ -148,6 +152,8 @@ export const experimentLogic = kea([ ['insightDataLoading as trendMetricInsightLoading'], insightDataLogic({ dashboardItemId: MetricInsightId.Funnels }), ['insightDataLoading as funnelMetricInsightLoading'], + savedMetricsLogic, + ['savedMetrics'], ], actions: [ experimentsLogic, @@ -273,6 +279,22 @@ export const experimentLogic = kea([ openSecondaryMetricModal: (index: number) => ({ index }), closeSecondaryMetricModal: true, setSecondaryMetricsResultErrors: (errors: any[]) => ({ errors }), + openPrimaryMetricSourceModal: true, + closePrimaryMetricSourceModal: true, + openSecondaryMetricSourceModal: true, + closeSecondaryMetricSourceModal: true, + openPrimarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }), + closePrimarySavedMetricModal: true, + openSecondarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }), + closeSecondarySavedMetricModal: true, + addSavedMetricToExperiment: ( + savedMetricId: SavedMetric['id'], + metadata: { type: 'primary' | 'secondary' } + ) => ({ + savedMetricId, + metadata, + }), + removeSavedMetricFromExperiment: (savedMetricId: SavedMetric['id']) => ({ savedMetricId }), }), reducers({ experiment: [ @@ -514,6 +536,16 @@ export const experimentLogic = kea([ updateExperimentGoal: () => null, }, ], + editingSavedMetricId: [ + null as SavedMetric['id'] | null, + { + openPrimarySavedMetricModal: (_, { savedMetricId }) => savedMetricId, + openSecondarySavedMetricModal: (_, { savedMetricId }) => savedMetricId, + closePrimarySavedMetricModal: () => null, + closeSecondarySavedMetricModal: () => null, + updateExperimentGoal: () => null, + }, + ], secondaryMetricsResultErrors: [ [] as any[], { @@ -522,6 +554,34 @@ export const experimentLogic = kea([ loadExperiment: () => [], }, ], + isPrimaryMetricSourceModalOpen: [ + false, + { + openPrimaryMetricSourceModal: () => true, + closePrimaryMetricSourceModal: () => false, + }, + ], + isSecondaryMetricSourceModalOpen: [ + false, + { + openSecondaryMetricSourceModal: () => true, + closeSecondaryMetricSourceModal: () => false, + }, + ], + isPrimarySavedMetricModalOpen: [ + false, + { + openPrimarySavedMetricModal: () => true, + closePrimarySavedMetricModal: () => false, + }, + ], + isSecondarySavedMetricModalOpen: [ + false, + { + openSecondarySavedMetricModal: () => true, + closeSecondarySavedMetricModal: () => false, + }, + ], }), listeners(({ values, actions }) => ({ createExperiment: async ({ draft }) => { @@ -697,6 +757,12 @@ export const experimentLogic = kea([ closeSecondaryMetricModal: () => { actions.loadExperiment() }, + closePrimarySavedMetricModal: () => { + actions.loadExperiment() + }, + closeSecondarySavedMetricModal: () => { + actions.loadExperiment() + }, resetRunningExperiment: async () => { actions.updateExperiment({ start_date: null, end_date: null, archived: false }) values.experiment && actions.reportExperimentReset(values.experiment) @@ -842,6 +908,36 @@ export const experimentLogic = kea([ holdout_id: values.experiment.holdout_id, }) }, + addSavedMetricToExperiment: async ({ savedMetricId, metadata }) => { + const savedMetricsIds = values.experiment.saved_metrics.map((savedMetric) => ({ + id: savedMetric.saved_metric, + metadata, + })) + savedMetricsIds.push({ id: savedMetricId, metadata }) + + await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, { + saved_metrics_ids: savedMetricsIds, + }) + + actions.closePrimarySavedMetricModal() + actions.closeSecondarySavedMetricModal() + actions.loadExperiment() + }, + removeSavedMetricFromExperiment: async ({ savedMetricId }) => { + const savedMetricsIds = values.experiment.saved_metrics + .filter((savedMetric) => savedMetric.saved_metric !== savedMetricId) + .map((savedMetric) => ({ + id: savedMetric.saved_metric, + metadata: savedMetric.metadata, + })) + await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, { + saved_metrics_ids: savedMetricsIds, + }) + + actions.closePrimarySavedMetricModal() + actions.closeSecondarySavedMetricModal() + actions.loadExperiment() + }, })), loaders(({ actions, props, values }) => ({ experiment: { @@ -876,8 +972,16 @@ export const experimentLogic = kea([ loadMetricResults: async ( refresh?: boolean ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => { + let metrics = values.experiment?.metrics + const savedMetrics = values.experiment?.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === 'primary') + .map((savedMetric) => savedMetric.query) + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + return (await Promise.all( - values.experiment?.metrics.map(async (metric, index) => { + metrics.map(async (metric, index) => { try { const queryWithExperimentId = { ...metric, @@ -913,8 +1017,16 @@ export const experimentLogic = kea([ loadSecondaryMetricResults: async ( refresh?: boolean ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => { + let metrics = values.experiment?.metrics_secondary + const savedMetrics = values.experiment?.saved_metrics + .filter((savedMetric) => savedMetric.metadata.type === 'secondary') + .map((savedMetric) => savedMetric.query) + if (savedMetrics) { + metrics = [...metrics, ...savedMetrics] + } + return (await Promise.all( - values.experiment?.metrics_secondary.map(async (metric, index) => { + metrics.map(async (metric, index) => { try { const queryWithExperimentId = { ...metric, @@ -992,20 +1104,11 @@ export const experimentLogic = kea([ () => [(_, props) => props.experimentId ?? 'new'], (experimentId): Experiment['id'] => experimentId, ], - getMetricType: [ - (s) => [s.experiment], - (experiment) => - (metricIdx: number = 0) => { - const query = experiment?.metrics?.[metricIdx] - return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS - }, - ], - getSecondaryMetricType: [ - (s) => [s.experiment], - (experiment) => - (metricIdx: number = 0) => { - const query = experiment?.metrics_secondary?.[metricIdx] - return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS + _getMetricType: [ + () => [], + () => + (metric: ExperimentTrendsQuery | ExperimentFunnelsQuery): InsightType => { + return metric?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS }, ], isExperimentRunning: [ @@ -1090,12 +1193,16 @@ export const experimentLogic = kea([ }, ], minimumDetectableEffect: [ - (s) => [s.experiment, s.getMetricType, s.conversionMetrics, s.trendResults], - (newExperiment, getMetricType, 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(getMetricType(0), conversionMetrics, trendResults) || + getMinimumDetectableEffect( + _getMetricType(newExperiment?.metrics[0]), + conversionMetrics, + trendResults + ) || 0 ) }, @@ -1176,7 +1283,7 @@ export const experimentLogic = kea([ (s) => [ s.experiment, s.variants, - s.getMetricType, + s._getMetricType, s.funnelResults, s.conversionMetrics, s.expectedRunningTime, @@ -1187,7 +1294,7 @@ export const experimentLogic = kea([ ( experiment, variants, - getMetricType, + _getMetricType, funnelResults, conversionMetrics, expectedRunningTime, @@ -1195,7 +1302,7 @@ export const experimentLogic = kea([ minimumSampleSizePerVariant, recommendedExposureForCountData ): number => { - if (getMetricType(0) === InsightType.FUNNELS) { + if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { const currentDuration = dayjs().diff(dayjs(experiment?.start_date), 'hour') const funnelEntrants = funnelResults?.[0]?.count @@ -1323,8 +1430,8 @@ export const experimentLogic = kea([ }, ], getIndexForVariant: [ - (s) => [s.getMetricType], - (getMetricType) => + (s) => [s.experiment, s._getMetricType], + (experiment, _getMetricType) => ( metricResult: | Partial @@ -1340,7 +1447,7 @@ export const experimentLogic = kea([ } let index = -1 - if (getMetricType(0) === InsightType.FUNNELS) { + if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { // Funnel Insight is displayed in order of decreasing count index = (Array.isArray(metricResult.insight) ? [...metricResult.insight] : []) .sort((a, b) => { @@ -1362,7 +1469,7 @@ export const experimentLogic = kea([ } const result = index === -1 ? null : index - if (result !== null && getMetricType(0) === InsightType.FUNNELS) { + if (result !== null && _getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) { return result + 1 } return result @@ -1463,7 +1570,7 @@ export const experimentLogic = kea([ }, ], tabularExperimentResults: [ - (s) => [s.experiment, s.metricResults, s.getMetricType], + (s) => [s.experiment, s.metricResults, s._getMetricType], ( experiment, metricResults: ( @@ -1471,11 +1578,11 @@ export const experimentLogic = kea([ | CachedExperimentTrendsQueryResponse | null )[], - getMetricType + _getMetricType ) => (metricIndex: number = 0): any[] => { const tabularResults = [] - const metricType = getMetricType(metricIndex) + const metricType = _getMetricType(experiment.metrics[metricIndex]) const result = metricResults?.[metricIndex] if (result) { @@ -1571,19 +1678,20 @@ export const experimentLogic = kea([ }, ], funnelResultsPersonsTotal: [ - (s) => [s.metricResults, s.getMetricType], + (s) => [s.experiment, s.metricResults, s._getMetricType], ( + experiment, metricResults: ( | CachedExperimentFunnelsQueryResponse | CachedExperimentTrendsQueryResponse | null )[], - getMetricType + _getMetricType ) => (metricIndex: number = 0): number => { const result = metricResults?.[metricIndex] - if (getMetricType(metricIndex) !== InsightType.FUNNELS || !result?.insight) { + if (_getMetricType(experiment.metrics[metricIndex]) !== InsightType.FUNNELS || !result?.insight) { return 0 } @@ -1671,7 +1779,6 @@ export const experimentLogic = kea([ if (parsedId === 'new') { actions.resetExperiment() } - if (parsedId !== 'new' && parsedId === values.experimentId) { actions.loadExperiment() } diff --git a/frontend/src/scenes/experiments/experimentsLogic.ts b/frontend/src/scenes/experiments/experimentsLogic.ts index 317b353070773..b9558daf5c07e 100644 --- a/frontend/src/scenes/experiments/experimentsLogic.ts +++ b/frontend/src/scenes/experiments/experimentsLogic.ts @@ -1,7 +1,8 @@ import { LemonTagType } from '@posthog/lemon-ui' import Fuse from 'fuse.js' -import { actions, connect, events, kea, path, reducers, selectors } from 'kea' +import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' +import { router } from 'kea-router' import api from 'lib/api' import { FEATURE_FLAGS } from 'lib/constants' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' @@ -43,6 +44,8 @@ export const experimentsLogic = kea([ ['user', 'hasAvailableFeature'], featureFlagLogic, ['featureFlags'], + router, + ['location'], ], }), actions({ @@ -67,10 +70,21 @@ export const experimentsLogic = kea([ tab: [ ExperimentsTabs.All as ExperimentsTabs, { - setExperimentsTab: (_, { tabKey }) => tabKey, + setExperimentsTab: (state, { tabKey }) => tabKey ?? state, }, ], }), + listeners(({ actions }) => ({ + setExperimentsTab: ({ tabKey }) => { + if (tabKey === ExperimentsTabs.SavedMetrics) { + // Saved Metrics is a fake tab that we use to redirect to the saved metrics page + actions.setExperimentsTab(ExperimentsTabs.All) + router.actions.push('/experiments/saved-metrics') + } else { + router.actions.push('/experiments') + } + }, + })), loaders(({ values }) => ({ experiments: [ [] as Experiment[], diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx index afde3f1836415..c87e65370cd23 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx @@ -81,7 +81,7 @@ const Component = ({ attributes }: NotebookNodeProps
- +
diff --git a/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx b/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx index 7ac6e5f9564c8..2cba3e1cf65b5 100644 --- a/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx +++ b/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx @@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea' import { PayGateButton } from 'lib/components/PayGateMini/PayGateButton' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { userLogic } from 'scenes/userLogic' import { AvailableFeature, HogFunctionTypeType, PipelineStage } from '~/types' @@ -31,6 +32,7 @@ export function DestinationOptionsTable({ types }: NewDestinationsProps): JSX.El const { loading, filteredDestinations, hiddenDestinations } = useValues(newDestinationsLogic({ types })) const { canEnableDestination } = useValues(pipelineAccessLogic) const { resetFilters } = useActions(destinationsFiltersLogic({ types })) + const { user } = useValues(userLogic) return ( <> @@ -75,15 +77,22 @@ export function DestinationOptionsTable({ types }: NewDestinationsProps): JSX.El type="primary" data-attr={`new-${PipelineStage.Destination}`} icon={} - // Preserve hash params to pass config in to={target.url} - fullWidth > Create ) : ( - + + {/* Allow staff users to create destinations */} + {user?.is_impersonated && ( + } + tooltip="Staff users can create destinations as an override" + to={target.url} + /> + )} ) }, diff --git a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx index e4b6bd8db6c24..285665e5aef79 100644 --- a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx +++ b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx @@ -63,7 +63,7 @@ export const newDestinationsLogic = kea([ const destinationTypes = siteDesinationsEnabled ? props.types : props.types.filter((type) => type !== 'site_destination') - const templates = await api.hogFunctions.listTemplates(destinationTypes) + const templates = await api.hogFunctions.listTemplates({ types: destinationTypes }) return templates.results.reduce((acc, template) => { acc[template.id] = template return acc diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx index cfa5dc06d463c..c94ac3d6681f5 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx @@ -6,7 +6,6 @@ import { LemonDropdown, LemonInput, LemonLabel, - LemonSelect, LemonSwitch, LemonTag, LemonTextArea, @@ -42,9 +41,23 @@ const EVENT_THRESHOLD_ALERT_LEVEL = 8000 export interface HogFunctionConfigurationProps { templateId?: string | null id?: string | null + + displayOptions?: { + showFilters?: boolean + showExpectedVolume?: boolean + showStatus?: boolean + showEnabled?: boolean + showTesting?: boolean + canEditSource?: boolean + showPersonsCount?: boolean + } } -export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigurationProps): JSX.Element { +export function HogFunctionConfiguration({ + templateId, + id, + displayOptions = {}, +}: HogFunctionConfigurationProps): JSX.Element { const logicProps = { templateId, id } const logic = hogFunctionConfigurationLogic(logicProps) const { @@ -66,9 +79,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur personsCountLoading, personsListQuery, template, - subTemplate, templateHasChanged, - forcedSubTemplateId, type, } = useValues(logic) const { @@ -80,7 +91,6 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur duplicateFromTemplate, setConfigurationValue, deleteHogFunction, - setSubTemplateId, } = useActions(logic) if (loading && !loaded) { @@ -152,13 +162,24 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur return } - const showFilters = ['destination', 'site_destination', 'broadcast', 'transformation'].includes(type) - const showExpectedVolume = ['destination', 'site_destination'].includes(type) - const showStatus = ['destination', 'email', 'transformation'].includes(type) - const showEnabled = ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type) - const canEditSource = ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type) - const showPersonsCount = ['broadcast'].includes(type) - const showTesting = ['destination', 'transformation', 'broadcast', 'email'].includes(type) + const showFilters = + displayOptions.showFilters ?? + ['destination', 'internal_destination', 'site_destination', 'broadcast', 'transformation'].includes(type) + const showExpectedVolume = displayOptions.showExpectedVolume ?? ['destination', 'site_destination'].includes(type) + const showStatus = + displayOptions.showStatus ?? ['destination', 'internal_destination', 'email', 'transformation'].includes(type) + const showEnabled = + displayOptions.showEnabled ?? + ['destination', 'internal_destination', 'email', 'site_destination', 'site_app', 'transformation'].includes( + type + ) + const canEditSource = + displayOptions.canEditSource ?? + ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type) + const showPersonsCount = displayOptions.showPersonsCount ?? ['broadcast'].includes(type) + const showTesting = + displayOptions.showTesting ?? + ['destination', 'internal_destination', 'transformation', 'broadcast', 'email'].includes(type) return (
@@ -359,41 +380,6 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
- {!forcedSubTemplateId && template?.sub_templates && ( - <> -
-
- Choose template - ({ - value: subTemplate.id, - label: subTemplate.name, - labelInMenu: ( -
-
{subTemplate.name}
-
- {subTemplate.description} -
-
- ), - })), - ]} - value={subTemplate?.id} - onChange={(value) => { - setSubTemplateId(value) - }} - /> -
-
- - )} -
{ if (!filters) { @@ -74,6 +75,10 @@ export function HogFunctionFilters(): JSX.Element { ) } + if (type === 'internal_destination') { + return + } + const showMasking = type === 'destination' const showDropEvents = type === 'transformation' diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx new file mode 100644 index 0000000000000..7195bc320cf40 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx @@ -0,0 +1,49 @@ +import { LemonSelect } from '@posthog/lemon-ui' +import { LemonField } from 'lib/lemon-ui/LemonField' + +import { HogFunctionFiltersType } from '~/types' + +// NOTE: This is all a bit WIP and will be improved upon over time +// TODO: Make this more advanced with sub type filtering etc. +// TODO: Make it possible for the renderer to limit the options based on the type +const FILTER_OPTIONS = [ + { + label: 'Team activity', + value: '$activity_log_entry_created', + }, +] + +const getSimpleFilterValue = (value?: HogFunctionFiltersType): string | undefined => { + return value?.events?.[0]?.id +} + +const setSimpleFilterValue = (value: string): HogFunctionFiltersType => { + return { + events: [ + { + name: FILTER_OPTIONS.find((option) => option.value === value)?.label, + id: value, + type: 'events', + }, + ], + } +} + +export function HogFunctionFiltersInternal(): JSX.Element { + return ( +
+ + {({ value, onChange }) => ( + <> + onChange(setSimpleFilterValue(value))} + placeholder="Select a filter" + /> + + )} + +
+ ) +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts index 3c9bef43c45d8..0f93034551c59 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts @@ -23,7 +23,7 @@ import { hogFunctionConfigurationLogic } from './hogFunctionConfigurationLogic' const HOG_TEMPLATE: HogFunctionTemplateType = { sub_templates: [ { - id: 'early_access_feature_enrollment', + id: 'early-access-feature-enrollment', name: 'HTTP Webhook on feature enrollment', description: null, filters: { @@ -38,7 +38,7 @@ const HOG_TEMPLATE: HogFunctionTemplateType = { inputs: null, }, { - id: 'survey_response', + id: 'survey-response', name: 'HTTP Webhook on survey response', description: null, filters: { diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx index d38b39ce21c59..f7312f19e8640 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx @@ -33,8 +33,6 @@ import { HogFunctionInputType, HogFunctionInvocationGlobals, HogFunctionMappingType, - HogFunctionSubTemplateIdType, - HogFunctionSubTemplateType, HogFunctionTemplateType, HogFunctionType, HogFunctionTypeType, @@ -49,7 +47,6 @@ import type { hogFunctionConfigurationLogicType } from './hogFunctionConfigurati export interface HogFunctionConfigurationLogicProps { templateId?: string | null - subTemplateId?: string | null id?: string | null } @@ -116,19 +113,11 @@ export function sanitizeConfiguration(data: HogFunctionConfigurationType): HogFu return payload } -const templateToConfiguration = ( - template: HogFunctionTemplateType, - subTemplate?: HogFunctionSubTemplateType | null -): HogFunctionConfigurationType => { - function getInputs( - inputs_schema?: HogFunctionInputSchemaType[] | null, - subTemplate?: HogFunctionSubTemplateType | null - ): Record { +const templateToConfiguration = (template: HogFunctionTemplateType): HogFunctionConfigurationType => { + function getInputs(inputs_schema?: HogFunctionInputSchemaType[] | null): Record { const inputs: Record = {} inputs_schema?.forEach((schema) => { - if (typeof subTemplate?.inputs?.[schema.key] !== 'undefined') { - inputs[schema.key] = { value: subTemplate.inputs[schema.key] } - } else if (schema.default !== undefined) { + if (schema.default !== undefined) { inputs[schema.key] = { value: schema.default } } }) @@ -149,11 +138,11 @@ const templateToConfiguration = ( return { type: template.type ?? 'destination', - name: subTemplate?.name ?? template.name, - description: subTemplate?.name ?? template.description, + name: template.name, + description: template.description, inputs_schema: template.inputs_schema, - filters: subTemplate?.filters ?? template.filters, - mappings: (subTemplate?.mappings ?? template.mappings)?.map( + filters: template.filters, + mappings: template.mappings?.map( (mapping): HogFunctionMappingType => ({ ...mapping, inputs: getMappingInputs(mapping.inputs_schema), @@ -161,7 +150,7 @@ const templateToConfiguration = ( ), hog: template.hog, icon_url: template.icon_url, - inputs: getInputs(template.inputs_schema, subTemplate), + inputs: getInputs(template.inputs_schema), enabled: template.type !== 'broadcast', } } @@ -226,7 +215,6 @@ export const hogFunctionConfigurationLogic = kea ({ sparklineQuery } as { sparklineQuery: TrendsQuery }), personsCountQueryChanged: (personsCountQuery: ActorsQuery) => ({ personsCountQuery } as { personsCountQuery: ActorsQuery }), - setSubTemplateId: (subTemplateId: HogFunctionSubTemplateIdType | null) => ({ subTemplateId }), loadSampleGlobals: true, setUnsavedConfiguration: (configuration: HogFunctionConfigurationType | null) => ({ configuration }), persistForUnload: true, @@ -254,12 +242,6 @@ export const hogFunctionConfigurationLogic = kea true, }, ], - subTemplateId: [ - null as HogFunctionSubTemplateIdType | null, - { - setSubTemplateId: (_, { subTemplateId }) => subTemplateId, - }, - ], unsavedConfiguration: [ null as { timestamp: number; configuration: HogFunctionConfigurationType } | null, @@ -467,6 +449,10 @@ export const hogFunctionConfigurationLogic = kea (hogFunction ?? template)?.type === 'site_destination', ], defaultFormState: [ - (s) => [s.template, s.hogFunction, s.subTemplate], - (template, hogFunction, subTemplate): HogFunctionConfigurationType | null => { + (s) => [s.template, s.hogFunction], + (template, hogFunction): HogFunctionConfigurationType | null => { if (template) { - return templateToConfiguration(template, subTemplate) + return templateToConfiguration(template) } return hogFunction ?? null }, @@ -843,18 +829,6 @@ export const hogFunctionConfigurationLogic = kea [s.template, s.subTemplateId], - (template, subTemplateId) => { - if (!template || !subTemplateId) { - return null - } - - const subTemplate = template.sub_templates?.find((st) => st.id === subTemplateId) - return subTemplate - }, - ], - forcedSubTemplateId: [() => [router.selectors.searchParams], ({ sub_template }) => !!sub_template], mappingTemplates: [ (s) => [s.hogFunction, s.template], (hogFunction, template) => template?.mapping_templates ?? hogFunction?.template?.mapping_templates ?? [], @@ -966,7 +940,7 @@ export const hogFunctionConfigurationLogic = kea { const template = values.hogFunction?.template ?? values.template if (template) { - const config = templateToConfiguration(template, values.subTemplate) + const config = templateToConfiguration(template) const inputs = config.inputs ?? {} @@ -1014,10 +988,6 @@ export const hogFunctionConfigurationLogic = kea { - actions.resetToTemplate() - }, - persistForUnload: () => { actions.setUnsavedConfiguration(values.configuration) }, @@ -1030,9 +1000,6 @@ export const hogFunctionConfigurationLogic = kea([ actions.setTestInvocationValue('globals', JSON.stringify(sampleGlobals, null, 2)) }, })), + forms(({ props, actions, values }) => ({ testInvocation: { defaults: { diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx index d86cd44b5634e..7f9fd156672ac 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx @@ -20,11 +20,11 @@ export function HogFunctionTemplateList({ ) const { loadHogFunctionTemplates, setFilters, resetFilters } = useActions(hogFunctionTemplateListLogic(props)) - useEffect(() => loadHogFunctionTemplates(), []) + useEffect(() => loadHogFunctionTemplates(), [props.type, props.subTemplateId]) return ( <> -
+
{!props.forceFilters?.search && ( setShowNewDestination(false)}> diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx index c3397c10d4c50..e8f71ecca6454 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx @@ -8,7 +8,7 @@ import { objectsEqual } from 'lib/utils' import { hogFunctionNewUrl } from 'scenes/pipeline/hogfunctions/urls' import { pipelineAccessLogic } from 'scenes/pipeline/pipelineAccessLogic' -import { HogFunctionTemplateType, HogFunctionTypeType } from '~/types' +import { HogFunctionSubTemplateIdType, HogFunctionTemplateType, HogFunctionTypeType } from '~/types' import type { hogFunctionTemplateListLogicType } from './hogFunctionTemplateListLogicType' @@ -18,11 +18,11 @@ export interface Fuse extends FuseClass {} export type HogFunctionTemplateListFilters = { search?: string filters?: Record - subTemplateId?: string } export type HogFunctionTemplateListLogicProps = { type: HogFunctionTypeType + subTemplateId?: HogFunctionSubTemplateIdType defaultFilters?: HogFunctionTemplateListFilters forceFilters?: HogFunctionTemplateListFilters syncFiltersWithUrl?: boolean @@ -30,7 +30,12 @@ export type HogFunctionTemplateListLogicProps = { export const hogFunctionTemplateListLogic = kea([ props({} as HogFunctionTemplateListLogicProps), - key((props) => `${props.syncFiltersWithUrl ? 'scene' : 'default'}/${props.type ?? 'destination'}`), + key( + (props) => + `${props.syncFiltersWithUrl ? 'scene' : 'default'}/${props.type ?? 'destination'}/${ + props.subTemplateId ?? '' + }` + ), path((id) => ['scenes', 'pipeline', 'destinationsLogic', id]), connect({ values: [pipelineAccessLogic, ['canEnableNewDestinations'], featureFlagLogic, ['featureFlags']], @@ -55,41 +60,22 @@ export const hogFunctionTemplateListLogic = kea ({ - rawTemplates: [ + templates: [ [] as HogFunctionTemplateType[], { loadHogFunctionTemplates: async () => { - return (await api.hogFunctions.listTemplates(props.type)).results + return ( + await api.hogFunctions.listTemplates({ + types: [props.type], + sub_template_id: props.subTemplateId, + }) + ).results }, }, ], })), selectors({ - loading: [(s) => [s.rawTemplatesLoading], (x) => x], - templates: [ - (s) => [s.rawTemplates, s.filters], - (rawTemplates, { subTemplateId }): HogFunctionTemplateType[] => { - if (!subTemplateId) { - return rawTemplates - } - const templates: HogFunctionTemplateType[] = [] - // We want to pull out the sub templates and return the template but with overrides applied - - rawTemplates.forEach((template) => { - const subTemplate = template.sub_templates?.find((subTemplate) => subTemplate.id === subTemplateId) - - if (subTemplate) { - templates.push({ - ...template, - name: subTemplate.name, - description: subTemplate.description ?? template.description, - }) - } - }) - - return templates - }, - ], + loading: [(s) => [s.templatesLoading], (x) => x], templatesFuse: [ (s) => [s.templates], (hogFunctionTemplates): Fuse => { @@ -123,13 +109,9 @@ export const hogFunctionTemplateListLogic = kea string) => { return (template: HogFunctionTemplateType) => { // Add the filters to the url and the template id - const subTemplateId = filters.subTemplateId - return combineUrl( hogFunctionNewUrl(template.type, template.id), - { - sub_template: subTemplateId, - }, + {}, { configuration: { filters: filters.filters, diff --git a/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx b/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx index d2db4bc584484..3b1930b176472 100644 --- a/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx @@ -215,7 +215,7 @@ export const pipelineBatchExportConfigurationLogic = kea) => ({ configuration }), setSelectedModel: (model: string) => ({ model }), }), - loaders(({ props, values, actions }) => ({ + loaders(({ props, actions }) => ({ batchExportConfig: [ null as BatchExportConfiguration | null, { @@ -226,13 +226,6 @@ export const pipelineBatchExportConfigurationLogic = kea { - if ( - (!values.batchExportConfig || (values.batchExportConfig.paused && formdata.paused !== true)) && - !values.canEnableNewDestinations - ) { - lemonToast.error('Data pipelines add-on is required for enabling new destinations.') - return null - } const { name, destination, interval, paused, created_at, start_at, end_at, model, ...config } = formdata const destinationObj = { diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 74cb1e175500d..4ce10aa5d33fb 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -36,6 +36,8 @@ export enum Scene { Group = 'Group', Action = 'Action', Experiments = 'Experiments', + ExperimentsSavedMetrics = 'ExperimentsSavedMetrics', + ExperimentsSavedMetric = 'ExperimentsSavedMetric', Experiment = 'Experiment', FeatureManagement = 'FeatureManagement', FeatureFlags = 'FeatureFlags', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 6a3dadf7d5c1e..816a79b3129db 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -203,6 +203,18 @@ export const sceneConfigurations: Record = { defaultDocsPath: '/docs/experiments/creating-an-experiment', activityScope: ActivityScope.EXPERIMENT, }, + [Scene.ExperimentsSavedMetric]: { + projectBased: true, + name: 'Saved metric', + defaultDocsPath: '/docs/experiments/creating-an-experiment', + activityScope: ActivityScope.EXPERIMENT, + }, + [Scene.ExperimentsSavedMetrics]: { + projectBased: true, + name: 'Saved metrics', + defaultDocsPath: '/docs/experiments/creating-an-experiment', + activityScope: ActivityScope.EXPERIMENT, + }, [Scene.FeatureFlags]: { projectBased: true, name: 'Feature flags', @@ -560,6 +572,8 @@ export const routes: Record = { [urls.cohort(':id')]: Scene.Cohort, [urls.cohorts()]: Scene.PersonsManagement, [urls.experiments()]: Scene.Experiments, + [urls.experimentsSavedMetrics()]: Scene.ExperimentsSavedMetrics, + [urls.experimentsSavedMetric(':id')]: Scene.ExperimentsSavedMetric, [urls.experiment(':id')]: Scene.Experiment, [urls.earlyAccessFeatures()]: Scene.EarlyAccessFeatures, [urls.earlyAccessFeature(':id')]: Scene.EarlyAccessFeature, @@ -591,6 +605,7 @@ export const routes: Record = { [urls.instanceStatus()]: Scene.SystemStatus, [urls.instanceSettings()]: Scene.SystemStatus, [urls.instanceStaffUsers()]: Scene.SystemStatus, + [urls.instanceKafkaInspector()]: Scene.SystemStatus, [urls.instanceMetrics()]: Scene.SystemStatus, [urls.asyncMigrations()]: Scene.AsyncMigrations, [urls.asyncMigrationsFuture()]: Scene.AsyncMigrations, diff --git a/frontend/src/scenes/surveys/SurveyView.tsx b/frontend/src/scenes/surveys/SurveyView.tsx index f064b83899bde..267879c405c43 100644 --- a/frontend/src/scenes/surveys/SurveyView.tsx +++ b/frontend/src/scenes/surveys/SurveyView.tsx @@ -279,7 +279,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element { content: (
- Display mode + Display mode {survey.type === SurveyType.API ? survey.type.toUpperCase() @@ -287,9 +287,9 @@ export function SurveyView({ id }: { id: string }): JSX.Element { {survey.questions[0].question && ( <> - Type + Type {SurveyQuestionLabel[survey.questions[0].type]} - + {pluralize( survey.questions.length, 'Question', @@ -304,20 +304,20 @@ export function SurveyView({ id }: { id: string }): JSX.Element { )} {survey.questions[0].type === SurveyQuestionType.Link && ( <> - Link url + Link url {survey.questions[0].link} )}
{survey.start_date && (
- Start date + Start date
)} {survey.end_date && (
- End date + End date
)} @@ -328,7 +328,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element { survey.iteration_count > 0 && survey.iteration_frequency_days > 0 ? (
- Schedule + Schedule Repeats every {survey.iteration_frequency_days}{' '} {pluralize( @@ -345,7 +345,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
{surveyUsesLimit && ( <> - Completion conditions + Completion conditions The survey will be stopped once {survey.responses_limit}{' '} responses are received. @@ -354,7 +354,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element { )} {surveyUsesAdaptiveLimit && ( <> - Completion conditions + Completion conditions Survey response collection is limited to receive{' '} {survey.response_sampling_limit} responses every{' '} @@ -370,10 +370,10 @@ export function SurveyView({ id }: { id: string }): JSX.Element { targetingFlagFilters={targetingFlagFilters} />
-
+
{survey.type === SurveyType.API && ( -
-
+
+
Learn how to set up API surveys{' '} Get notified whenever a survey result is submitted

{surveyNPSScore}
-
Latest NPS Score
+
Latest NPS Score
)} @@ -544,7 +544,7 @@ export function SurveyResult({ disableEventsTable }: { disableEventsTable?: bool } })} -
+
{tab === SurveysTabs.Settings && ( <> -
+
These settings apply to new surveys in this organization. @@ -165,7 +165,7 @@ export function Surveys(): JSX.Element { )}
-
+
Get notified whenever a survey result is submitted

-
+
'/cohorts', experiment: (id: string | number): string => `/experiments/${id}`, experiments: (): string => '/experiments', + experimentsSavedMetrics: (): string => '/experiments/saved-metrics', + experimentsSavedMetric: (id: string | number): string => `/experiments/saved-metrics/${id}`, featureFlags: (tab?: string): string => `/feature_flags${tab ? `?tab=${tab}` : ''}`, featureFlag: (id: string | number): string => `/feature_flags/${id}`, featureManagement: (id?: string | number): string => `/features${id ? `/${id}` : ''}`, @@ -213,6 +215,7 @@ export const urls = { // Self-hosted only instanceStatus: (): string => '/instance/status', instanceStaffUsers: (): string => '/instance/staff_users', + instanceKafkaInspector: (): string => '/instance/kafka_inspector', instanceSettings: (): string => '/instance/settings', instanceMetrics: (): string => `/instance/metrics`, asyncMigrations: (): string => '/instance/async_migrations', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4b218bd90f0f9..be7dea47a8302 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -702,6 +702,7 @@ export enum ExperimentsTabs { Yours = 'yours', Archived = 'archived', Holdouts = 'holdouts', + SavedMetrics = 'saved-metrics', } export enum ActivityTab { @@ -3310,6 +3311,8 @@ export interface Experiment { filters: TrendsFilterType | FunnelsFilterType metrics: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[] metrics_secondary: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[] + saved_metrics_ids: { id: number; metadata: { type: 'primary' | 'secondary' } }[] + saved_metrics: any[] parameters: { minimum_detectable_effect?: number recommended_running_time?: number @@ -4669,6 +4672,7 @@ export interface HogFunctionMappingTemplateType extends HogFunctionMappingType { export type HogFunctionTypeType = | 'destination' + | 'internal_destination' | 'site_destination' | 'site_app' | 'transformation' @@ -4701,7 +4705,7 @@ export type HogFunctionType = { } export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' | 'client-side' -export type HogFunctionSubTemplateIdType = 'early_access_feature_enrollment' | 'survey_response' +export type HogFunctionSubTemplateIdType = 'early-access-feature-enrollment' | 'survey-response' | 'activity-log' export type HogFunctionConfigurationType = Omit< HogFunctionType, diff --git a/plugin-server/package.json b/plugin-server/package.json index 9014d19be548b..5f2a9dfdac165 100644 --- a/plugin-server/package.json +++ b/plugin-server/package.json @@ -93,7 +93,8 @@ "tail": "^2.2.6", "uuid": "^9.0.1", "v8-profiler-next": "^1.9.0", - "vm2": "3.9.18" + "vm2": "3.9.18", + "zod": "^3.24.1" }, "devDependencies": { "0x": "^5.5.0", diff --git a/plugin-server/pnpm-lock.yaml b/plugin-server/pnpm-lock.yaml index f187191553102..e23910979edfc 100644 --- a/plugin-server/pnpm-lock.yaml +++ b/plugin-server/pnpm-lock.yaml @@ -166,6 +166,9 @@ dependencies: vm2: specifier: 3.9.18 version: 3.9.18 + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: 0x: @@ -10915,6 +10918,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + dev: false + file:../rust/cyclotron-node: resolution: {directory: ../rust/cyclotron-node, type: directory} name: '@posthog/cyclotron' diff --git a/plugin-server/src/capabilities.ts b/plugin-server/src/capabilities.ts index 6a9d30af15ff4..9cefda83bb90d 100644 --- a/plugin-server/src/capabilities.ts +++ b/plugin-server/src/capabilities.ts @@ -24,6 +24,7 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin appManagementSingleton: true, preflightSchedules: true, cdpProcessedEvents: true, + cdpInternalEvents: true, cdpFunctionCallbacks: true, cdpCyclotronWorker: true, syncInlinePlugins: true, @@ -98,6 +99,11 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin cdpProcessedEvents: true, ...sharedCapabilities, } + case PluginServerMode.cdp_internal_events: + return { + cdpInternalEvents: true, + ...sharedCapabilities, + } case PluginServerMode.cdp_function_callbacks: return { cdpFunctionCallbacks: true, diff --git a/plugin-server/src/cdp/cdp-consumers.ts b/plugin-server/src/cdp/cdp-consumers.ts index f738b559a0523..a219cd6242864 100644 --- a/plugin-server/src/cdp/cdp-consumers.ts +++ b/plugin-server/src/cdp/cdp-consumers.ts @@ -7,6 +7,7 @@ import { buildIntegerMatcher } from '../config/config' import { KAFKA_APP_METRICS_2, KAFKA_CDP_FUNCTION_CALLBACKS, + KAFKA_CDP_INTERNAL_EVENTS, KAFKA_EVENTS_JSON, KAFKA_EVENTS_PLUGIN_INGESTION, KAFKA_LOG_ENTRIES, @@ -37,6 +38,7 @@ import { HogFunctionManager } from './hog-function-manager' import { HogMasker } from './hog-masker' import { HogWatcher, HogWatcherState } from './hog-watcher' import { CdpRedis, createCdpRedisPool } from './redis' +import { CdpInternalEventSchema } from './schema' import { HogFunctionInvocation, HogFunctionInvocationGlobals, @@ -46,9 +48,11 @@ import { HogFunctionLogEntrySerialized, HogFunctionMessageToProduce, HogFunctionType, + HogFunctionTypeType, HogHooksFetchResponse, } from './types' import { + convertInternalEventToHogFunctionInvocationGlobals, convertToCaptureEvent, convertToHogFunctionInvocationGlobals, createInvocation, @@ -81,6 +85,12 @@ const counterFunctionInvocation = new Counter({ labelNames: ['outcome'], // One of 'failed', 'succeeded', 'overflowed', 'disabled', 'filtered' }) +const counterParseError = new Counter({ + name: 'cdp_function_parse_error', + help: 'A function invocation was parsed with an error', + labelNames: ['error'], +}) + const gaugeBatchUtilization = new Gauge({ name: 'cdp_cyclotron_batch_utilization', help: 'Indicates how big batches are we are processing compared to the max batch size. Useful as a scaling metric', @@ -110,6 +120,7 @@ abstract class CdpConsumerBase { messagesToProduce: HogFunctionMessageToProduce[] = [] redis: CdpRedis + protected hogTypes: HogFunctionTypeType[] = [] protected kafkaProducer?: KafkaProducerWrapper protected abstract name: string @@ -363,7 +374,7 @@ abstract class CdpConsumerBase { public async start(): Promise { // NOTE: This is only for starting shared services await Promise.all([ - this.hogFunctionManager.start(), + this.hogFunctionManager.start(this.hogTypes), createKafkaProducerWrapper(this.hub).then((producer) => { this.kafkaProducer = producer this.kafkaProducer.producer.connect() @@ -397,6 +408,10 @@ abstract class CdpConsumerBase { */ export class CdpProcessedEventsConsumer extends CdpConsumerBase { protected name = 'CdpProcessedEventsConsumer' + protected topic = KAFKA_EVENTS_JSON + protected groupId = 'cdp-processed-events-consumer' + protected hogTypes: HogFunctionTypeType[] = ['destination'] + private cyclotronMatcher: ValueMatcher private cyclotronManager?: CyclotronManager @@ -559,8 +574,8 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase { } // This consumer always parses from kafka - public async _handleKafkaBatch(messages: Message[]): Promise { - const invocationGlobals = await this.runWithHeartbeat(() => + public async _parseKafkaBatch(messages: Message[]): Promise { + return await this.runWithHeartbeat(() => runInstrumentedFunction({ statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`, func: async () => { @@ -596,16 +611,17 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase { }, }) ) - - await this.processBatch(invocationGlobals) } public async start(): Promise { await super.start() await this.startKafkaConsumer({ - topic: KAFKA_EVENTS_JSON, - groupId: 'cdp-processed-events-consumer', - handleBatch: (messages) => this._handleKafkaBatch(messages), + topic: this.topic, + groupId: this.groupId, + handleBatch: async (messages) => { + const invocationGlobals = await this._parseKafkaBatch(messages) + await this.processBatch(invocationGlobals) + }, }) const shardDepthLimit = this.hub.CYCLOTRON_SHARD_DEPTH_LIMIT ?? 1000000 @@ -618,11 +634,66 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase { } } +/** + * This consumer handles incoming events from the main clickhouse topic + * Currently it produces to both kafka and Cyclotron based on the team + */ +export class CdpInternalEventsConsumer extends CdpProcessedEventsConsumer { + protected name = 'CdpInternalEventsConsumer' + protected topic = KAFKA_CDP_INTERNAL_EVENTS + protected groupId = 'cdp-internal-events-consumer' + protected hogTypes: HogFunctionTypeType[] = ['internal_destination'] + + // This consumer always parses from kafka + public async _parseKafkaBatch(messages: Message[]): Promise { + return await this.runWithHeartbeat(() => + runInstrumentedFunction({ + statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`, + func: async () => { + const events: HogFunctionInvocationGlobals[] = [] + await Promise.all( + messages.map(async (message) => { + try { + const kafkaEvent = JSON.parse(message.value!.toString()) as unknown + // This is the input stream from elsewhere so we want to do some proper validation + const event = CdpInternalEventSchema.parse(kafkaEvent) + + if (!this.hogFunctionManager.teamHasHogDestinations(event.team_id)) { + // No need to continue if the team doesn't have any functions + return + } + + const team = await this.hub.teamManager.fetchTeam(event.team_id) + if (!team) { + return + } + events.push( + convertInternalEventToHogFunctionInvocationGlobals( + event, + team, + this.hub.SITE_URL ?? 'http://localhost:8000' + ) + ) + } catch (e) { + status.error('Error parsing message', e) + counterParseError.labels({ error: e.message }).inc() + } + }) + ) + + return events + }, + }) + ) + } +} + /** * This consumer only deals with kafka messages and will eventually be replaced by the Cyclotron worker */ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { protected name = 'CdpFunctionCallbackConsumer' + protected hogTypes: HogFunctionTypeType[] = ['destination', 'internal_destination'] public async processBatch(invocations: HogFunctionInvocation[]): Promise { if (!invocations.length) { @@ -658,8 +729,8 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { await this.produceQueuedMessages() } - public async _handleKafkaBatch(messages: Message[]): Promise { - const events = await this.runWithHeartbeat(() => + public async _parseKafkaBatch(messages: Message[]): Promise { + return await this.runWithHeartbeat(() => runInstrumentedFunction({ statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`, func: async () => { @@ -727,8 +798,6 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { }, }) ) - - await this.processBatch(events) } public async start(): Promise { @@ -736,7 +805,10 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { await this.startKafkaConsumer({ topic: KAFKA_CDP_FUNCTION_CALLBACKS, groupId: 'cdp-function-callback-consumer', - handleBatch: (messages) => this._handleKafkaBatch(messages), + handleBatch: async (messages) => { + const invocations = await this._parseKafkaBatch(messages) + await this.processBatch(invocations) + }, }) } } @@ -749,6 +821,7 @@ export class CdpCyclotronWorker extends CdpConsumerBase { private cyclotronWorker?: CyclotronWorker private runningWorker: Promise | undefined protected queue: 'hog' | 'fetch' = 'hog' + protected hogTypes: HogFunctionTypeType[] = ['destination', 'internal_destination'] public async processBatch(invocations: HogFunctionInvocation[]): Promise { if (!invocations.length) { diff --git a/plugin-server/src/cdp/hog-executor.ts b/plugin-server/src/cdp/hog-executor.ts index 1b843ca04c513..e45536dc947da 100644 --- a/plugin-server/src/cdp/hog-executor.ts +++ b/plugin-server/src/cdp/hog-executor.ts @@ -126,7 +126,7 @@ export class HogExecutor { nonMatchingFunctions: HogFunctionType[] erroredFunctions: [HogFunctionType, string][] } { - const allFunctionsForTeam = this.hogFunctionManager.getTeamHogDestinations(globals.project.id) + const allFunctionsForTeam = this.hogFunctionManager.getTeamHogFunctions(globals.project.id) const filtersGlobals = convertToHogFunctionFilterGlobal(globals) const nonMatchingFunctions: HogFunctionType[] = [] @@ -333,39 +333,39 @@ export class HogExecutor { // We need to pass these in but they don't actually do anything as it is a sync exec fetch: async () => Promise.resolve(), }, - importBytecode: (module) => { - // TODO: more than one hardcoded module - if (module === 'provider/email') { - const provider = this.hogFunctionManager.getTeamHogEmailProvider(invocation.teamId) - if (!provider) { - throw new Error('No email provider configured') - } - try { - const providerGlobals = this.buildHogFunctionGlobals({ - id: '', - teamId: invocation.teamId, - hogFunction: provider, - globals: {} as any, - queue: 'hog', - timings: [], - priority: 0, - } satisfies HogFunctionInvocation) - - return { - bytecode: provider.bytecode, - globals: providerGlobals, - } - } catch (e) { - result.logs.push({ - level: 'error', - timestamp: DateTime.now(), - message: `Error building inputs: ${e}`, - }) - throw e - } - } - throw new Error(`Can't import unknown module: ${module}`) - }, + // importBytecode: (module) => { + // // TODO: more than one hardcoded module + // if (module === 'provider/email') { + // const provider = this.hogFunctionManager.getTeamHogEmailProvider(invocation.teamId) + // if (!provider) { + // throw new Error('No email provider configured') + // } + // try { + // const providerGlobals = this.buildHogFunctionGlobals({ + // id: '', + // teamId: invocation.teamId, + // hogFunction: provider, + // globals: {} as any, + // queue: 'hog', + // timings: [], + // priority: 0, + // } satisfies HogFunctionInvocation) + + // return { + // bytecode: provider.bytecode, + // globals: providerGlobals, + // } + // } catch (e) { + // result.logs.push({ + // level: 'error', + // timestamp: DateTime.now(), + // message: `Error building inputs: ${e}`, + // }) + // throw e + // } + // } + // throw new Error(`Can't import unknown module: ${module}`) + // }, functions: { print: (...args) => { hogLogs++ diff --git a/plugin-server/src/cdp/hog-function-manager.ts b/plugin-server/src/cdp/hog-function-manager.ts index c53ff71952ec2..aea3ffb9b10e5 100644 --- a/plugin-server/src/cdp/hog-function-manager.ts +++ b/plugin-server/src/cdp/hog-function-manager.ts @@ -5,7 +5,7 @@ import { Hub, Team } from '../types' import { PostgresUse } from '../utils/db/postgres' import { PubSub } from '../utils/pubsub' import { status } from '../utils/status' -import { HogFunctionType, IntegrationType } from './types' +import { HogFunctionType, HogFunctionTypeType, IntegrationType } from './types' type HogFunctionCache = { functions: Record @@ -26,14 +26,13 @@ const HOG_FUNCTION_FIELDS = [ 'type', ] -const RELOAD_HOG_FUNCTION_TYPES = ['destination', 'email'] - export class HogFunctionManager { private started: boolean private ready: boolean private cache: HogFunctionCache private pubSub: PubSub private refreshJob?: schedule.Job + private hogTypes: HogFunctionTypeType[] = [] constructor(private hub: Hub) { this.started = false @@ -60,7 +59,8 @@ export class HogFunctionManager { }) } - public async start(): Promise { + public async start(hogTypes: HogFunctionTypeType[]): Promise { + this.hogTypes = hogTypes // TRICKY - when running with individual capabilities, this won't run twice but locally or as a complete service it will... if (this.started) { return @@ -96,14 +96,6 @@ export class HogFunctionManager { .filter((x) => !!x) as HogFunctionType[] } - public getTeamHogDestinations(teamId: Team['id']): HogFunctionType[] { - return this.getTeamHogFunctions(teamId).filter((x) => x.type === 'destination' || !x.type) - } - - public getTeamHogEmailProvider(teamId: Team['id']): HogFunctionType | undefined { - return this.getTeamHogFunctions(teamId).find((x) => x.type === 'email') - } - public getHogFunction(id: HogFunctionType['id']): HogFunctionType | undefined { if (!this.ready) { throw new Error('HogFunctionManager is not ready! Run HogFunctionManager.start() before this') @@ -124,7 +116,7 @@ export class HogFunctionManager { } public teamHasHogDestinations(teamId: Team['id']): boolean { - return !!Object.keys(this.getTeamHogDestinations(teamId)).length + return !!Object.keys(this.getTeamHogFunctions(teamId)).length } public async reloadAllHogFunctions(): Promise { @@ -134,9 +126,9 @@ export class HogFunctionManager { ` SELECT ${HOG_FUNCTION_FIELDS.join(', ')} FROM posthog_hogfunction - WHERE deleted = FALSE AND enabled = TRUE AND (type is NULL or type = ANY($1)) + WHERE deleted = FALSE AND enabled = TRUE AND type = ANY($1) `, - [RELOAD_HOG_FUNCTION_TYPES], + [this.hogTypes], 'fetchAllHogFunctions' ) ).rows @@ -167,8 +159,8 @@ export class HogFunctionManager { PostgresUse.COMMON_READ, `SELECT ${HOG_FUNCTION_FIELDS.join(', ')} FROM posthog_hogfunction - WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE`, - [ids], + WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE AND type = ANY($2)`, + [ids, this.hogTypes], 'fetchEnabledHogFunctions' ) ).rows @@ -218,6 +210,11 @@ export class HogFunctionManager { items.forEach((item) => { const encryptedInputsString = item.encrypted_inputs as string | undefined + if (!Array.isArray(item.inputs_schema)) { + // NOTE: The sql lib can sometimes return an empty object instead of an empty array + item.inputs_schema = [] + } + if (encryptedInputsString) { try { const decrypted = this.hub.encryptedFields.decrypt(encryptedInputsString || '') diff --git a/plugin-server/src/cdp/schema.ts b/plugin-server/src/cdp/schema.ts new file mode 100644 index 0000000000000..35dbf01e5e3f3 --- /dev/null +++ b/plugin-server/src/cdp/schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +export const CdpInternalEventSchema = z.object({ + team_id: z.number(), + event: z.object({ + uuid: z.string(), + event: z.string(), + // In this context distinct_id should be whatever we want to use if doing follow up things (like tracking a standard event) + distinct_id: z.string(), + properties: z.record(z.any()), + timestamp: z.string(), + url: z.string().optional().nullable(), + }), + // Person may be a event-style person or an org member + person: z + .object({ + id: z.string(), + properties: z.record(z.any()), + name: z.string().optional().nullable(), + url: z.string().optional().nullable(), + }) + .optional() + .nullable(), +}) + +// Infer the TypeScript type +export type CdpInternalEvent = z.infer diff --git a/plugin-server/src/cdp/types.ts b/plugin-server/src/cdp/types.ts index dfe0464a1f9ec..9f7b8bba7433f 100644 --- a/plugin-server/src/cdp/types.ts +++ b/plugin-server/src/cdp/types.ts @@ -275,7 +275,16 @@ export type HogFunctionInputSchemaType = { requiredScopes?: string } -export type HogFunctionTypeType = 'destination' | 'email' | 'sms' | 'push' | 'activity' | 'alert' | 'broadcast' +export type HogFunctionTypeType = + | 'destination' + | 'transformation' + | 'internal_destination' + | 'email' + | 'sms' + | 'push' + | 'activity' + | 'alert' + | 'broadcast' export type HogFunctionType = { id: string diff --git a/plugin-server/src/cdp/utils.ts b/plugin-server/src/cdp/utils.ts index f4c09b602a514..73909ec9e5a7b 100644 --- a/plugin-server/src/cdp/utils.ts +++ b/plugin-server/src/cdp/utils.ts @@ -11,6 +11,7 @@ import { RawClickHouseEvent, Team, TimestampFormat } from '../types' import { safeClickhouseString } from '../utils/db/utils' import { status } from '../utils/status' import { castTimestampOrNow, clickHouseTimestampToISO, UUIDT } from '../utils/utils' +import { CdpInternalEvent } from './schema' import { HogFunctionCapturedEvent, HogFunctionFilterGlobals, @@ -90,6 +91,47 @@ export function convertToHogFunctionInvocationGlobals( return context } +export function convertInternalEventToHogFunctionInvocationGlobals( + data: CdpInternalEvent, + team: Team, + siteUrl: string +): HogFunctionInvocationGlobals { + const projectUrl = `${siteUrl}/project/${team.id}` + + let person: HogFunctionInvocationGlobals['person'] + + if (data.person) { + const personDisplayName = getPersonDisplayName(team, data.event.distinct_id, data.person.properties) + + person = { + id: data.person.id, + properties: data.person.properties, + name: personDisplayName, + url: data.person.url ?? '', + } + } + + const context: HogFunctionInvocationGlobals = { + project: { + id: team.id, + name: team.name, + url: projectUrl, + }, + event: { + uuid: data.event.uuid, + event: data.event.event, + elements_chain: '', // Not applicable but left here for compatibility + distinct_id: data.event.distinct_id, + properties: data.event.properties, + timestamp: data.event.timestamp, + url: data.event.url ?? '', + }, + person, + } + + return context +} + function getElementsChainHref(elementsChain: string): string { // Adapted from SQL: extract(elements_chain, '(?::|\")href="(.*?)"'), const hrefRegex = new RE2(/(?::|")href="(.*?)"/) diff --git a/plugin-server/src/config/kafka-topics.ts b/plugin-server/src/config/kafka-topics.ts index 8610bf8f0b819..79959a951e9a7 100644 --- a/plugin-server/src/config/kafka-topics.ts +++ b/plugin-server/src/config/kafka-topics.ts @@ -45,6 +45,7 @@ export const KAFKA_LOG_ENTRIES = `${prefix}log_entries${suffix}` // CDP topics export const KAFKA_CDP_FUNCTION_CALLBACKS = `${prefix}cdp_function_callbacks${suffix}` export const KAFKA_CDP_FUNCTION_OVERFLOW = `${prefix}cdp_function_overflow${suffix}` +export const KAFKA_CDP_INTERNAL_EVENTS = `${prefix}cdp_internal_events${suffix}` // Error tracking topics export const KAFKA_EXCEPTION_SYMBOLIFICATION_EVENTS = `${prefix}exception_symbolification_events${suffix}` diff --git a/plugin-server/src/kafka/batch-consumer.ts b/plugin-server/src/kafka/batch-consumer.ts index 2f1082f2aa5b4..66f4eac0ea0f7 100644 --- a/plugin-server/src/kafka/batch-consumer.ts +++ b/plugin-server/src/kafka/batch-consumer.ts @@ -249,6 +249,8 @@ export const startBatchConsumer = async ({ let batchesProcessed = 0 const statusLogInterval = setInterval(() => { status.info('🔁', 'main_loop', { + groupId, + topic, messagesPerSecond: messagesProcessed / (STATUS_LOG_INTERVAL_MS / 1000), batchesProcessed: batchesProcessed, lastHeartbeatTime: new Date(lastHeartbeatTime).toISOString(), diff --git a/plugin-server/src/main/pluginsServer.ts b/plugin-server/src/main/pluginsServer.ts index d61f3bb5e0510..ac482ca21a6fa 100644 --- a/plugin-server/src/main/pluginsServer.ts +++ b/plugin-server/src/main/pluginsServer.ts @@ -15,6 +15,7 @@ import { CdpCyclotronWorker, CdpCyclotronWorkerFetch, CdpFunctionCallbackConsumer, + CdpInternalEventsConsumer, CdpProcessedEventsConsumer, } from '../cdp/cdp-consumers' import { defaultConfig } from '../config/config' @@ -451,6 +452,13 @@ export async function startPluginsServer( services.push(consumer.service) } + if (capabilities.cdpInternalEvents) { + const hub = await setupHub() + const consumer = new CdpInternalEventsConsumer(hub) + await consumer.start() + services.push(consumer.service) + } + if (capabilities.cdpFunctionCallbacks) { const hub = await setupHub() const consumer = new CdpFunctionCallbackConsumer(hub) diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts index 1263a896d04a3..390f7d8d3a5a5 100644 --- a/plugin-server/src/types.ts +++ b/plugin-server/src/types.ts @@ -84,6 +84,7 @@ export enum PluginServerMode { recordings_blob_ingestion = 'recordings-blob-ingestion', recordings_blob_ingestion_overflow = 'recordings-blob-ingestion-overflow', cdp_processed_events = 'cdp-processed-events', + cdp_internal_events = 'cdp-internal-events', cdp_function_callbacks = 'cdp-function-callbacks', cdp_cyclotron_worker = 'cdp-cyclotron-worker', functional_tests = 'functional-tests', @@ -358,6 +359,7 @@ export interface PluginServerCapabilities { sessionRecordingBlobIngestion?: boolean sessionRecordingBlobOverflowIngestion?: boolean cdpProcessedEvents?: boolean + cdpInternalEvents?: boolean cdpFunctionCallbacks?: boolean cdpCyclotronWorker?: boolean appManagementSingleton?: boolean diff --git a/plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts b/plugin-server/tests/cdp/cdp-events-consumer.test.ts similarity index 95% rename from plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts rename to plugin-server/tests/cdp/cdp-events-consumer.test.ts index c559a4240fca4..db400a56672b3 100644 --- a/plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts +++ b/plugin-server/tests/cdp/cdp-events-consumer.test.ts @@ -1,4 +1,4 @@ -import { CdpProcessedEventsConsumer } from '../../src/cdp/cdp-consumers' +import { CdpInternalEventsConsumer, CdpProcessedEventsConsumer } from '../../src/cdp/cdp-consumers' import { HogWatcherState } from '../../src/cdp/hog-watcher' import { HogFunctionInvocationGlobals, HogFunctionType } from '../../src/cdp/types' import { Hub, Team } from '../../src/types' @@ -74,13 +74,22 @@ const decodeAllKafkaMessages = (): any[] => { return mockProducer.produce.mock.calls.map((x) => decodeKafkaMessage(x[0])) } -describe('CDP Processed Events Consumer', () => { - let processor: CdpProcessedEventsConsumer +/** + * NOTE: The internal and normal events consumers are very similar so we can test them together + */ +describe.each([ + [CdpProcessedEventsConsumer.name, CdpProcessedEventsConsumer, 'destination' as const], + [CdpInternalEventsConsumer.name, CdpInternalEventsConsumer, 'internal_destination' as const], +])('%s', (_name, Consumer, hogType) => { + let processor: CdpProcessedEventsConsumer | CdpInternalEventsConsumer let hub: Hub let team: Team const insertHogFunction = async (hogFunction: Partial) => { - const item = await _insertHogFunction(hub.postgres, team.id, hogFunction) + const item = await _insertHogFunction(hub.postgres, team.id, { + ...hogFunction, + type: hogType, + }) // Trigger the reload that django would do await processor.hogFunctionManager.reloadAllHogFunctions() return item @@ -91,7 +100,7 @@ describe('CDP Processed Events Consumer', () => { hub = await createHub() team = await getFirstTeam(hub) - processor = new CdpProcessedEventsConsumer(hub) + processor = new Consumer(hub) await processor.start() mockFetch.mockClear() diff --git a/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts b/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts new file mode 100644 index 0000000000000..995b2eeae2667 --- /dev/null +++ b/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts @@ -0,0 +1,99 @@ +import { CdpInternalEventsConsumer } from '../../src/cdp/cdp-consumers' +import { HogFunctionType } from '../../src/cdp/types' +import { Hub, Team } from '../../src/types' +import { closeHub, createHub } from '../../src/utils/db/hub' +import { getFirstTeam, resetTestDatabase } from '../helpers/sql' +import { HOG_EXAMPLES, HOG_FILTERS_EXAMPLES, HOG_INPUTS_EXAMPLES } from './examples' +import { createInternalEvent, createKafkaMessage, insertHogFunction as _insertHogFunction } from './fixtures' + +describe('CDP Internal Events Consumer', () => { + let processor: CdpInternalEventsConsumer + let hub: Hub + let team: Team + + const insertHogFunction = async (hogFunction: Partial) => { + const item = await _insertHogFunction(hub.postgres, team.id, hogFunction) + // Trigger the reload that django would do + await processor.hogFunctionManager.reloadAllHogFunctions() + return item + } + + beforeEach(async () => { + await resetTestDatabase() + hub = await createHub() + team = await getFirstTeam(hub) + + processor = new CdpInternalEventsConsumer(hub) + // Speed hack as we don't need all of kafka to be started for this test + await processor.hogFunctionManager.start(processor['hogTypes']) + }) + + afterEach(async () => { + jest.setTimeout(1000) + await closeHub(hub) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + describe('_handleKafkaBatch', () => { + it('should ignore invalid message', async () => { + const events = await processor._parseKafkaBatch([createKafkaMessage({})]) + expect(events).toHaveLength(0) + }) + + it('should ignore message with no team', async () => { + const events = await processor._parseKafkaBatch([createKafkaMessage(createInternalEvent(999999, {}))]) + expect(events).toHaveLength(0) + }) + + describe('with an existing team and hog function', () => { + beforeEach(async () => { + await insertHogFunction({ + ...HOG_EXAMPLES.simple_fetch, + ...HOG_INPUTS_EXAMPLES.simple_fetch, + ...HOG_FILTERS_EXAMPLES.no_filters, + type: 'internal_destination', + }) + }) + + it('should ignore invalid payloads', async () => { + const events = await processor._parseKafkaBatch([ + createKafkaMessage( + createInternalEvent(team.id, { + event: 'WRONG' as any, + }) + ), + ]) + expect(events).toHaveLength(0) + }) + + it('should parse a valid message with an existing team and hog function ', async () => { + const event = createInternalEvent(team.id, {}) + event.event.timestamp = '2024-12-18T15:06:23.545Z' + event.event.uuid = 'b6da2f33-ba54-4550-9773-50d3278ad61f' + + const events = await processor._parseKafkaBatch([createKafkaMessage(event)]) + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + event: { + distinct_id: 'distinct_id', + elements_chain: '', + event: '$pageview', + properties: {}, + timestamp: '2024-12-18T15:06:23.545Z', + url: '', + uuid: 'b6da2f33-ba54-4550-9773-50d3278ad61f', + }, + person: undefined, + project: { + id: 2, + name: 'TEST PROJECT', + url: 'http://localhost:8000/project/2', + }, + }) + }) + }) + }) +}) diff --git a/plugin-server/tests/cdp/fixtures.ts b/plugin-server/tests/cdp/fixtures.ts index e34920fdd981e..79c56798866db 100644 --- a/plugin-server/tests/cdp/fixtures.ts +++ b/plugin-server/tests/cdp/fixtures.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto' import { Message } from 'node-rdkafka' +import { CdpInternalEvent } from '../../src/cdp/schema' import { HogFunctionInvocation, HogFunctionInvocationGlobals, @@ -60,7 +61,7 @@ export const createIncomingEvent = (teamId: number, data: Partial = {}): Message => { +export const createKafkaMessage = (event: any, overrides: Partial = {}): Message => { return { partition: 1, topic: 'test', @@ -72,6 +73,20 @@ export const createMessage = (event: RawClickHouseEvent, overrides: Partial): CdpInternalEvent => { + return { + team_id: teamId, + event: { + timestamp: new Date().toISOString(), + properties: {}, + uuid: randomUUID(), + event: '$pageview', + distinct_id: 'distinct_id', + }, + ...data, + } +} + export const insertHogFunction = async ( postgres: PostgresRouter, team_id: Team['id'], diff --git a/plugin-server/tests/cdp/hog-executor.test.ts b/plugin-server/tests/cdp/hog-executor.test.ts index aeacc1067d0f4..99feb53d62207 100644 --- a/plugin-server/tests/cdp/hog-executor.test.ts +++ b/plugin-server/tests/cdp/hog-executor.test.ts @@ -48,9 +48,8 @@ describe('Hog Executor', () => { const mockFunctionManager = { reloadAllHogFunctions: jest.fn(), - getTeamHogDestinations: jest.fn(), + getTeamHogFunctions: jest.fn(), getTeamHogFunction: jest.fn(), - getTeamHogEmailProvider: jest.fn(), } beforeEach(async () => { @@ -70,7 +69,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.no_filters, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([hogFunction]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([hogFunction]) mockFunctionManager.getTeamHogFunction.mockReturnValue(hogFunction) }) @@ -254,7 +253,7 @@ describe('Hog Executor', () => { }) }) - describe('email provider functions', () => { + describe.skip('email provider functions', () => { let hogFunction: HogFunctionType let providerFunction: HogFunctionType beforeEach(() => { @@ -270,9 +269,9 @@ describe('Hog Executor', () => { ...HOG_INPUTS_EXAMPLES.email, ...HOG_FILTERS_EXAMPLES.no_filters, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([hogFunction, providerFunction]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([hogFunction, providerFunction]) mockFunctionManager.getTeamHogFunction.mockReturnValue(hogFunction) - mockFunctionManager.getTeamHogEmailProvider.mockReturnValue(providerFunction) + // mockFunctionManager.getTeamHogEmailProvider.mockReturnValue(providerFunction) }) it('can execute an invocation', () => { @@ -326,7 +325,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.pageview_or_autocapture_filter, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const resultsShouldntMatch = executor.findMatchingFunctions(createHogExecutionGlobals({ groups: {} })) expect(resultsShouldntMatch.matchingFunctions).toHaveLength(0) @@ -356,7 +355,7 @@ describe('Hog Executor', () => { ...HOG_INPUTS_EXAMPLES.simple_fetch, ...HOG_FILTERS_EXAMPLES.broken_filters, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const resultsShouldMatch = executor.findMatchingFunctions( createHogExecutionGlobals({ groups: {}, @@ -388,7 +387,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.elements_text_filter, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const elementsChain = (buttonText: string) => `span.LemonButton__content:attr__class="LemonButton__content"nth-child="2"nth-of-type="2"text="${buttonText}";span.LemonButton__chrome:attr__class="LemonButton__chrome"nth-child="1"nth-of-type="1";button.LemonButton.LemonButton--has-icon.LemonButton--secondary.LemonButton--status-default:attr__class="LemonButton LemonButton--secondary LemonButton--status-default LemonButton--has-icon"attr__type="button"nth-child="1"nth-of-type="1"text="${buttonText}";div.flex.gap-4.items-center:attr__class="flex gap-4 items-center"nth-child="1"nth-of-type="1";div.flex.flex-wrap.gap-4.justify-between:attr__class="flex gap-4 justify-between flex-wrap"nth-child="3"nth-of-type="3";div.flex.flex-1.flex-col.gap-4.h-full.relative.w-full:attr__class="relative w-full flex flex-col gap-4 flex-1 h-full"nth-child="1"nth-of-type="1";div.LemonTabs__content:attr__class="LemonTabs__content"nth-child="2"nth-of-type="1";div.LemonTabs.LemonTabs--medium:attr__class="LemonTabs LemonTabs--medium"attr__style="--lemon-tabs-slider-width: 48px; --lemon-tabs-slider-offset: 0px;"nth-child="1"nth-of-type="1";div.Navigation3000__scene:attr__class="Navigation3000__scene"nth-child="2"nth-of-type="2";main:nth-child="2"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"` @@ -438,7 +437,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.elements_href_filter, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const elementsChain = (link: string) => `span.LemonButton__content:attr__class="LemonButton__content"attr__href="${link}"href="${link}"nth-child="2"nth-of-type="2"text="Activity";span.LemonButton__chrome:attr__class="LemonButton__chrome"nth-child="1"nth-of-type="1";a.LemonButton.LemonButton--full-width.LemonButton--has-icon.LemonButton--secondary.LemonButton--status-alt.Link.NavbarButton:attr__class="Link LemonButton LemonButton--secondary LemonButton--status-alt LemonButton--full-width LemonButton--has-icon NavbarButton"attr__data-attr="menu-item-activity"attr__href="${link}"href="${link}"nth-child="1"nth-of-type="1"text="Activity";li.w-full:attr__class="w-full"nth-child="6"nth-of-type="6";ul:nth-child="1"nth-of-type="1";div.Navbar3000__top.ScrollableShadows__inner:attr__class="ScrollableShadows__inner Navbar3000__top"nth-child="1"nth-of-type="1";div.ScrollableShadows.ScrollableShadows--vertical:attr__class="ScrollableShadows ScrollableShadows--vertical"nth-child="1"nth-of-type="1";div.Navbar3000__content:attr__class="Navbar3000__content"nth-child="1"nth-of-type="1";nav.Navbar3000:attr__class="Navbar3000"nth-child="1"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"` @@ -488,7 +487,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.elements_tag_and_id_filter, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const elementsChain = (id: string) => `a.Link.font-semibold.text-text-3000.text-xl:attr__class="Link font-semibold text-xl text-text-3000"attr__href="/project/1/dashboard/1"attr__id="${id}"attr_id="${id}"href="/project/1/dashboard/1"nth-child="1"nth-of-type="1"text="My App Dashboard";div.ProjectHomepage__dashboardheader__title:attr__class="ProjectHomepage__dashboardheader__title"nth-child="1"nth-of-type="1";div.ProjectHomepage__dashboardheader:attr__class="ProjectHomepage__dashboardheader"nth-child="2"nth-of-type="2";div.ProjectHomepage:attr__class="ProjectHomepage"nth-child="1"nth-of-type="1";div.Navigation3000__scene:attr__class="Navigation3000__scene"nth-child="2"nth-of-type="2";main:nth-child="2"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"` @@ -579,7 +578,7 @@ describe('Hog Executor', () => { ...HOG_FILTERS_EXAMPLES.no_filters, }) - mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn]) + mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn]) const result = executor.execute(createInvocation(fn)) expect(result.error).toContain('Execution timed out after 0.1 seconds. Performed ') diff --git a/plugin-server/tests/cdp/hog-function-manager.test.ts b/plugin-server/tests/cdp/hog-function-manager.test.ts index d5d5b575dd3ec..752927c3d53dd 100644 --- a/plugin-server/tests/cdp/hog-function-manager.test.ts +++ b/plugin-server/tests/cdp/hog-function-manager.test.ts @@ -62,22 +62,31 @@ describe('HogFunctionManager', () => { hogFunctions.push( await insertHogFunction(hub.postgres, teamId1, { - name: 'Email Provider team 1', - type: 'email', - inputs_schema: [ - { - type: 'email', - key: 'message', - }, - ], - inputs: { - email: { - value: { from: 'me@a.com', to: 'you@b.com', subject: 'subject', html: 'text' }, - }, - }, + name: 'Test Hog Function team 1 - transformation', + type: 'transformation', + inputs_schema: [], + inputs: {}, }) ) + // hogFunctions.push( + // await insertHogFunction(hub.postgres, teamId1, { + // name: 'Email Provider team 1', + // type: 'email', + // inputs_schema: [ + // { + // type: 'email', + // key: 'message', + // }, + // ], + // inputs: { + // email: { + // value: { from: 'me@a.com', to: 'you@b.com', subject: 'subject', html: 'text' }, + // }, + // }, + // }) + // ) + hogFunctions.push( await insertHogFunction(hub.postgres, teamId2, { name: 'Test Hog Function team 2', @@ -98,7 +107,7 @@ describe('HogFunctionManager', () => { }) ) - await manager.start() + await manager.start(['destination']) }) afterEach(async () => { @@ -107,7 +116,7 @@ describe('HogFunctionManager', () => { }) it('returns the hog functions', async () => { - let items = manager.getTeamHogDestinations(teamId1) + let items = manager.getTeamHogFunctions(teamId1) expect(items).toEqual([ { @@ -142,13 +151,6 @@ describe('HogFunctionManager', () => { }, ]) - const allFunctions = manager.getTeamHogFunctions(teamId1) - expect(allFunctions.length).toEqual(2) - expect(allFunctions.map((f) => f.type).sort()).toEqual(['destination', 'email']) - - const emailProvider = manager.getTeamHogEmailProvider(teamId1) - expect(emailProvider.type).toEqual('email') - await hub.db.postgres.query( PostgresUse.COMMON_WRITE, `UPDATE posthog_hogfunction SET name='Test Hog Function team 1 updated' WHERE id = $1`, @@ -159,7 +161,7 @@ describe('HogFunctionManager', () => { // This is normally dispatched by django await manager.reloadHogFunctions(teamId1, [hogFunctions[0].id]) - items = manager.getTeamHogDestinations(teamId1) + items = manager.getTeamHogFunctions(teamId1) expect(items).toMatchObject([ { @@ -169,8 +171,21 @@ describe('HogFunctionManager', () => { ]) }) + it('filters hog functions by type', async () => { + manager['hogTypes'] = ['transformation'] + await manager.reloadAllHogFunctions() + expect(manager.getTeamHogFunctions(teamId1).length).toEqual(1) + expect(manager.getTeamHogFunctions(teamId1)[0].type).toEqual('transformation') + + manager['hogTypes'] = ['transformation', 'destination'] + await manager.reloadAllHogFunctions() + expect(manager.getTeamHogFunctions(teamId1).length).toEqual(2) + expect(manager.getTeamHogFunctions(teamId1)[0].type).toEqual('destination') + expect(manager.getTeamHogFunctions(teamId1)[1].type).toEqual('transformation') + }) + it('removes disabled functions', async () => { - let items = manager.getTeamHogDestinations(teamId1) + let items = manager.getTeamHogFunctions(teamId1) expect(items).toMatchObject([ { @@ -188,14 +203,14 @@ describe('HogFunctionManager', () => { // This is normally dispatched by django await manager.reloadHogFunctions(teamId1, [hogFunctions[0].id]) - items = manager.getTeamHogDestinations(teamId1) + items = manager.getTeamHogFunctions(teamId1) expect(items).toEqual([]) }) it('enriches integration inputs if found and belonging to the team', () => { - const function1Inputs = manager.getTeamHogDestinations(teamId1)[0].inputs - const function2Inputs = manager.getTeamHogDestinations(teamId2)[0].inputs + const function1Inputs = manager.getTeamHogFunctions(teamId1)[0].inputs + const function2Inputs = manager.getTeamHogFunctions(teamId2)[0].inputs // Only the right team gets the integration inputs enriched expect(function1Inputs).toEqual({ diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py index 38641031167ad..1f8151e161bec 100644 --- a/posthog/api/hog_function_template.py +++ b/posthog/api/hog_function_template.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.exceptions import NotFound -from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES +from posthog.cdp.templates import HOG_FUNCTION_SUB_TEMPLATES, HOG_FUNCTION_TEMPLATES, ALL_HOG_FUNCTION_TEMPLATES_BY_ID from posthog.cdp.templates.hog_function_template import ( HogFunctionMapping, HogFunctionMappingTemplate, @@ -51,17 +51,33 @@ class PublicHogFunctionTemplateViewSet(viewsets.GenericViewSet): def list(self, request: Request, *args, **kwargs): types = ["destination"] + + sub_template_id = request.GET.get("sub_template_id") + if "type" in request.GET: types = [self.request.GET.get("type", "destination")] elif "types" in request.GET: types = self.request.GET.get("types", "destination").split(",") - templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type in types] - page = self.paginate_queryset(templates) + + templates_list = HOG_FUNCTION_SUB_TEMPLATES if sub_template_id else HOG_FUNCTION_TEMPLATES + + matching_templates = [] + + for template in templates_list: + if template.type not in types: + continue + + if sub_template_id and sub_template_id not in template.id: + continue + + matching_templates.append(template) + + page = self.paginate_queryset(matching_templates) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) def retrieve(self, request: Request, *args, **kwargs): - item = next((item for item in HOG_FUNCTION_TEMPLATES if item.id == kwargs["pk"]), None) + item = ALL_HOG_FUNCTION_TEMPLATES_BY_ID.get(kwargs["pk"], None) if not item: raise NotFound(f"Template with id {kwargs['pk']} not found.") diff --git a/posthog/api/test/batch_exports/fixtures.py b/posthog/api/test/batch_exports/fixtures.py new file mode 100644 index 0000000000000..1c13b43d22db7 --- /dev/null +++ b/posthog/api/test/batch_exports/fixtures.py @@ -0,0 +1,13 @@ +from posthog.api.test.test_organization import create_organization as create_organization_base +from posthog.constants import AvailableFeature +from posthog.models import Organization + + +def create_organization(name: str, has_data_pipelines_feature: bool = True) -> Organization: + organization = create_organization_base(name) + if has_data_pipelines_feature: + organization.available_product_features = [ + {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES} + ] + organization.save() + return organization diff --git a/posthog/api/test/batch_exports/test_backfill.py b/posthog/api/test/batch_exports/test_backfill.py index e8dd4a29e81db..797db7ea93fd1 100644 --- a/posthog/api/test/batch_exports/test_backfill.py +++ b/posthog/api/test/batch_exports/test_backfill.py @@ -10,7 +10,7 @@ backfill_batch_export, create_batch_export_ok, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.temporal.common.client import sync_connect diff --git a/posthog/api/test/batch_exports/test_create.py b/posthog/api/test/batch_exports/test_create.py index 28524c7d99513..676935e56b597 100644 --- a/posthog/api/test/batch_exports/test_create.py +++ b/posthog/api/test/batch_exports/test_create.py @@ -9,8 +9,8 @@ from rest_framework import status from posthog.api.test.batch_exports.conftest import describe_schedule, start_test_worker +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.batch_exports.operations import create_batch_export -from posthog.api.test.test_organization import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.batch_exports.models import BatchExport diff --git a/posthog/api/test/batch_exports/test_delete.py b/posthog/api/test/batch_exports/test_delete.py index 697415a4525cb..f5b303538707a 100644 --- a/posthog/api/test/batch_exports/test_delete.py +++ b/posthog/api/test/batch_exports/test_delete.py @@ -8,6 +8,7 @@ from temporalio.service import RPCError from posthog.api.test.batch_exports.conftest import start_test_worker +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.batch_exports.operations import ( backfill_batch_export_ok, create_batch_export_ok, @@ -15,7 +16,6 @@ delete_batch_export_ok, get_batch_export, ) -from posthog.api.test.test_organization import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.temporal.common.client import sync_connect diff --git a/posthog/api/test/batch_exports/test_get.py b/posthog/api/test/batch_exports/test_get.py index f5e0060bc67b5..4e5adfd5a5d63 100644 --- a/posthog/api/test/batch_exports/test_get.py +++ b/posthog/api/test/batch_exports/test_get.py @@ -7,7 +7,7 @@ create_batch_export_ok, get_batch_export, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.temporal.common.client import sync_connect diff --git a/posthog/api/test/batch_exports/test_list.py b/posthog/api/test/batch_exports/test_list.py index 7796964228fe5..579e3469b5b16 100644 --- a/posthog/api/test/batch_exports/test_list.py +++ b/posthog/api/test/batch_exports/test_list.py @@ -6,7 +6,7 @@ delete_batch_export_ok, list_batch_exports_ok, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user diff --git a/posthog/api/test/batch_exports/test_pause.py b/posthog/api/test/batch_exports/test_pause.py index 33c32f1a200bc..97eb8f90a1809 100644 --- a/posthog/api/test/batch_exports/test_pause.py +++ b/posthog/api/test/batch_exports/test_pause.py @@ -14,7 +14,7 @@ unpause_batch_export, unpause_batch_export_ok, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.batch_exports.service import batch_export_delete_schedule diff --git a/posthog/api/test/batch_exports/test_runs.py b/posthog/api/test/batch_exports/test_runs.py index 0c58b717be6f2..75be430e4b07c 100644 --- a/posthog/api/test/batch_exports/test_runs.py +++ b/posthog/api/test/batch_exports/test_runs.py @@ -15,7 +15,7 @@ get_batch_export_runs, get_batch_export_runs_ok, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.temporal.common.client import sync_connect diff --git a/posthog/api/test/batch_exports/test_update.py b/posthog/api/test/batch_exports/test_update.py index ec794d85484ee..80860ab153057 100644 --- a/posthog/api/test/batch_exports/test_update.py +++ b/posthog/api/test/batch_exports/test_update.py @@ -14,7 +14,7 @@ patch_batch_export, put_batch_export, ) -from posthog.api.test.test_organization import create_organization +from posthog.api.test.batch_exports.fixtures import create_organization from posthog.api.test.test_team import create_team from posthog.api.test.test_user import create_user from posthog.batch_exports.service import sync_batch_export diff --git a/posthog/api/test/test_app_metrics.py b/posthog/api/test/test_app_metrics.py index 67b9a0a42eaa5..d11a27a394b76 100644 --- a/posthog/api/test/test_app_metrics.py +++ b/posthog/api/test/test_app_metrics.py @@ -8,6 +8,7 @@ from posthog.api.test.batch_exports.conftest import start_test_worker from posthog.api.test.batch_exports.operations import create_batch_export_ok from posthog.batch_exports.models import BatchExportRun +from posthog.constants import AvailableFeature from posthog.models.activity_logging.activity_log import Detail, Trigger, log_activity from posthog.models.plugin import Plugin, PluginConfig from posthog.models.utils import UUIDT @@ -27,6 +28,11 @@ def setUp(self): self.plugin = Plugin.objects.create(organization=self.organization) self.plugin_config = PluginConfig.objects.create(plugin=self.plugin, team=self.team, enabled=True, order=1) + self.organization.available_product_features = [ + {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES} + ] + self.organization.save() + def test_retrieve(self): create_app_metric( team_id=self.team.pk, diff --git a/posthog/api/test/test_hog_function_templates.py b/posthog/api/test/test_hog_function_templates.py index 7a9b5150f5acd..4a34e36f88235 100644 --- a/posthog/api/test/test_hog_function_templates.py +++ b/posthog/api/test/test_hog_function_templates.py @@ -1,6 +1,8 @@ from unittest.mock import ANY +from inline_snapshot import snapshot from rest_framework import status +from posthog.cdp.templates.hog_function_template import derive_sub_templates from posthog.test.base import APIBaseTest, ClickhouseTestMixin, QueryMatchingTest from posthog.cdp.templates.slack.template_slack import template @@ -23,6 +25,22 @@ } +class TestHogFunctionTemplatesMixin(APIBaseTest): + def test_derive_sub_templates(self): + # One sanity check test (rather than all of them) + sub_templates = derive_sub_templates([template]) + + # check overridden params + assert sub_templates[0].inputs_schema[-1]["key"] == "text" + assert sub_templates[0].inputs_schema[-1]["default"] == snapshot( + "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'" + ) + assert sub_templates[0].filters == snapshot( + {"events": [{"id": "$feature_enrollment_update", "type": "events"}]} + ) + assert sub_templates[0].type == "destination" + + class TestHogFunctionTemplates(ClickhouseTestMixin, APIBaseTest, QueryMatchingTest): def test_list_function_templates(self): response = self.client.get("/api/projects/@current/hog_function_templates/") @@ -48,6 +66,29 @@ def test_filter_function_templates(self): response5 = self.client.get("/api/projects/@current/hog_function_templates/?types=site_destination,destination") assert len(response5.json()["results"]) > 0 + def test_filter_sub_templates(self): + response1 = self.client.get( + "/api/projects/@current/hog_function_templates/?type=internal_destination&sub_template_id=activity-log" + ) + assert response1.status_code == status.HTTP_200_OK, response1.json() + assert len(response1.json()["results"]) > 0 + + template = response1.json()["results"][0] + + assert template["sub_templates"] is None + assert template["type"] == "internal_destination" + assert template["id"] == "template-slack-activity-log" + + def test_retrieve_function_template(self): + response = self.client.get("/api/projects/@current/hog_function_templates/template-slack") + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["id"] == "template-slack" + + def test_retrieve_function_sub_template(self): + response = self.client.get("/api/projects/@current/hog_function_templates/template-slack-activity-log") + assert response.status_code == status.HTTP_200_OK, response.json() + assert response.json()["id"] == "template-slack-activity-log" + def test_public_list_function_templates(self): self.client.logout() response = self.client.get("/api/public_hog_function_templates/") diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py index 1da488504f57d..8d4509f930584 100644 --- a/posthog/api/test/test_team.py +++ b/posthog/api/test/test_team.py @@ -458,7 +458,10 @@ def test_delete_bulky_postgres_data(self): def test_delete_batch_exports(self): self.organization_membership.level = OrganizationMembership.Level.ADMIN self.organization_membership.save() - + self.organization.available_product_features = [ + {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES} + ] + self.organization.save() team: Team = Team.objects.create_with_data(initiating_user=self.user, organization=self.organization) destination_data = { @@ -486,16 +489,16 @@ def test_delete_batch_exports(self): json.dumps(batch_export_data), content_type="application/json", ) - self.assertEqual(response.status_code, 201) + assert response.status_code == 201, response.json() batch_export = response.json() batch_export_id = batch_export["id"] response = self.client.delete(f"/api/environments/{team.id}") - self.assertEqual(response.status_code, 204) + assert response.status_code == 204, response.json() response = self.client.get(f"/api/environments/{team.id}/batch_exports/{batch_export_id}") - self.assertEqual(response.status_code, 404) + assert response.status_code == 404, response.json() with self.assertRaises(RPCError): describe_schedule(temporal, batch_export_id) diff --git a/posthog/batch_exports/http.py b/posthog/batch_exports/http.py index 72bc94adf03bd..c3929503635b2 100644 --- a/posthog/batch_exports/http.py +++ b/posthog/batch_exports/http.py @@ -1,5 +1,6 @@ import datetime as dt from typing import Any, TypedDict, cast +from loginas.utils import is_impersonated_session import posthoganalytics import structlog @@ -31,6 +32,7 @@ sync_batch_export, unpause_batch_export, ) +from posthog.constants import AvailableFeature from posthog.hogql import ast, errors from posthog.hogql.hogql import HogQLContext from posthog.hogql.parser import parse_select @@ -245,6 +247,20 @@ class Meta: ] read_only_fields = ["id", "team_id", "created_at", "last_updated_at", "latest_runs", "schema"] + def validate(self, attrs: dict) -> dict: + team = self.context["get_team"]() + attrs["team"] = team + + has_addon = team.organization.is_feature_available(AvailableFeature.DATA_PIPELINES) + + if not has_addon: + # Check if the user is impersonated - if so we allow changes as it could be an admin user fixing things + + if not is_impersonated_session(self.context["request"]): + raise serializers.ValidationError("The Data Pipelines addon is required for batch exports.") + + return attrs + def create(self, validated_data: dict) -> BatchExport: """Create a BatchExport.""" destination_data = validated_data.pop("destination") diff --git a/posthog/cdp/internal_events.py b/posthog/cdp/internal_events.py new file mode 100644 index 0000000000000..ba945ede3f2ff --- /dev/null +++ b/posthog/cdp/internal_events.py @@ -0,0 +1,72 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional +import uuid + +import structlog +from posthog.kafka_client.client import KafkaProducer +from posthog.kafka_client.topics import KAFKA_CDP_INTERNAL_EVENTS +from rest_framework_dataclasses.serializers import DataclassSerializer + +logger = structlog.get_logger(__name__) + + +@dataclass +class InternalEventEvent: + event: str + distinct_id: str + properties: dict + timestamp: Optional[str] = None + url: Optional[str] = None + uuid: Optional[str] = None + + +@dataclass +class InternalEventPerson: + id: str + properties: dict + name: Optional[str] = None + url: Optional[str] = None + + +@dataclass +class InternalEvent: + team_id: int + event: InternalEventEvent + person: Optional[InternalEventPerson] = None + + +class InternalEventSerializer(DataclassSerializer): + class Meta: + dataclass = InternalEvent + + +def internal_event_to_dict(data: InternalEvent) -> dict: + return InternalEventSerializer(data).data + + +def create_internal_event( + team_id: int, event: InternalEventEvent, person: Optional[InternalEventPerson] = None +) -> InternalEvent: + data = InternalEvent(team_id=team_id, event=event, person=person) + + if data.event.uuid is None: + data.event.uuid = str(uuid.uuid4()) + if data.event.timestamp is None: + data.event.timestamp = datetime.now().isoformat() + + return data + + +def produce_internal_event(team_id: int, event: InternalEventEvent, person: Optional[InternalEventPerson] = None): + data = create_internal_event(team_id, event, person) + serialized_data = internal_event_to_dict(data) + kafka_topic = KAFKA_CDP_INTERNAL_EVENTS + + try: + producer = KafkaProducer() + future = producer.produce(topic=kafka_topic, data=serialized_data, key=data.event.uuid) + future.get() + except Exception as e: + logger.exception("Failed to produce internal event", data=serialized_data, error=e) + raise diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py index fd2f988a8df72..6d4a24e6d1a27 100644 --- a/posthog/cdp/templates/__init__.py +++ b/posthog/cdp/templates/__init__.py @@ -1,3 +1,4 @@ +from posthog.cdp.templates.hog_function_template import derive_sub_templates from .webhook.template_webhook import template as webhook from .slack.template_slack import template as slack from .hubspot.template_hubspot import template_event as hubspot_event, template as hubspot, TemplateHubspotMigrator @@ -51,6 +52,7 @@ from .snapchat_ads.template_pixel import template_snapchat_pixel as snapchat_pixel from ._transformations.template_pass_through import template as pass_through_transformation + HOG_FUNCTION_TEMPLATES = [ _broadcast, blank_site_destination, @@ -107,7 +109,12 @@ ] +# This is a list of sub templates that are generated by merging the subtemplate with it's template +HOG_FUNCTION_SUB_TEMPLATES = derive_sub_templates(HOG_FUNCTION_TEMPLATES) + HOG_FUNCTION_TEMPLATES_BY_ID = {template.id: template for template in HOG_FUNCTION_TEMPLATES} +HOG_FUNCTION_SUB_TEMPLATES_BY_ID = {template.id: template for template in HOG_FUNCTION_SUB_TEMPLATES} +ALL_HOG_FUNCTION_TEMPLATES_BY_ID = {**HOG_FUNCTION_TEMPLATES_BY_ID, **HOG_FUNCTION_SUB_TEMPLATES_BY_ID} HOG_FUNCTION_MIGRATORS = { TemplateCustomerioMigrator.plugin_url: TemplateCustomerioMigrator, @@ -123,4 +130,4 @@ TemplateAvoMigrator.plugin_url: TemplateAvoMigrator, } -__all__ = ["HOG_FUNCTION_TEMPLATES", "HOG_FUNCTION_TEMPLATES_BY_ID"] +__all__ = ["HOG_FUNCTION_TEMPLATES", "HOG_FUNCTION_TEMPLATES_BY_ID", "ALL_HOG_FUNCTION_TEMPLATES_BY_ID"] diff --git a/posthog/cdp/templates/discord/template_discord.py b/posthog/cdp/templates/discord/template_discord.py index fb8cb2bf50c64..9e3111ec88817 100644 --- a/posthog/cdp/templates/discord/template_discord.py +++ b/posthog/cdp/templates/discord/template_discord.py @@ -1,5 +1,16 @@ from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON +COMMON_INPUTS_SCHEMA = [ + { + "key": "webhookUrl", + "type": "string", + "label": "Webhook URL", + "description": "See this page on how to generate a Webhook URL: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks", + "secret": False, + "required": True, + }, +] + template: HogFunctionTemplate = HogFunctionTemplate( status="free", type="destination", @@ -48,20 +59,37 @@ ], sub_templates=[ HogFunctionSubTemplate( - id="early_access_feature_enrollment", + id="early-access-feature-enrollment", name="Post to Discord on feature enrollment", description="Posts a message to Discord when a user enrolls or un-enrolls in an early access feature", - filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, - inputs={ - "content": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'" + filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters, + input_schema_overrides={ + "content": { + "default": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + } }, ), HogFunctionSubTemplate( - id="survey_response", + id="survey-response", name="Post to Discord on survey response", description="Posts a message to Discord when a user responds to a survey", - filters=SUB_TEMPLATE_COMMON["survey_response"].filters, - inputs={"content": "**{person.name}** responded to survey **{event.properties.$survey_name}**"}, + filters=SUB_TEMPLATE_COMMON["survey-response"].filters, + input_schema_overrides={ + "content": { + "default": "**{person.name}** responded to survey **{event.properties.$survey_name}**", + } + }, + ), + HogFunctionSubTemplate( + id="activity-log", + type="internal_destination", + name="Post to Discord on team activity", + filters=SUB_TEMPLATE_COMMON["activity-log"].filters, + input_schema_overrides={ + "content": { + "default": "**{person.name}** {event.properties.activity} {event.properties.scope} {event.properties.item_id}", + } + }, ), ], ) diff --git a/posthog/cdp/templates/hog_function_template.py b/posthog/cdp/templates/hog_function_template.py index 0ebfc1f1c37dc..f76deacc3d4e4 100644 --- a/posthog/cdp/templates/hog_function_template.py +++ b/posthog/cdp/templates/hog_function_template.py @@ -8,10 +8,25 @@ PluginConfig = None -SubTemplateId = Literal["early_access_feature_enrollment", "survey_response"] +SubTemplateId = Literal["early-access-feature-enrollment", "survey-response", "activity-log"] SUB_TEMPLATE_ID: tuple[SubTemplateId, ...] = get_args(SubTemplateId) +HogFunctionTemplateType = Literal[ + "destination", + "internal_destination", + "site_destination", + "site_app", + "transformation", + "shared", + "email", + "sms", + "push", + "broadcast", + "activity", + "alert", +] + @dataclasses.dataclass(frozen=True) class HogFunctionSubTemplate: @@ -20,7 +35,8 @@ class HogFunctionSubTemplate: description: Optional[str] = None filters: Optional[dict] = None masking: Optional[dict] = None - inputs: Optional[dict] = None + input_schema_overrides: Optional[dict[str, dict]] = None + type: Optional[HogFunctionTemplateType] = None @dataclasses.dataclass(frozen=True) @@ -42,19 +58,7 @@ class HogFunctionMappingTemplate: @dataclasses.dataclass(frozen=True) class HogFunctionTemplate: status: Literal["alpha", "beta", "stable", "free", "client-side"] - type: Literal[ - "destination", - "site_destination", - "site_app", - "transformation", - "shared", - "email", - "sms", - "push", - "broadcast", - "activity", - "alert", - ] + type: HogFunctionTemplateType id: str name: str description: str @@ -78,9 +82,41 @@ def migrate(cls, obj: PluginConfig) -> dict: raise NotImplementedError() +def derive_sub_templates(templates: list[HogFunctionTemplate]) -> list[HogFunctionTemplate]: + sub_templates = [] + for template in templates: + for sub_template in template.sub_templates or []: + merged_id = f"{template.id}-{sub_template.id}" + template_params = dataclasses.asdict(template) + sub_template_params = dataclasses.asdict(sub_template) + + # Override inputs_schema if set + input_schema_overrides = sub_template_params.pop("input_schema_overrides") + if input_schema_overrides: + new_input_schema = [] + for schema in template_params["inputs_schema"]: + if schema["key"] in input_schema_overrides: + schema.update(input_schema_overrides[schema["key"]]) + new_input_schema.append(schema) + template_params["inputs_schema"] = new_input_schema + + # Get rid of the sub_templates from the template + template_params.pop("sub_templates") + # Update with the sub template params if not none + for key, value in sub_template_params.items(): + if value is not None: + template_params[key] = value + + template_params["id"] = merged_id + merged_template = HogFunctionTemplate(**template_params) + sub_templates.append(merged_template) + + return sub_templates + + SUB_TEMPLATE_COMMON: dict[SubTemplateId, HogFunctionSubTemplate] = { - "survey_response": HogFunctionSubTemplate( - id="survey_response", + "survey-response": HogFunctionSubTemplate( + id="survey-response", name="Survey Response", filters={ "events": [ @@ -99,9 +135,15 @@ def migrate(cls, obj: PluginConfig) -> dict: ] }, ), - "early_access_feature_enrollment": HogFunctionSubTemplate( - id="early_access_feature_enrollment", + "early-access-feature-enrollment": HogFunctionSubTemplate( + id="early-access-feature-enrollment", name="Early Access Feature Enrollment", filters={"events": [{"id": "$feature_enrollment_update", "type": "events"}]}, ), + "activity-log": HogFunctionSubTemplate( + id="activity-log", + name="Team Activity", + type="internal_destination", + filters={"events": [{"id": "$activity_log_entry_created", "type": "events"}]}, + ), } diff --git a/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py b/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py index e647dde19f411..a6eb7063a52e6 100644 --- a/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py +++ b/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py @@ -1,5 +1,6 @@ from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON + template: HogFunctionTemplate = HogFunctionTemplate( status="free", type="destination", @@ -66,20 +67,37 @@ ], sub_templates=[ HogFunctionSubTemplate( - id="early_access_feature_enrollment", + id="early-access-feature-enrollment", name="Post to Microsoft Teams on feature enrollment", description="Posts a message to Microsoft Teams when a user enrolls or un-enrolls in an early access feature", - filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, - inputs={ - "text": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'" + filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters, + input_schema_overrides={ + "text": { + "default": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + } }, ), HogFunctionSubTemplate( - id="survey_response", + id="survey-response", name="Post to Microsoft Teams on survey response", description="Posts a message to Microsoft Teams when a user responds to a survey", - filters=SUB_TEMPLATE_COMMON["survey_response"].filters, - inputs={"text": "**{person.name}** responded to survey **{event.properties.$survey_name}**"}, + filters=SUB_TEMPLATE_COMMON["survey-response"].filters, + input_schema_overrides={ + "text": { + "default": "**{person.name}** responded to survey **{event.properties.$survey_name}**", + } + }, + ), + HogFunctionSubTemplate( + id="activity-log", + type="internal_destination", + name="Post to Microsoft Teams on team activity", + filters=SUB_TEMPLATE_COMMON["activity-log"].filters, + input_schema_overrides={ + "text": { + "default": "**{person.name}** {event.properties.activity} {event.properties.scope} {event.properties.item_id}", + } + }, ), ], ) diff --git a/posthog/cdp/templates/slack/template_slack.py b/posthog/cdp/templates/slack/template_slack.py index 8cfb5a84101de..3454c18381797 100644 --- a/posthog/cdp/templates/slack/template_slack.py +++ b/posthog/cdp/templates/slack/template_slack.py @@ -1,5 +1,6 @@ from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON + template: HogFunctionTemplate = HogFunctionTemplate( status="free", type="destination", @@ -108,65 +109,95 @@ ], sub_templates=[ HogFunctionSubTemplate( - id="early_access_feature_enrollment", + id="early-access-feature-enrollment", name="Post to Slack on feature enrollment", - description="Posts a message to Slack when a user enrolls or un-enrolls in an early access feature", - filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, - inputs={ - "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", - "blocks": [ - { - "text": { - "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", - "type": "mrkdwn", - }, - "type": "section", - }, - { - "type": "actions", - "elements": [ - { - "url": "{person.url}", - "text": {"text": "View Person in PostHog", "type": "plain_text"}, - "type": "button", + # description="Posts a message to Slack when a user enrolls or un-enrolls in an early access feature", + filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters, + input_schema_overrides={ + "blocks": { + "default": [ + { + "text": { + "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + "type": "mrkdwn", }, - # NOTE: It would be nice to have a link to the EAF but the event needs more info - ], - }, - ], + "type": "section", + }, + { + "type": "actions", + "elements": [ + { + "url": "{person.url}", + "text": {"text": "View Person in PostHog", "type": "plain_text"}, + "type": "button", + }, + ], + }, + ], + }, + "text": { + "default": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'", + }, }, ), HogFunctionSubTemplate( - id="survey_response", + id="survey-response", name="Post to Slack on survey response", description="Posts a message to Slack when a user responds to a survey", - filters=SUB_TEMPLATE_COMMON["survey_response"].filters, - inputs={ - "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*", - "blocks": [ - { - "text": { - "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*", - "type": "mrkdwn", - }, - "type": "section", - }, - { - "type": "actions", - "elements": [ - { - "url": "{project.url}/surveys/{event.properties.$survey_id}", - "text": {"text": "View Survey", "type": "plain_text"}, - "type": "button", + filters=SUB_TEMPLATE_COMMON["survey-response"].filters, + input_schema_overrides={ + "blocks": { + "default": [ + { + "text": { + "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*", + "type": "mrkdwn", }, - { - "url": "{person.url}", - "text": {"text": "View Person", "type": "plain_text"}, - "type": "button", + "type": "section", + }, + { + "type": "actions", + "elements": [ + { + "url": "{project.url}/surveys/{event.properties.$survey_id}", + "text": {"text": "View Survey", "type": "plain_text"}, + "type": "button", + }, + { + "url": "{person.url}", + "text": {"text": "View Person", "type": "plain_text"}, + "type": "button", + }, + ], + }, + ], + }, + "text": { + "default": "*{person.name}* responded to survey *{event.properties.$survey_name}*", + }, + }, + ), + HogFunctionSubTemplate( + id="activity-log", + name="Post to Slack on team activity", + description="", + filters=SUB_TEMPLATE_COMMON["activity-log"].filters, + type="internal_destination", + input_schema_overrides={ + "blocks": { + "default": [ + { + "text": { + "text": "*{person.properties.email}* {event.properties.activity} {event.properties.scope} {event.properties.item_id} ", + "type": "mrkdwn", }, - ], - }, - ], + "type": "section", + } + ], + }, + "text": { + "default": "*{person.properties.email}* {event.properties.activity} {event.properties.scope} {event.properties.item_id}", + }, }, ), ], diff --git a/posthog/cdp/templates/webhook/template_webhook.py b/posthog/cdp/templates/webhook/template_webhook.py index 49e350736de51..45789df2b9fac 100644 --- a/posthog/cdp/templates/webhook/template_webhook.py +++ b/posthog/cdp/templates/webhook/template_webhook.py @@ -92,14 +92,20 @@ ], sub_templates=[ HogFunctionSubTemplate( - id="early_access_feature_enrollment", + id="early-access-feature-enrollment", name="HTTP Webhook on feature enrollment", - filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters, + filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters, ), HogFunctionSubTemplate( - id="survey_response", + id="survey-response", name="HTTP Webhook on survey response", - filters=SUB_TEMPLATE_COMMON["survey_response"].filters, + filters=SUB_TEMPLATE_COMMON["survey-response"].filters, + ), + HogFunctionSubTemplate( + id="activity-log", + name="HTTP Webhook on team activity", + filters=SUB_TEMPLATE_COMMON["activity-log"].filters, + type="internal_destination", ), ], ) diff --git a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py index 363ee06bc78be..08c7e3dd91de5 100644 --- a/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py +++ b/posthog/hogql_queries/experiments/experiment_funnels_query_runner.py @@ -31,6 +31,7 @@ from typing import Optional, Any, cast from zoneinfo import ZoneInfo from rest_framework.exceptions import ValidationError +from datetime import datetime, timedelta, UTC class ExperimentFunnelsQueryRunner(QueryRunner): @@ -216,3 +217,14 @@ def _validate_event_variants(self, funnels_result: FunnelsQueryResponse): def to_query(self) -> ast.SelectQuery: raise ValueError(f"Cannot convert source query of type {self.query.funnels_query.kind} to query") + + # Cache results for 24 hours + def cache_target_age(self, last_refresh: Optional[datetime], lazy: bool = False) -> Optional[datetime]: + if last_refresh is None: + return None + return last_refresh + timedelta(hours=24) + + def _is_stale(self, last_refresh: Optional[datetime], lazy: bool = False) -> bool: + if not last_refresh: + return True + return (datetime.now(UTC) - last_refresh) > timedelta(hours=24) diff --git a/posthog/hogql_queries/experiments/experiment_trends_query_runner.py b/posthog/hogql_queries/experiments/experiment_trends_query_runner.py index 6cf76b4c5b4e7..b3c72c788c951 100644 --- a/posthog/hogql_queries/experiments/experiment_trends_query_runner.py +++ b/posthog/hogql_queries/experiments/experiment_trends_query_runner.py @@ -45,6 +45,7 @@ ) from typing import Any, Optional import threading +from datetime import datetime, timedelta, UTC class ExperimentTrendsQueryRunner(QueryRunner): @@ -430,3 +431,14 @@ def _is_data_warehouse_query(self, query: TrendsQuery) -> bool: def to_query(self) -> ast.SelectQuery: raise ValueError(f"Cannot convert source query of type {self.query.count_query.kind} to query") + + # Cache results for 24 hours + def cache_target_age(self, last_refresh: Optional[datetime], lazy: bool = False) -> Optional[datetime]: + if last_refresh is None: + return None + return last_refresh + timedelta(hours=24) + + def _is_stale(self, last_refresh: Optional[datetime], lazy: bool = False) -> bool: + if not last_refresh: + return True + return (datetime.now(UTC) - last_refresh) > timedelta(hours=24) diff --git a/posthog/kafka_client/topics.py b/posthog/kafka_client/topics.py index fa58d40c5fa36..3ed04cfc78d38 100644 --- a/posthog/kafka_client/topics.py +++ b/posthog/kafka_client/topics.py @@ -35,3 +35,5 @@ KAFKA_EXCEPTION_SYMBOLIFICATION_EVENTS = f"{KAFKA_PREFIX}exception_symbolification_events{SUFFIX}" KAFKA_ERROR_TRACKING_ISSUE_FINGERPRINT = f"{KAFKA_PREFIX}clickhouse_error_tracking_issue_fingerprint{SUFFIX}" + +KAFKA_CDP_INTERNAL_EVENTS = f"{KAFKA_PREFIX}cdp_internal_events{SUFFIX}" diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index 7cf9595e64983..567ea9d6d7f85 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -5,6 +5,9 @@ from typing import Any, Literal, Optional, Union from uuid import UUID +from django.db.models.signals import post_save +from django.dispatch.dispatcher import receiver +from sentry_sdk import capture_exception import structlog from django.core.paginator import Paginator from django.core.exceptions import ObjectDoesNotExist @@ -498,3 +501,37 @@ def load_all_activity(scope_list: list[ActivityScope], team_id: int, limit: int ) return get_activity_page(activity_query, limit, page) + + +@receiver(post_save, sender=ActivityLog) +def activity_log_created(sender, instance: "ActivityLog", created, **kwargs): + from posthog.cdp.internal_events import InternalEventEvent, InternalEventPerson, produce_internal_event + from posthog.api.activity_log import ActivityLogSerializer + from posthog.api.shared import UserBasicSerializer + + try: + serialized_data = ActivityLogSerializer(instance).data + # TODO: Move this into the producer to support dataclasses + serialized_data["detail"] = dataclasses.asdict(serialized_data["detail"]) + user_data = UserBasicSerializer(instance.user).data if instance.user else None + + if created and instance.team_id is not None: + produce_internal_event( + team_id=instance.team_id, + event=InternalEventEvent( + event="$activity_log_entry_created", + distinct_id=user_data["distinct_id"] if user_data else f"team_{instance.team_id}", + properties=serialized_data, + ), + person=InternalEventPerson( + id=user_data["id"], + properties=user_data, + ) + if user_data + else None, + ) + except Exception as e: + # We don't want to hard fail here. + logger.exception("Failed to produce internal event", data=serialized_data, error=e) + capture_exception(e) + return diff --git a/posthog/models/hog_functions/hog_function.py b/posthog/models/hog_functions/hog_function.py index 8328973bc0a2e..a715f10b86b7b 100644 --- a/posthog/models/hog_functions/hog_function.py +++ b/posthog/models/hog_functions/hog_function.py @@ -36,6 +36,7 @@ class HogFunctionState(enum.Enum): class HogFunctionType(models.TextChoices): DESTINATION = "destination" SITE_DESTINATION = "site_destination" + INTERNAL_DESTINATION = "internal_destination" SITE_APP = "site_app" TRANSFORMATION = "transformation" EMAIL = "email" @@ -46,8 +47,13 @@ class HogFunctionType(models.TextChoices): BROADCAST = "broadcast" -TYPES_THAT_RELOAD_PLUGIN_SERVER = (HogFunctionType.DESTINATION, HogFunctionType.EMAIL, HogFunctionType.TRANSFORMATION) -TYPES_WITH_COMPILED_FILTERS = (HogFunctionType.DESTINATION,) +TYPES_THAT_RELOAD_PLUGIN_SERVER = ( + HogFunctionType.DESTINATION, + HogFunctionType.EMAIL, + HogFunctionType.TRANSFORMATION, + HogFunctionType.INTERNAL_DESTINATION, +) +TYPES_WITH_COMPILED_FILTERS = (HogFunctionType.DESTINATION, HogFunctionType.INTERNAL_DESTINATION) TYPES_WITH_TRANSPILED_FILTERS = (HogFunctionType.SITE_DESTINATION, HogFunctionType.SITE_APP) TYPES_WITH_JAVASCRIPT_SOURCE = (HogFunctionType.SITE_DESTINATION, HogFunctionType.SITE_APP) @@ -88,9 +94,9 @@ class Meta: @property def template(self) -> Optional[HogFunctionTemplate]: - from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES_BY_ID + from posthog.cdp.templates import ALL_HOG_FUNCTION_TEMPLATES_BY_ID - return HOG_FUNCTION_TEMPLATES_BY_ID.get(self.template_id, None) + return ALL_HOG_FUNCTION_TEMPLATES_BY_ID.get(self.template_id, None) @property def filter_action_ids(self) -> list[int]: