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 7df6f432cbc78..74ab9873aa914 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/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index 74d9ac5212cad..09fd77527c43e 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -8,6 +8,7 @@ import { IconHome, IconLive, IconLogomark, + IconMegaphone, IconNotebook, IconPeople, IconPieChart, @@ -526,6 +527,15 @@ export const navigation3000Logic = kea([ to: urls.pipeline(), } : null, + featureFlags[FEATURE_FLAGS.MESSAGING] && hasOnboardedAnyProduct + ? { + identifier: Scene.MessagingBroadcasts, + label: 'Messaging', + icon: , + to: urls.messagingBroadcasts(), + tag: 'alpha' as const, + } + : null, ].filter(isNotNil), ] }, diff --git a/frontend/src/layout/navigation/EnvironmentSwitcher.tsx b/frontend/src/layout/navigation/EnvironmentSwitcher.tsx index 2432cb72c63f7..83c85092ff3ab 100644 --- a/frontend/src/layout/navigation/EnvironmentSwitcher.tsx +++ b/frontend/src/layout/navigation/EnvironmentSwitcher.tsx @@ -157,6 +157,17 @@ function determineProjectSwitchUrl(pathname: string, newTeamId: number): string // and after switching is on a different page than before. let route = removeProjectIdIfPresent(pathname) route = removeFlagIdIfPresent(route) + + // List of routes that should redirect to project home + // instead of keeping the current path. + const redirectToHomeRoutes = ['/products', '/onboarding'] + + const shouldRedirectToHome = redirectToHomeRoutes.some((redirectRoute) => route.includes(redirectRoute)) + + if (shouldRedirectToHome) { + return urls.project(newTeamId) // Go to project home + } + return urls.project(newTeamId, route) } diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index acdbae72b8530..3639d06e4b466 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -220,6 +220,7 @@ export const FEATURE_FLAGS = { LEGACY_ACTION_WEBHOOKS: 'legacy-action-webhooks', // owner: @mariusandra #team-cdp SESSION_REPLAY_URL_TRIGGER: 'session-replay-url-trigger', // owner: @richard-better #team-replay REPLAY_TEMPLATES: 'replay-templates', // owner: @raquelmsmith #team-replay + MESSAGING: 'messaging', // owner @mariusandra #team-cdp } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 315dfaa5e1926..b15cea71ad019 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -10322,6 +10322,21 @@ "required": ["k", "t"], "type": "object" }, + "RecordingOrder": { + "enum": [ + "duration", + "recording_duration", + "inactive_seconds", + "active_seconds", + "start_time", + "console_error_count", + "click_count", + "keypress_count", + "mouse_activity_count", + "activity_score" + ], + "type": "string" + }, "RecordingPropertyFilter": { "additionalProperties": false, "properties": { @@ -10411,35 +10426,7 @@ "$ref": "#/definitions/FilterLogicalOperator" }, "order": { - "anyOf": [ - { - "$ref": "#/definitions/DurationType" - }, - { - "const": "start_time", - "type": "string" - }, - { - "const": "console_error_count", - "type": "string" - }, - { - "const": "click_count", - "type": "string" - }, - { - "const": "keypress_count", - "type": "string" - }, - { - "const": "mouse_activity_count", - "type": "string" - }, - { - "const": "activity_score", - "type": "string" - } - ] + "$ref": "#/definitions/RecordingOrder" }, "person_uuid": { "type": "string" @@ -10463,7 +10450,7 @@ "type": "object" } }, - "required": ["kind", "order"], + "required": ["kind"], "type": "object" }, "RecordingsQueryResponse": { diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 397e0487de994..562c9bf50ada8 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -10,7 +10,6 @@ import { ChartDisplayCategory, ChartDisplayType, CountPerActorMathType, - DurationType, EventPropertyFilter, EventType, FeaturePropertyFilter, @@ -313,6 +312,18 @@ export interface RecordingsQueryResponse { has_next: boolean } +export type RecordingOrder = + | 'duration' + | 'recording_duration' + | 'inactive_seconds' + | 'active_seconds' + | 'start_time' + | 'console_error_count' + | 'click_count' + | 'keypress_count' + | 'mouse_activity_count' + | 'activity_score' + export interface RecordingsQuery extends DataNode { kind: NodeKind.RecordingsQuery date_from?: string | null @@ -326,14 +337,7 @@ export interface RecordingsQuery extends DataNode { operand?: FilterLogicalOperator session_ids?: string[] person_uuid?: string - order: - | DurationType - | 'start_time' - | 'console_error_count' - | 'click_count' - | 'keypress_count' - | 'mouse_activity_count' - | 'activity_score' + order?: RecordingOrder limit?: integer offset?: integer user_modified_filters?: Record diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 0bfb2ae19e362..7d5f1291a5213 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -77,4 +77,6 @@ export const appScenes: Record any> = { [Scene.Heatmaps]: () => import('./heatmaps/HeatmapsScene'), [Scene.SessionAttributionExplorer]: () => import('scenes/web-analytics/SessionAttributionExplorer/SessionAttributionExplorerScene'), + [Scene.MessagingProviders]: () => import('./messaging/Providers'), + [Scene.MessagingBroadcasts]: () => import('./messaging/Broadcasts'), } diff --git a/frontend/src/scenes/messaging/Broadcasts.tsx b/frontend/src/scenes/messaging/Broadcasts.tsx new file mode 100644 index 0000000000000..9d26e53398d17 --- /dev/null +++ b/frontend/src/scenes/messaging/Broadcasts.tsx @@ -0,0 +1,43 @@ +import { IconPlusSmall } from '@posthog/icons' +import { useValues } from 'kea' +import { PageHeader } from 'lib/components/PageHeader' +import { LemonButton } from 'lib/lemon-ui/LemonButton' +import { broadcastsLogic } from 'scenes/messaging/broadcastsLogic' +import { FunctionsTable } from 'scenes/messaging/FunctionsTable' +import { MessagingTabs } from 'scenes/messaging/MessagingTabs' +import { HogFunctionConfiguration } from 'scenes/pipeline/hogfunctions/HogFunctionConfiguration' +import { SceneExport } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +export function Broadcasts(): JSX.Element { + const { broadcastId } = useValues(broadcastsLogic) + return broadcastId ? ( + + ) : ( + <> + + } + > + New broadcast + + } + /> + + + ) +} + +export const scene: SceneExport = { + component: Broadcasts, + logic: broadcastsLogic, +} diff --git a/frontend/src/scenes/messaging/FunctionsTable.tsx b/frontend/src/scenes/messaging/FunctionsTable.tsx new file mode 100644 index 0000000000000..329106a8ceaca --- /dev/null +++ b/frontend/src/scenes/messaging/FunctionsTable.tsx @@ -0,0 +1,125 @@ +import { LemonInput, LemonTable, LemonTableColumn, Link, Tooltip } from '@posthog/lemon-ui' +import { BindLogic, useActions, useValues } from 'kea' +import { More } from 'lib/lemon-ui/LemonButton/More' +import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { functionsTableLogic } from 'scenes/messaging/functionsTableLogic' +import { hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' + +import { HogFunctionType, HogFunctionTypeType } from '~/types' + +import { HogFunctionIcon } from '../pipeline/hogfunctions/HogFunctionIcon' +import { HogFunctionStatusIndicator } from '../pipeline/hogfunctions/HogFunctionStatusIndicator' + +export interface FunctionsTableProps { + type?: HogFunctionTypeType +} + +export function FunctionsTableFilters(): JSX.Element | null { + const { filters } = useValues(functionsTableLogic) + const { setFilters } = useActions(functionsTableLogic) + + return ( +
+
+ setFilters({ search: e })} + /> +
+
+ ) +} + +export function FunctionsTable({ type }: FunctionsTableProps): JSX.Element { + const { hogFunctions, filteredHogFunctions, loading } = useValues(functionsTableLogic({ type })) + const { deleteHogFunction, resetFilters } = useActions(functionsTableLogic({ type })) + + return ( + +
+ + + + }, + }, + { + title: 'Name', + sticky: true, + sorter: true, + key: 'name', + dataIndex: 'name', + render: function RenderPluginName(_, hogFunction) { + return ( + + + {hogFunction.name} + + + } + description={hogFunction.description} + /> + ) + }, + }, + + updatedAtColumn() as LemonTableColumn, + { + title: 'Status', + key: 'enabled', + sorter: (a) => (a.enabled ? 1 : -1), + width: 0, + render: function RenderStatus(_, hogFunction) { + return + }, + }, + { + width: 0, + render: function Render(_, hogFunction) { + return ( + deleteHogFunction(hogFunction), + }, + ]} + /> + } + /> + ) + }, + }, + ]} + emptyState={ + hogFunctions.length === 0 && !loading ? ( + 'Nothing found' + ) : ( + <> + Nothing matches filters. resetFilters()}>Clear filters{' '} + + ) + } + /> +
+
+ ) +} diff --git a/frontend/src/scenes/messaging/MessagingTabs.tsx b/frontend/src/scenes/messaging/MessagingTabs.tsx new file mode 100644 index 0000000000000..dac7146eab023 --- /dev/null +++ b/frontend/src/scenes/messaging/MessagingTabs.tsx @@ -0,0 +1,25 @@ +import { useActions, useValues } from 'kea' +import { LemonTabs } from 'lib/lemon-ui/LemonTabs' +import { SceneExport } from 'scenes/sceneTypes' + +import { MessagingTab, messagingTabsLogic } from './messagingTabsLogic' + +export function MessagingTabs(): JSX.Element { + const { currentTab } = useValues(messagingTabsLogic) + const { setTab } = useActions(messagingTabsLogic) + return ( + setTab(tab as MessagingTab)} + tabs={[ + { key: 'broadcasts', label: 'Broadcasts' }, + { key: 'providers', label: 'Providers' }, + ]} + /> + ) +} + +export const scene: SceneExport = { + component: MessagingTabs, + logic: messagingTabsLogic, +} diff --git a/frontend/src/scenes/messaging/Providers.tsx b/frontend/src/scenes/messaging/Providers.tsx new file mode 100644 index 0000000000000..bab003d9cf71b --- /dev/null +++ b/frontend/src/scenes/messaging/Providers.tsx @@ -0,0 +1,32 @@ +import { useValues } from 'kea' +import { PageHeader } from 'lib/components/PageHeader' +import { FunctionsTable } from 'scenes/messaging/FunctionsTable' +import { MessagingTabs } from 'scenes/messaging/MessagingTabs' +import { providersLogic } from 'scenes/messaging/providersLogic' +import { HogFunctionConfiguration } from 'scenes/pipeline/hogfunctions/HogFunctionConfiguration' +import { HogFunctionTemplateList } from 'scenes/pipeline/hogfunctions/list/HogFunctionTemplateList' +import { SceneExport } from 'scenes/sceneTypes' + +export function Providers(): JSX.Element { + const { providerId, templateId } = useValues(providersLogic) + return providerId ? ( + + ) : ( + <> + + + +
+

Add Provider

+ +
+ Note: to add a provider that's not in the list, select one that's similar and edit its source to point + to the right API URLs +
+ + ) +} +export const scene: SceneExport = { + component: Providers, + logic: providersLogic, +} diff --git a/frontend/src/scenes/messaging/broadcastsLogic.tsx b/frontend/src/scenes/messaging/broadcastsLogic.tsx new file mode 100644 index 0000000000000..635e105a064a6 --- /dev/null +++ b/frontend/src/scenes/messaging/broadcastsLogic.tsx @@ -0,0 +1,65 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { urlToAction } from 'kea-router' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { Breadcrumb } from '~/types' + +import type { broadcastsLogicType } from './broadcastsLogicType' + +export const broadcastsLogic = kea([ + path(['scenes', 'messaging', 'broadcastsLogic']), + actions({ + editBroadcast: (id: string | null) => ({ id }), + }), + reducers({ + broadcastId: [null as string | null, { editBroadcast: (_, { id }) => id }], + }), + selectors({ + breadcrumbs: [ + (s) => [s.broadcastId], + (broadcastId): Breadcrumb[] => { + return [ + { + key: Scene.MessagingBroadcasts, + name: 'Messaging', + path: urls.messagingBroadcasts(), + }, + { + key: 'broadcasts', + name: 'Broadcasts', + path: urls.messagingBroadcasts(), + }, + ...(broadcastId === 'new' + ? [ + { + key: 'new-broadcast', + name: 'New broadcast', + path: urls.messagingBroadcastNew(), + }, + ] + : broadcastId + ? [ + { + key: 'edit-broadcast', + name: 'Edit broadcast', + path: urls.messagingBroadcast(broadcastId), + }, + ] + : []), + ] + }, + ], + }), + urlToAction(({ actions }) => ({ + '/messaging/broadcasts/new': () => { + actions.editBroadcast('new') + }, + '/messaging/broadcasts/:id': ({ id }) => { + actions.editBroadcast(id ?? null) + }, + '/messaging/broadcasts': () => { + actions.editBroadcast(null) + }, + })), +]) diff --git a/frontend/src/scenes/messaging/functionsTableLogic.tsx b/frontend/src/scenes/messaging/functionsTableLogic.tsx new file mode 100644 index 0000000000000..054b8144d73e8 --- /dev/null +++ b/frontend/src/scenes/messaging/functionsTableLogic.tsx @@ -0,0 +1,94 @@ +import FuseClass from 'fuse.js' +import { actions, afterMount, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { deleteWithUndo } from 'lib/utils/deleteWithUndo' +import { teamLogic } from 'scenes/teamLogic' + +import { HogFunctionType, HogFunctionTypeType } from '~/types' + +import type { functionsTableLogicType } from './functionsTableLogicType' + +// Helping kea-typegen navigate the exported default class for Fuse +export interface Fuse extends FuseClass {} + +export interface FunctionsTableLogicProps { + type?: HogFunctionTypeType +} +export interface HogFunctionsFilter { + search?: string +} +export const functionsTableLogic = kea([ + path(['scenes', 'messaging', 'functionsTableLogic']), + props({} as FunctionsTableLogicProps), + key((props: FunctionsTableLogicProps) => props.type ?? 'destination'), + connect({ + values: [teamLogic, ['currentTeamId']], + }), + actions({ + deleteHogFunction: (hogFunction: HogFunctionType) => ({ hogFunction }), + setFilters: (filters: Partial) => ({ filters }), + resetFilters: true, + }), + reducers({ + filters: [ + {} as HogFunctionsFilter, + { + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + }), + resetFilters: () => ({}), + }, + ], + }), + loaders(({ props, values, actions }) => ({ + hogFunctions: [ + [] as HogFunctionType[], + { + loadHogFunctions: async () => { + // TODO: pagination? + return (await api.hogFunctions.list({ type: props.type ?? 'destination' })).results + }, + deleteHogFunction: async ({ hogFunction }) => { + await deleteWithUndo({ + endpoint: `projects/${teamLogic.values.currentTeamId}/hog_functions`, + object: { + id: hogFunction.id, + name: hogFunction.name, + }, + callback: (undo) => { + if (undo) { + actions.loadHogFunctions() + } + }, + }) + return values.hogFunctions.filter((hf) => hf.id !== hogFunction.id) + }, + }, + ], + })), + selectors({ + loading: [(s) => [s.hogFunctionsLoading], (hogFunctionsLoading) => hogFunctionsLoading], + hogFunctionsFuse: [ + (s) => [s.hogFunctions], + (hogFunctions): Fuse => { + return new FuseClass(hogFunctions || [], { + keys: ['name', 'description'], + threshold: 0.3, + }) + }, + ], + + filteredHogFunctions: [ + (s) => [s.filters, s.hogFunctions, s.hogFunctionsFuse], + (filters, hogFunctions, hogFunctionsFuse): HogFunctionType[] => { + const { search } = filters + return search ? hogFunctionsFuse.search(search).map((x) => x.item) : hogFunctions + }, + ], + }), + afterMount(({ actions }) => { + actions.loadHogFunctions() + }), +]) diff --git a/frontend/src/scenes/messaging/messagingTabsLogic.ts b/frontend/src/scenes/messaging/messagingTabsLogic.ts new file mode 100644 index 0000000000000..577a7f6d1ac5e --- /dev/null +++ b/frontend/src/scenes/messaging/messagingTabsLogic.ts @@ -0,0 +1,44 @@ +import { actions, kea, path, reducers } from 'kea' +import { actionToUrl, urlToAction } from 'kea-router' +import { urls } from 'scenes/urls' + +import type { messagingTabsLogicType } from './messagingTabsLogicType' + +export type MessagingTab = 'broadcasts' | 'providers' + +export const messagingTabsLogic = kea([ + path(['scenes', 'messaging', 'messagingLogic']), + actions({ + setTab: (tab: MessagingTab, fromUrl = false) => ({ tab, fromUrl }), + }), + reducers({ + currentTab: ['broadcasts' as MessagingTab, { setTab: (_, { tab }) => tab }], + }), + + actionToUrl(({ values }) => ({ + setTab: ({ fromUrl }) => { + // do not override deeper urls like /messaging/broadcasts/new + if (!fromUrl) { + return ( + { + broadcasts: urls.messagingBroadcasts(), + providers: urls.messagingProviders(), + }[values.currentTab] ?? urls.messagingBroadcasts() + ) + } + }, + })), + + urlToAction(({ actions, values }) => ({ + '/messaging/:tab': ({ tab }) => { + if (tab !== values.currentTab) { + actions.setTab(tab as MessagingTab, true) + } + }, + '/messaging/:tab/*': ({ tab }) => { + if (tab !== values.currentTab) { + actions.setTab(tab as MessagingTab, true) + } + }, + })), +]) diff --git a/frontend/src/scenes/messaging/providersLogic.ts b/frontend/src/scenes/messaging/providersLogic.ts new file mode 100644 index 0000000000000..12ede195abb60 --- /dev/null +++ b/frontend/src/scenes/messaging/providersLogic.ts @@ -0,0 +1,69 @@ +import { actions, kea, path, reducers, selectors } from 'kea' +import { urlToAction } from 'kea-router' +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { Breadcrumb } from '~/types' + +import type { providersLogicType } from './providersLogicType' + +export const providersLogic = kea([ + path(['scenes', 'messaging', 'providersLogic']), + actions({ + editProvider: (id: string | null, template: string | null) => ({ id, template }), + }), + reducers({ + providerId: [null as string | null, { editProvider: (_, { id }) => id }], + templateId: [null as string | null, { editProvider: (_, { template }) => template }], + }), + selectors({ + breadcrumbs: [ + (s) => [s.providerId, s.templateId], + (providerId, templateId): Breadcrumb[] => { + return [ + { + key: Scene.MessagingBroadcasts, + name: 'Messaging', + path: urls.messagingBroadcasts(), + }, + { + key: 'providers', + name: 'Providers', + path: urls.messagingProviders(), + }, + ...(providerId === 'new' || templateId + ? [ + { + key: 'new-provider', + name: 'New provider', + path: urls.messagingProviderNew(), + }, + ] + : providerId + ? [ + { + key: 'edit-provider', + name: 'Edit provider', + path: urls.messagingProvider(providerId), + }, + ] + : []), + ] + }, + ], + }), + urlToAction(({ actions }) => ({ + '/messaging/providers/new': () => { + actions.editProvider('new', null) + }, + '/messaging/providers/new/:template': ({ template }) => { + actions.editProvider('new', template ?? null) + }, + '/messaging/providers/:id': ({ id }) => { + actions.editProvider(id ?? null, null) + }, + '/messaging/providers': () => { + actions.editProvider(null, null) + }, + })), +]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx index d0e60b03d8944..a27181b3e77c7 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx @@ -16,6 +16,7 @@ import { import clsx from 'clsx' import { BindLogic, useActions, useValues } from 'kea' import { Form } from 'kea-forms' +import { combineUrl } from 'kea-router' import { NotFound } from 'lib/components/NotFound' import { PageHeader } from 'lib/components/PageHeader' import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini' @@ -23,6 +24,7 @@ import { Sparkline } from 'lib/components/Sparkline' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonField } from 'lib/lemon-ui/LemonField' import { CodeEditorResizeable } from 'lib/monaco/CodeEditorResizable' +import { urls } from 'scenes/urls' import { AvailableFeature } from '~/types' @@ -59,10 +61,14 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur hasAddon, sparkline, sparklineLoading, + personsCount, + personsCountLoading, + personsListQuery, template, subTemplate, templateHasChanged, forcedSubTemplateId, + type, } = useValues(logic) const { submitConfiguration, @@ -145,6 +151,12 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur return } + const showFilters = type === 'destination' || type === 'broadcast' + const showExpectedVolume = type === 'destination' + const showEnabled = type === 'destination' || type === 'email' + const canEditSource = type === 'destination' || type === 'email' + const showPersonsCount = type === 'broadcast' + return (
@@ -157,10 +169,13 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur } /> - - Hog Functions are in beta and are the next generation of our data pipeline destinations. You - can use pre-existing templates or modify the source Hog code to create your own custom functions. - + {type === 'destination' ? ( + + Hog Functions are in beta and are the next generation of our data pipeline destinations. + You can use pre-existing templates or modify the source Hog code to create your own custom + functions. + + ) : null} {hogFunction?.filters?.bytecode_error ? (
@@ -197,17 +212,20 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur - - {({ value, onChange }) => ( - onChange(!value)} - checked={value} - disabled={loading} - bordered - /> - )} - + {showEnabled && } + {showEnabled && ( + + {({ value, onChange }) => ( + onChange(!value)} + checked={value} + disabled={loading} + bordered + /> + )} + + )}
@@ -266,45 +284,77 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur ) : null}
- + {showFilters && } -
- Expected volume - {sparkline && !sparklineLoading ? ( - <> - {sparkline.count > EVENT_THRESHOLD_ALERT_LEVEL ? ( - - Warning: This destination would have triggered{' '} - - {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} - {' '} - in the last 7 days. Consider the impact of this function on your - destination. - - ) : ( -

- This destination would have triggered{' '} - - {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} - {' '} - in the last 7 days. -

- )} - - - ) : sparklineLoading ? ( -
- + {showPersonsCount && ( +
+
+ Matching persons
- ) : ( -

The expected volume could not be calculated

- )} -
+ {personsCount && !personsCountLoading ? ( + <> + Found{' '} + + + {personsCount ?? 0} {personsCount !== 1 ? 'people' : 'person'} + + {' '} + to send to. + + ) : personsCountLoading ? ( +
+ +
+ ) : ( +

The expected volume could not be calculated

+ )} +
+ )} + + {showExpectedVolume && ( +
+ Expected volume + {sparkline && !sparklineLoading ? ( + <> + {sparkline.count > EVENT_THRESHOLD_ALERT_LEVEL ? ( + + Warning: This destination would have triggered{' '} + + {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} + {' '} + in the last 7 days. Consider the impact of this function on your + destination. + + ) : ( +

+ This destination would have triggered{' '} + + {sparkline.count ?? 0} time{sparkline.count !== 1 ? 's' : ''} + {' '} + in the last 7 days. +

+ )} + + + ) : sparklineLoading ? ( +
+ +
+ ) : ( +

The expected volume could not be calculated

+ )} +
+ )}
@@ -346,7 +396,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
- {showSource ? ( + {showSource && canEditSource ? ( } size="small" @@ -370,76 +420,79 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
-
-
-
-

Edit source

- {!showSource ?

Click here to edit the function's source code

: null} -
- - {!showSource ? ( - setShowSource(true)} - disabledReason={ - !hasAddon - ? 'Editing the source code requires the Data Pipelines addon' - : undefined - } - > - Edit source code - - ) : ( - setShowSource(false)} - > - Hide source code - + {canEditSource && ( +
+ > +
+
+

Edit source

+ {!showSource ?

Click here to edit the function's source code

: null} +
- {showSource ? ( - - {({ value, onChange }) => ( - <> - - This is the underlying Hog code that will run whenever the filters - match. See the docs{' '} - for more info - - onChange(v ?? '')} - globals={globalsWithInputs} - options={{ - minimap: { - enabled: false, - }, - wordWrap: 'on', - scrollBeyondLastLine: false, - automaticLayout: true, - fixedOverflowWidgets: true, - suggest: { - showInlineDetails: true, - }, - quickSuggestionsDelay: 300, - }} - /> - + {!showSource ? ( + setShowSource(true)} + disabledReason={ + !hasAddon + ? 'Editing the source code requires the Data Pipelines addon' + : undefined + } + > + Edit source code + + ) : ( + setShowSource(false)} + > + Hide source code + )} - - ) : null} -
+
+ + {showSource ? ( + + {({ value, onChange }) => ( + <> + + This is the underlying Hog code that will run whenever the + filters match.{' '} + See the docs for + more info + + onChange(v ?? '')} + globals={globalsWithInputs} + options={{ + minimap: { + enabled: false, + }, + wordWrap: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + fixedOverflowWidgets: true, + suggest: { + showInlineDetails: true, + }, + quickSuggestionsDelay: 300, + }} + /> + + )} + + ) : null} +
+ )} - {id ? : } + {!id || id === 'new' ? : }
{saveButtons}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx index 9ee3c844ab66e..1a08481511f47 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionTest.tsx @@ -46,19 +46,24 @@ const HogFunctionTestEditor = ({ ) } -export function HogFunctionTestPlaceholder(): JSX.Element { +export function HogFunctionTestPlaceholder({ + title, + description, +}: { + title?: string | JSX.Element + description?: string | JSX.Element +}): JSX.Element { return (
-

Testing

-

Save your configuration to enable testing

+

{title || 'Testing'}

+

{description || 'Save your configuration to enable testing'}

) } export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element { - const { isTestInvocationSubmitting, testResult, expanded, sampleGlobalsLoading, sampleGlobalsError } = useValues( - hogFunctionTestLogic(props) - ) + const { isTestInvocationSubmitting, testResult, expanded, sampleGlobalsLoading, sampleGlobalsError, type } = + useValues(hogFunctionTestLogic(props)) const { submitTestInvocation, setTestResult, toggleExpanded, loadSampleGlobals } = useActions( hogFunctionTestLogic(props) ) @@ -71,7 +76,14 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {

Testing

- {!expanded &&

Click here to test your function with an example event

} + {!expanded && + (type === 'email' ? ( +

Click here to test the provider with a sample e-mail

+ ) : type === 'broadcast' ? ( +

Click here to test your broadcast

+ ) : ( +

Click here to test your function with an example event

+ ))}
{!expanded ? ( @@ -90,14 +102,16 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element { ) : ( <> - - Refresh globals - + {type === 'destination' ? ( + + Refresh globals + + ) : null} {({ value, onChange }) => ( ( <>
-
Here are all the global variables you can use in your code:
+
+ {type === 'broadcast' + ? 'The test broadcast will be sent with this sample data:' + : type === 'email' + ? 'The provider will be tested with this sample data:' + : 'Here are all the global variables you can use in your code:'} +
{sampleGlobalsError ? (
{sampleGlobalsError}
) : null} diff --git a/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx b/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx index 872e1beebcaf5..c25ea2ce9ce65 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/activityDescriptions.tsx @@ -8,19 +8,12 @@ import { import { LemonDropdown } from 'lib/lemon-ui/LemonDropdown' import { Link } from 'lib/lemon-ui/Link' import { initHogLanguage } from 'lib/monaco/languages/hog' -import { urls } from 'scenes/urls' -import { PipelineNodeTab, PipelineStage } from '~/types' +import { hogFunctionUrl } from './urls' const nameOrLinkToHogFunction = (id?: string | null, name?: string | null): string | JSX.Element => { const displayName = name || '(empty string)' - return id ? ( - - {displayName} - - ) : ( - displayName - ) + return id ? {displayName} : displayName } export interface DiffProps { diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx index c9d0e7a9023b8..50bf6bad4f4df 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx @@ -9,6 +9,7 @@ import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter' import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow' import { groupsModel } from '~/models/groupsModel' +import { NodeKind } from '~/queries/schema' import { AnyPropertyFilter, EntityTypes, FilterType, HogFunctionFiltersType } from '~/types' import { hogFunctionConfigurationLogic } from '../hogFunctionConfigurationLogic' @@ -44,7 +45,34 @@ function sanitizeActionFilters(filters?: FilterType): Partial + + {({ value, onChange }) => ( + { + onChange({ + ...value, + properties, + }) + }} + pageKey={`HogFunctionPropertyFilters.${id}`} + metadataSource={{ kind: NodeKind.ActorsQuery }} + /> + )} + +
+ ) + } return (
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx index 08511bfbdc8f9..93d6359d349ca 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx @@ -11,15 +11,16 @@ import { uuid } from 'lib/utils' import { deleteWithUndo } from 'lib/utils/deleteWithUndo' import posthog from 'posthog-js' import { asDisplay } from 'scenes/persons/person-utils' +import { hogFunctionNewUrl, hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' import { teamLogic } from 'scenes/teamLogic' -import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { groupsModel } from '~/models/groupsModel' import { performQuery } from '~/queries/query' -import { EventsNode, EventsQuery, NodeKind, TrendsQuery } from '~/queries/schema' +import { ActorsQuery, DataTableNode, EventsNode, EventsQuery, NodeKind, TrendsQuery } from '~/queries/schema' import { escapePropertyAsHogQlIdentifier, hogql } from '~/queries/utils' import { + AnyPersonScopeFilter, AnyPropertyFilter, AvailableFeature, BaseMathType, @@ -34,9 +35,6 @@ import { HogFunctionTemplateType, HogFunctionType, PersonType, - PipelineNodeTab, - PipelineStage, - PipelineTab, PropertyFilterType, PropertyGroupFilter, PropertyGroupFilterValue, @@ -127,7 +125,7 @@ const templateToConfiguration = ( hog: template.hog, icon_url: template.icon_url, inputs, - enabled: true, + enabled: template.type !== 'broadcast', } } @@ -183,6 +181,8 @@ 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 }), @@ -256,7 +256,7 @@ export const hogFunctionConfigurationLogic = kea { - if (!props.id) { + if (!props.id || props.id === 'new') { return null } @@ -264,9 +264,10 @@ export const hogFunctionConfigurationLogic = kea { - const res = props.id - ? await api.hogFunctions.update(props.id, configuration) - : await api.hogFunctions.create(configuration) + const res = + props.id && props.id !== 'new' + ? await api.hogFunctions.update(props.id, configuration) + : await api.hogFunctions.create(configuration) posthog.capture('hog function saved', { id: res.id, @@ -289,6 +290,9 @@ export const hogFunctionConfigurationLogic = kea { + if (values.type !== 'destination') { + return null + } if (values.sparkline === null) { await breakpoint(100) } else { @@ -327,16 +331,38 @@ export const hogFunctionConfigurationLogic = kea { + if (values.type !== 'broadcast') { + return null + } + if (values.personsCount === null) { + await breakpoint(100) + } else { + await breakpoint(1000) + } + const result = await performQuery(personsCountQuery) + breakpoint() + return result?.results?.[0]?.[0] ?? null + }, + }, + ], + sampleGlobals: [ null as HogFunctionInvocationGlobals | null, { loadSampleGlobals: async (_, breakpoint) => { + if (!values.lastEventQuery || values.type !== 'destination') { + return values.sampleGlobals + } const errorMessage = 'No events match these filters in the last 30 days. Showing an example $pageview event instead.' try { await breakpoint(values.sampleGlobals === null ? 10 : 1000) let response = await performQuery(values.lastEventQuery) - if (!response?.results?.[0]) { + if (!response?.results?.[0] && values.lastEventSecondQuery) { response = await performQuery(values.lastEventSecondQuery) } if (!response?.results?.[0]) { @@ -653,9 +679,46 @@ export const hogFunctionConfigurationLogic = kea [s.configuration, s.type], + (configuration, type): ActorsQuery | null => { + if (type !== 'broadcast') { + return null + } + return { + kind: NodeKind.ActorsQuery, + properties: configuration.filters?.properties as AnyPersonScopeFilter[] | undefined, + select: ['count()'], + } + }, + { resultEqualityCheck: equal }, + ], + + personsListQuery: [ + (s) => [s.configuration, s.type], + (configuration, type): DataTableNode | null => { + if (type !== 'broadcast') { + return null + } + return { + kind: NodeKind.DataTableNode, + source: { + kind: NodeKind.ActorsQuery, + properties: configuration.filters?.properties as AnyPersonScopeFilter[] | undefined, + select: ['person', 'properties.email', 'created_at'], + }, + full: true, + } + }, + { resultEqualityCheck: equal }, + ], + lastEventQuery: [ - (s) => [s.configuration, s.matchingFilters, s.groupTypes], - (configuration, matchingFilters, groupTypes): EventsQuery => { + (s) => [s.configuration, s.matchingFilters, s.groupTypes, s.type], + (configuration, matchingFilters, groupTypes, type): EventsQuery | null => { + if (type !== 'destination') { + return null + } const query: EventsQuery = { kind: NodeKind.EventsQuery, filterTestAccounts: configuration.filters?.filter_test_accounts, @@ -677,7 +740,7 @@ export const hogFunctionConfigurationLogic = kea [s.lastEventQuery], - (lastEventQuery): EventsQuery => ({ ...lastEventQuery, after: '-30d' }), + (lastEventQuery): EventsQuery | null => (lastEventQuery ? { ...lastEventQuery, after: '-30d' } : null), ], templateHasChanged: [ (s) => [s.hogFunction, s.configuration], @@ -774,13 +837,9 @@ export const hogFunctionConfigurationLogic = kea { @@ -788,13 +847,9 @@ export const hogFunctionConfigurationLogic = kea { @@ -831,7 +886,7 @@ export const hogFunctionConfigurationLogic = kea { @@ -870,7 +923,7 @@ export const hogFunctionConfigurationLogic = kea { - actions.sparklineQueryChanged(sparklineQuery) + if (sparklineQuery) { + actions.sparklineQueryChanged(sparklineQuery) + } }, - - lastEventQuery: () => { - actions.loadSampleGlobals() + personsCountQuery: async (personsCountQuery) => { + if (personsCountQuery) { + actions.personsCountQueryChanged(personsCountQuery) + } + }, + lastEventQuery: (lastEventQuery) => { + if (lastEventQuery) { + actions.loadSampleGlobals() + } }, })), diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx index 45e0d9a523c3c..c309442268f58 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionTestLogic.tsx @@ -31,7 +31,15 @@ export const hogFunctionTestLogic = kea([ connect((props: HogFunctionTestLogicProps) => ({ values: [ hogFunctionConfigurationLogic({ id: props.id }), - ['configuration', 'configurationHasErrors', 'sampleGlobals', 'sampleGlobalsLoading', 'sampleGlobalsError'], + [ + 'configuration', + 'configurationHasErrors', + 'sampleGlobals', + 'sampleGlobalsLoading', + 'exampleInvocationGlobals', + 'sampleGlobalsError', + 'type', + ], groupsModel, ['groupTypes'], ], @@ -61,7 +69,9 @@ export const hogFunctionTestLogic = kea([ }), listeners(({ values, actions }) => ({ loadSampleGlobalsSuccess: () => { - actions.setTestInvocationValue('globals', JSON.stringify(values.sampleGlobals, null, 2)) + if (values.type === 'destination') { + actions.setTestInvocationValue('globals', JSON.stringify(values.sampleGlobals, null, 2)) + } }, })), forms(({ props, actions, values }) => ({ @@ -103,7 +113,25 @@ export const hogFunctionTestLogic = kea([ }, })), - afterMount(({ actions }) => { - actions.setTestInvocationValue('globals', '{/* Please wait, fetching a real event. */}') + afterMount(({ actions, values }) => { + if (values.type === 'email') { + const email = { + from: 'me@example.com', + to: 'you@example.com', + subject: 'Hello', + html: 'hello world', + } + actions.setTestInvocationValue( + 'globals', + JSON.stringify({ email, person: values.exampleInvocationGlobals.person }, null, 2) + ) + } else if (values.type === 'broadcast') { + actions.setTestInvocationValue( + 'globals', + JSON.stringify({ person: values.exampleInvocationGlobals.person }, null, 2) + ) + } else { + actions.setTestInvocationValue('globals', '{/* Please wait, fetching a real event. */}') + } }), ]) diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx index 25a59059e6586..a7598463d54fa 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionsList.tsx @@ -5,6 +5,7 @@ import { LemonMenuOverlay } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { updatedAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' import { useEffect } from 'react' +import { hogFunctionUrl } from 'scenes/pipeline/hogfunctions/urls' import { AppMetricSparkLineV2 } from 'scenes/pipeline/metrics/AppMetricsV2Sparkline' import { urls } from 'scenes/urls' @@ -72,11 +73,7 @@ export function HogFunctionList({ render: (_, hogFunction) => { return ( @@ -96,6 +93,7 @@ export function HogFunctionList({ return ( ( stage: stage as PipelineStage.Destination, backend: PipelineBackend.HogFunction, interval: 'realtime', - id: `hog-${candidate.id}`, + id: candidate.type === 'destination' ? `hog-${candidate.id}` : candidate.id, name: candidate.name, description: candidate.description, enabled: candidate.enabled, diff --git a/frontend/src/scenes/pipeline/utils.tsx b/frontend/src/scenes/pipeline/utils.tsx index 1b3171ec9a087..530c09b9b2df5 100644 --- a/frontend/src/scenes/pipeline/utils.tsx +++ b/frontend/src/scenes/pipeline/utils.tsx @@ -25,6 +25,7 @@ import { PluginType, } from '~/types' +import { hogFunctionUrl } from './hogfunctions/urls' import { pipelineAccessLogic } from './pipelineAccessLogic' import { PluginImage, PluginImageSize } from './PipelinePluginImage' import { @@ -268,14 +269,19 @@ export function pipelineNodeMenuCommonItems(node: Transformation | SiteApp | Imp const items: LemonMenuItem[] = [ { label: canConfigurePlugins ? 'Edit configuration' : 'View configuration', - to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Configuration), + to: + 'hog_function' in node && node.hog_function + ? hogFunctionUrl(node.hog_function.type, node.hog_function.id) + : urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Configuration), }, { label: 'View metrics', + // TODO: metrics URL to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Metrics), }, { label: 'View logs', + // TODO: logs URL to: urls.pipelineNode(node.stage, node.id, PipelineNodeTab.Logs), }, ] diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 4f94e984542d5..7ecee067a5439 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -81,6 +81,8 @@ export enum Scene { MoveToPostHogCloud = 'MoveToPostHogCloud', Heatmaps = 'Heatmaps', SessionAttributionExplorer = 'SessionAttributionExplorer', + MessagingProviders = 'MessagingProviders', + MessagingBroadcasts = 'MessagingBroadcasts', } export type SceneProps = Record diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index c90772eef7fa4..b230d3244117e 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -402,6 +402,14 @@ export const sceneConfigurations: Record = { projectBased: true, name: 'Session attribution explorer (beta)', }, + [Scene.MessagingBroadcasts]: { + projectBased: true, + name: 'Broadcasts', + }, + [Scene.MessagingProviders]: { + projectBased: true, + name: 'Providers', + }, } // NOTE: These redirects will fully replace the URL. If you want to keep support for query and hash params then you should use a function (not string) redirect @@ -469,6 +477,7 @@ export const redirects: Record< '/batch_exports': urls.pipeline(PipelineTab.Destinations), '/apps': urls.pipeline(PipelineTab.Overview), '/apps/:id': ({ id }) => urls.pipelineNode(PipelineStage.Transformation, id), + '/messaging': urls.messagingBroadcasts(), } export const routes: Record = { @@ -584,4 +593,11 @@ export const routes: Record = { [urls.moveToPostHogCloud()]: Scene.MoveToPostHogCloud, [urls.heatmaps()]: Scene.Heatmaps, [urls.sessionAttributionExplorer()]: Scene.SessionAttributionExplorer, + [urls.messagingProviders()]: Scene.MessagingProviders, + [urls.messagingProvider(':id')]: Scene.MessagingProviders, + [urls.messagingProviderNew()]: Scene.MessagingProviders, + [urls.messagingProviderNew(':template')]: Scene.MessagingProviders, + [urls.messagingBroadcasts()]: Scene.MessagingBroadcasts, + [urls.messagingBroadcast(':id')]: Scene.MessagingBroadcasts, + [urls.messagingBroadcastNew()]: Scene.MessagingBroadcasts, } diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 61f59ac76efed..c1d5479aa0635 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -10,7 +10,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { useNotebookNode } from 'scenes/notebooks/Nodes/NotebookNodeContext' import { urls } from 'scenes/urls' -import { RecordingsQuery } from '~/queries/schema' +import { RecordingOrder } from '~/queries/schema' import { RecordingUniversalFilters, ReplayTabs, SessionRecordingType } from '~/types' import { RecordingsUniversalFilters } from '../filters/RecordingsUniversalFilters' @@ -31,7 +31,7 @@ function SortedBy({ filters: RecordingUniversalFilters setFilters: (filters: Partial) => void }): JSX.Element { - const simpleSortingOptions: LemonSelectSection = { + const simpleSortingOptions: LemonSelectSection = { options: [ { value: 'start_time', @@ -47,7 +47,7 @@ function SortedBy({ }, ], } - const detailedSortingOptions: LemonSelectSection = { + const detailedSortingOptions: LemonSelectSection = { options: [ { label: 'Longest', diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 35201c4d0f4cb..bc238b4a35e14 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -16,7 +16,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { objectClean, objectsEqual } from 'lib/utils' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { NodeKind, RecordingsQuery, RecordingsQueryResponse } from '~/queries/schema' +import { NodeKind, RecordingOrder, RecordingsQuery, RecordingsQueryResponse } from '~/queries/schema' import { EntityTypes, FilterLogicalOperator, @@ -225,17 +225,11 @@ function combineLegacyRecordingFilters( } } -function sortRecordings(recordings: SessionRecordingType[], order: RecordingsQuery['order']): SessionRecordingType[] { - const orderKey: - | 'recording_duration' - | 'activity_score' - | 'active_seconds' - | 'inactive_seconds' - | 'console_error_count' - | 'click_count' - | 'keypress_count' - | 'mouse_activity_count' - | 'start_time' = order === 'duration' ? 'recording_duration' : order +function sortRecordings( + recordings: SessionRecordingType[], + order: RecordingsQuery['order'] | 'duration' = 'start_time' +): SessionRecordingType[] { + const orderKey: RecordingOrder = order === 'duration' ? 'recording_duration' : order return recordings.sort((a, b) => { const orderA = a[orderKey] @@ -741,6 +735,9 @@ export const sessionRecordingsPlaylistLogic = kea { - const { setVariable } = useActions(sessionReplayTemplatesLogic(props)) + const { setVariable, resetVariable } = useActions(sessionReplayTemplatesLogic(props)) useMountedLogic(actionsModel) return variable.type === 'pageview' ? ( @@ -80,7 +80,10 @@ const SingleTemplateVariable = ({ {variable.name} setVariable({ ...variable, value: e })} + value={variable.value} + onChange={(e) => + e ? setVariable({ ...variable, value: e }) : resetVariable({ ...variable, value: undefined }) + } size="small" />
@@ -91,7 +94,7 @@ const SingleTemplateVariable = ({ rootKey={`session-recordings-${variable.key}`} group={{ type: FilterLogicalOperator.And, - values: [], + values: variable.filterGroup ? [variable.filterGroup] : [], }} taxonomicGroupTypes={ variable.type === 'event' @@ -103,9 +106,13 @@ const SingleTemplateVariable = ({ : [] } onChange={(thisFilterGroup) => { - variable.type === 'flag' - ? setVariable({ ...variable, value: (thisFilterGroup.values[0] as FeaturePropertyFilter).key }) - : setVariable({ ...variable, filterGroup: thisFilterGroup.values[0] }) + if (thisFilterGroup.values.length === 0) { + resetVariable({ ...variable, filterGroup: undefined }) + } else if (variable.type === 'flag') { + setVariable({ ...variable, value: (thisFilterGroup.values[0] as FeaturePropertyFilter).key }) + } else { + setVariable({ ...variable, filterGroup: thisFilterGroup.values[0] }) + } }} > { const { navigate } = useActions(sessionReplayTemplatesLogic(props)) - const { variables, areAnyVariablesTouched } = useValues(sessionReplayTemplatesLogic(props)) + const { variables, canApplyFilters } = useValues(sessionReplayTemplatesLogic(props)) return (
- {variables.map((variable) => ( - - ))} + {variables + .filter((v) => !v.noTouch) + .map((variable) => ( + + ))}
navigate()} type="primary" className="mt-2" - disabledReason={ - !areAnyVariablesTouched ? 'Please set a value for at least one variable' : undefined - } + disabledReason={!canApplyFilters ? 'Please set a value for at least one variable' : undefined} > Apply filters @@ -145,14 +152,14 @@ const TemplateVariables = (props: RecordingTemplateCardProps): JSX.Element => { } const RecordingTemplateCard = (props: RecordingTemplateCardProps): JSX.Element => { - const { showVariables, hideVariables, navigate } = useActions(sessionReplayTemplatesLogic(props)) - const { variablesVisible, editableVariables } = useValues(sessionReplayTemplatesLogic(props)) + const { showVariables, hideVariables } = useActions(sessionReplayTemplatesLogic(props)) + const { variablesVisible } = useValues(sessionReplayTemplatesLogic(props)) return ( { - editableVariables.length > 0 ? showVariables() : navigate() + showVariables() }} closeable={variablesVisible} onClose={hideVariables} diff --git a/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx b/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx index 47f5418bb5825..04520ca8affdb 100644 --- a/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx +++ b/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx @@ -177,6 +177,7 @@ export const replayTemplates: ReplayTemplateType[] = [ description: 'Watch all recent replays, and see where users are getting stuck.', variables: [], categories: ['More'], + order: 'start_time', icon: , }, { @@ -223,4 +224,12 @@ export const replayTemplates: ReplayTemplateType[] = [ categories: ['More'], icon: , }, + { + key: 'activity-score', + name: 'Most active users', + description: 'Watch recordings of the most active sessions. Lots of valuable insights, guaranteed!', + order: 'activity_score', + categories: ['More'], + icon: , + }, ] diff --git a/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx b/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx index 10e14b031d66b..f44023016729f 100644 --- a/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx +++ b/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx @@ -1,5 +1,8 @@ -import { actions, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import clsx from 'clsx' +import { actions, connect, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { router } from 'kea-router' +import posthog from 'posthog-js' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { @@ -64,20 +67,35 @@ export const sessionReplayTemplatesLogic = kea( path(() => ['scenes', 'session-recordings', 'templates', 'sessionReplayTemplatesLogic']), props({} as ReplayTemplateLogicPropsType), key((props) => `${props.category}-${props.template.key}`), + connect({ + values: [teamLogic, ['currentTeam']], + }), actions({ - setVariables: (variables: ReplayTemplateVariableType[]) => ({ variables }), + setVariables: (variables?: ReplayTemplateVariableType[]) => ({ variables }), setVariable: (variable: ReplayTemplateVariableType) => ({ variable }), + resetVariable: (variable: ReplayTemplateVariableType) => ({ variable }), navigate: true, showVariables: true, hideVariables: true, }), - reducers(({ props }) => ({ + reducers(({ props, values }) => ({ variables: [ - props.template.variables, + props.template.variables ?? [], + { + persist: true, + storageKey: clsx( + 'session-recordings.templates.variables', + values.currentTeam?.id, + props.category, + props.template.key + ), + }, { - setVariables: (_, { variables }) => variables, + setVariables: (_, { variables }) => variables ?? [], setVariable: (state, { variable }) => state.map((v) => (v.key === variable.key ? { ...variable, touched: true } : v)), + resetVariable: (state, { variable }) => + state.map((v) => (v.key === variable.key ? { ...variable, touched: false } : v)), }, ], variablesVisible: [ @@ -125,21 +143,31 @@ export const sessionReplayTemplatesLogic = kea( return filterGroup }, ], + canApplyFilters: [ + (s) => [s.variables, s.areAnyVariablesTouched], + (variables, areAnyVariablesTouched) => areAnyVariablesTouched || variables.length === 0, + ], areAnyVariablesTouched: [ (s) => [s.variables], (variables) => variables.some((v) => v.touched) || variables.some((v) => v.noTouch), ], editableVariables: [(s) => [s.variables], (variables) => variables.filter((v) => !v.noTouch)], }), - listeners(({ values }) => ({ + listeners(({ values, props }) => ({ navigate: () => { + posthog.capture('session replay template used', { + template: props.template.key, + category: props.category, + }) const filterGroup = values.variables.length > 0 ? values.filterGroup : undefined - router.actions.push(urls.replay(ReplayTabs.Home, filterGroup)) + router.actions.push(urls.replay(ReplayTabs.Home, filterGroup, undefined, props.template.order)) }, })), - events(({ actions, props }) => ({ + events(({ actions, props, values }) => ({ afterMount: () => { - actions.setVariables(props.template.variables) + if (values.variables.length === 0) { + actions.setVariables(props.template.variables) + } }, })), ]) diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 05f0372b7f8c8..e3c85d33260c7 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -110,10 +110,16 @@ export const urls = { savedInsights: (tab?: string): string => `/insights${tab ? `?tab=${tab}` : ''}`, webAnalytics: (): string => `/web`, - replay: (tab?: ReplayTabs, filters?: Partial, sessionRecordingId?: string): string => + replay: ( + tab?: ReplayTabs, + filters?: Partial, + sessionRecordingId?: string, + order?: string + ): string => combineUrl(tab ? `/replay/${tab}` : '/replay/home', { ...(filters ? { filters } : {}), ...(sessionRecordingId ? { sessionRecordingId } : {}), + ...(order ? { order } : {}), }).url, replayPlaylist: (id: string): string => `/replay/playlists/${id}`, replaySingle: (id: string): string => `/replay/${id}`, @@ -137,6 +143,12 @@ export const urls = { `/pipeline/${!stage.startsWith(':') && !stage?.endsWith('s') ? `${stage}s` : stage}/${id}${ nodeTab ? `/${nodeTab}` : '' }`, + messagingBroadcasts: (): string => '/messaging/broadcasts', + messagingBroadcast: (id?: string): string => `/messaging/broadcasts/${id}`, + messagingBroadcastNew: (): string => '/messaging/broadcasts/new', + messagingProviders: (): string => '/messaging/providers', + messagingProvider: (id?: string): string => `/messaging/providers/${id}`, + messagingProviderNew: (template?: string): string => '/messaging/providers/new' + (template ? `/${template}` : ''), groups: (groupTypeIndex: string | number): string => `/groups/${groupTypeIndex}`, // :TRICKY: Note that groupKey is provided by user. We need to override urlPatternOptions for kea-router. group: (groupTypeIndex: string | number, groupKey: string, encode: boolean = true, tab?: string | null): string => diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 86a20ea4cd23b..b01a6a080d5c9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -40,6 +40,7 @@ import type { InsightVizNode, Node, QueryStatus, + RecordingOrder, RecordingsQuery, } from './queries/schema' import { NodeKind } from './queries/schema' @@ -4639,9 +4640,10 @@ export type ReplayTemplateType = { key: string name: string description: string - variables: ReplayTemplateVariableType[] + variables?: ReplayTemplateVariableType[] categories: ReplayTemplateCategory[] icon?: React.ReactNode + order?: RecordingOrder } export type ReplayTemplateCategory = 'B2B' | 'B2C' | 'More' diff --git a/mypy-baseline.txt b/mypy-baseline.txt index 6f5de8a4ccf38..57a7e095f6298 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -808,7 +808,6 @@ posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index] posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index] posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index] -posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index] posthog/api/test/batch_exports/conftest.py:0: error: Signature of "run" incompatible with supertype "Worker" [override] posthog/api/test/batch_exports/conftest.py:0: note: Superclass: posthog/api/test/batch_exports/conftest.py:0: note: def run(self) -> Coroutine[Any, Any, None] diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py index befb986262ee2..2f68614e50a02 100644 --- a/posthog/api/hog_function_template.py +++ b/posthog/api/hog_function_template.py @@ -28,22 +28,19 @@ class Meta: # NOTE: There is nothing currently private about these values class PublicHogFunctionTemplateViewSet(viewsets.GenericViewSet): filter_backends = [DjangoFilterBackend] - filterset_fields = ["id", "team", "created_by", "enabled"] + filterset_fields = ["id", "team", "created_by", "enabled", "type"] permission_classes = [permissions.AllowAny] serializer_class = HogFunctionTemplateSerializer - def _get_templates(self): - type = self.request.GET.get("type", "destination") - return [item for item in HOG_FUNCTION_TEMPLATES if item.type == type] - def list(self, request: Request, *args, **kwargs): - page = self.paginate_queryset(self._get_templates()) + type = self.request.GET.get("type", "destination") + templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type == type] + page = self.paginate_queryset(templates) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) def retrieve(self, request: Request, *args, **kwargs): - data = self._get_templates() - item = next((item for item in data if item.id == kwargs["pk"]), None) + item = next((item for item in HOG_FUNCTION_TEMPLATES if item.id == kwargs["pk"]), None) if not item: raise NotFound(f"Template with id {kwargs['pk']} not found.") diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py index d8c414f54dd03..1acedd79c25d5 100644 --- a/posthog/cdp/templates/__init__.py +++ b/posthog/cdp/templates/__init__.py @@ -14,6 +14,7 @@ from .mailjet.template_mailjet import ( template_create_contact as mailjet_create_contact, template_update_contact_list as mailjet_update_contact_list, + template_send_email as mailset_send_email, ) from .zapier.template_zapier import template as zapier from .mailgun.template_mailgun import template_mailgun_send_email as mailgun @@ -37,8 +38,10 @@ TemplateGoogleCloudStorageMigrator, ) from .airtable.template_airtable import template as airtable +from ._internal.template_broadcast import template_new_broadcast as _broadcast HOG_FUNCTION_TEMPLATES = [ + _broadcast, slack, webhook, activecampaign, @@ -66,6 +69,7 @@ mailgun, mailjet_create_contact, mailjet_update_contact_list, + mailset_send_email, meta_ads, microsoft_teams, posthog, diff --git a/posthog/cdp/templates/_internal/template_broadcast.py b/posthog/cdp/templates/_internal/template_broadcast.py new file mode 100644 index 0000000000000..fa380de5642a6 --- /dev/null +++ b/posthog/cdp/templates/_internal/template_broadcast.py @@ -0,0 +1,206 @@ +from posthog.cdp.templates.hog_function_template import HogFunctionTemplate + +template_new_broadcast: HogFunctionTemplate = HogFunctionTemplate( + status="beta", + type="broadcast", + id="template-new-broadcast", + name="Hello !", + description="This is a broadcast", + icon_url="/static/hedgehog/explorer-hog.png", + category=["Email Marketing"], + hog="""import('provider/email').sendEmail(inputs.email)""".strip(), + inputs_schema=[ + { + "key": "email", + "type": "email", + "label": "Email", + "default": { + "to": "{person.properties.email}", + "body": "Hello {person.properties.first_name} {person.properties.last_name}!\n\nThis is a broadcast", + "from": "info@posthog.com", + "subject": "Hello {person.properties.email}", + "html": '\n\n \n \n \n \n \n \n \n \n \n\n\n\n\n \n \n \n \n \n \n \n \n
\n \n \n \n \n
\n
\n
\n \n \n\n
\n
\n
\n \n\n \n \n \n \n \n
\n \n
\n

Hello from PostHog!

\n
\n\n
\n\n\n \n \n \n \n \n
\n \n \n
\n \n \n Learn more\n \n \n
\n\n
\n\n
\n
\n
\n\n \n
\n
\n
\n \n\n\n \n
\n \n \n\n\n\n', + "design": { + "body": { + "id": "TlJ2GekAva", + "rows": [ + { + "id": "-sL7LJ6rhD", + "cells": [1], + "values": { + "_meta": {"htmlID": "u_row_1", "htmlClassNames": "u_row"}, + "anchor": "", + "columns": False, + "padding": "0px", + "hideable": True, + "deletable": True, + "draggable": True, + "selectable": True, + "_styleGuide": None, + "hideDesktop": False, + "duplicatable": True, + "backgroundColor": "", + "backgroundImage": { + "url": "", + "size": "custom", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + "customPosition": ["50%", "50%"], + }, + "displayCondition": None, + "columnsBackgroundColor": "", + }, + "columns": [ + { + "id": "IT_J8vPWTn", + "values": { + "_meta": {"htmlID": "u_column_1", "htmlClassNames": "u_column"}, + "border": {}, + "padding": "0px", + "borderRadius": "0px", + "backgroundColor": "", + }, + "contents": [ + { + "id": "BSAz3H7CN9", + "type": "text", + "values": { + "text": '

Hello from PostHog!

', + "_meta": { + "htmlID": "u_content_text_1", + "htmlClassNames": "u_content_text", + }, + "anchor": "", + "fontSize": "14px", + "hideable": True, + "deletable": True, + "draggable": True, + "linkStyle": { + "inherit": True, + "linkColor": "#0000ee", + "linkUnderline": True, + "linkHoverColor": "#0000ee", + "linkHoverUnderline": True, + }, + "textAlign": "left", + "_languages": {}, + "lineHeight": "140%", + "selectable": True, + "_styleGuide": None, + "duplicatable": True, + "containerPadding": "10px", + "displayCondition": None, + }, + }, + { + "id": "cAnEDJLdIg", + "type": "button", + "values": { + "href": { + "name": "web", + "attrs": {"href": "{{href}}", "target": "{{target}}"}, + "values": {"href": "https://posthog.com/", "target": "_blank"}, + }, + "size": {"width": "100%", "autoWidth": True}, + "text": "Learn more", + "_meta": { + "htmlID": "u_content_button_1", + "htmlClassNames": "u_content_button", + }, + "anchor": "", + "border": {}, + "padding": "10px 20px", + "fontSize": "14px", + "hideable": True, + "deletable": True, + "draggable": True, + "textAlign": "center", + "_languages": {}, + "lineHeight": "120%", + "selectable": True, + "_styleGuide": None, + "borderRadius": "4px", + "buttonColors": { + "color": "#FFFFFF", + "hoverColor": "#FFFFFF", + "backgroundColor": "#3AAEE0", + "hoverBackgroundColor": "#3AAEE0", + }, + "duplicatable": True, + "calculatedWidth": 112, + "calculatedHeight": 37, + "containerPadding": "10px", + "displayCondition": None, + }, + }, + ], + } + ], + } + ], + "values": { + "_meta": {"htmlID": "u_body", "htmlClassNames": "u_body"}, + "language": {}, + "linkStyle": { + "body": True, + "linkColor": "#0000ee", + "linkUnderline": True, + "linkHoverColor": "#0000ee", + "linkHoverUnderline": True, + }, + "textColor": "#000000", + "fontFamily": {"label": "Arial", "value": "arial,helvetica,sans-serif"}, + "popupWidth": "600px", + "_styleGuide": None, + "popupHeight": "auto", + "borderRadius": "10px", + "contentAlign": "center", + "contentWidth": "500px", + "popupPosition": "center", + "preheaderText": "", + "backgroundColor": "#F7F8F9", + "backgroundImage": { + "url": "", + "size": "custom", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + }, + "contentVerticalAlign": "center", + "popupBackgroundColor": "#FFFFFF", + "popupBackgroundImage": { + "url": "", + "size": "cover", + "repeat": "no-repeat", + "position": "center", + "fullWidth": True, + }, + "popupCloseButton_action": { + "name": "close_popup", + "attrs": { + "onClick": "document.querySelector('.u-popup-container').style.display = 'none';" + }, + }, + "popupCloseButton_margin": "0px", + "popupCloseButton_position": "top-right", + "popupCloseButton_iconColor": "#000000", + "popupOverlay_backgroundColor": "rgba(0, 0, 0, 0.1)", + "popupCloseButton_borderRadius": "0px", + "popupCloseButton_backgroundColor": "#DDDDDD", + }, + "footers": [], + "headers": [], + }, + "counters": {"u_row": 1, "u_column": 1, "u_content_text": 1, "u_content_button": 1}, + "schemaVersion": 17, + }, + }, + "secret": False, + "required": True, + }, + ], + filters={ + "properties": [{"key": "email", "value": "is_set", "operator": "is_set", "type": "person"}], + }, +) diff --git a/posthog/cdp/templates/mailjet/template_mailjet.py b/posthog/cdp/templates/mailjet/template_mailjet.py index a089372daefb8..23cf5e07da0f4 100644 --- a/posthog/cdp/templates/mailjet/template_mailjet.py +++ b/posthog/cdp/templates/mailjet/template_mailjet.py @@ -3,24 +3,22 @@ # See https://dev.mailjet.com/email/reference/contacts/contact-list/ -common_inputs_schemas = [ - { - "key": "api_key", - "type": "string", - "label": "Mailjet API Key", - "secret": True, - "required": True, - }, - { - "key": "email", - "type": "string", - "label": "Email of the user", - "description": "Where to find the email for the user to be checked with Mailjet", - "default": "{person.properties.email}", - "secret": False, - "required": True, - }, -] +input_api_key = { + "key": "api_key", + "type": "string", + "label": "Mailjet API Key", + "secret": True, + "required": True, +} +input_email = { + "key": "email", + "type": "string", + "label": "Email of the user", + "description": "Where to find the email for the user to be checked with Mailjet", + "default": "{person.properties.email}", + "secret": False, + "required": True, +} common_filters = { "events": [{"id": "$identify", "name": "$identify", "type": "events", "order": 0}], @@ -56,7 +54,8 @@ }) """.strip(), inputs_schema=[ - *common_inputs_schemas, + input_api_key, + input_email, { "key": "name", "type": "string", @@ -110,7 +109,8 @@ }) """.strip(), inputs_schema=[ - *common_inputs_schemas, + input_api_key, + input_email, { "key": "contact_list_id", "type": "string", @@ -148,3 +148,56 @@ ], filters=common_filters, ) + + +template_send_email: HogFunctionTemplate = HogFunctionTemplate( + status="beta", + type="email", + id="template-mailjet-send-email", + name="Mailjet", + description="Send an email with Mailjet", + icon_url="/static/services/mailjet.png", + category=["Email Provider"], + hog=""" +fun sendEmail(email) { + fetch(f'https://api.mailjet.com/v3.1/send', { + 'method': 'POST', + 'headers': { + 'Authorization': f'Bearer {inputs.api_key}', + 'Content-Type': 'application/json' + }, + 'body': { + 'Messages': [ + { + 'From': { + 'Email': inputs.email.from, + 'Name': '' + }, + 'To': [ + { + 'Email': inputs.email.to, + 'Name': '' + } + ], + 'Subject': inputs.email.subject, + 'HTMLPart': inputs.email.html + } + ] + } + }) +} +// TODO: support the "export" keyword in front of functions +return {'sendEmail': sendEmail} +""".strip(), + inputs_schema=[ + input_api_key, + { + "key": "from_email", + "type": "string", + "label": "Email to send from", + "secret": False, + "required": True, + }, + ], + filters=common_filters, +) diff --git a/posthog/hogql/database/schema/persons.py b/posthog/hogql/database/schema/persons.py index 89e7d6775b2fa..cb7444756f417 100644 --- a/posthog/hogql/database/schema/persons.py +++ b/posthog/hogql/database/schema/persons.py @@ -64,7 +64,6 @@ def select_from_persons_table( and_conditions.append(filter) # For now, only do this optimization for directly querying the persons table (without joins or as part of a subquery) to avoid knock-on effects to insight queries - if ( node.select_from and node.select_from.type diff --git a/posthog/hogql/database/schema/test/__snapshots__/test_persons.ambr b/posthog/hogql/database/schema/test/__snapshots__/test_persons.ambr index 8f0b094ae70a5..22467525c8c38 100644 --- a/posthog/hogql/database/schema/test/__snapshots__/test_persons.ambr +++ b/posthog/hogql/database/schema/test/__snapshots__/test_persons.ambr @@ -139,7 +139,12 @@ WHERE and(equals(person.team_id, 2), in(id, (SELECT where_optimization.id AS id FROM person AS where_optimization - WHERE and(equals(where_optimization.team_id, 2), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'something'), 0))))) + WHERE and(equals(where_optimization.team_id, 2), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(where_optimization.properties, '$some_prop'), ''), 'null'), '^"|"$', ''), 'something'), 0), notIn(where_optimization.id, + (SELECT limit_delete_optimization.id AS id + FROM person AS limit_delete_optimization + WHERE and(equals(limit_delete_optimization.team_id, 2), equals(limit_delete_optimization.is_deleted, 1))))) + LIMIT 1 BY where_optimization.id + LIMIT 100))) GROUP BY person.id HAVING and(ifNull(equals(argMax(person.is_deleted, person.version), 0), 0), ifNull(less(argMax(toTimeZone(person.created_at, 'UTC'), person.version), plus(now64(6, 'UTC'), toIntervalDay(1))), 0))) AS persons WHERE ifNull(equals(persons.`properties___$some_prop`, 'something'), 0) diff --git a/posthog/schema.py b/posthog/schema.py index b04a1bb6ebdf6..b386d5d6c8e97 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -1108,6 +1108,19 @@ class QueryTiming(BaseModel): t: float = Field(..., description="Time in seconds. Shortened to 't' to save on data.") +class RecordingOrder(StrEnum): + DURATION = "duration" + RECORDING_DURATION = "recording_duration" + INACTIVE_SECONDS = "inactive_seconds" + ACTIVE_SECONDS = "active_seconds" + START_TIME = "start_time" + CONSOLE_ERROR_COUNT = "console_error_count" + CLICK_COUNT = "click_count" + KEYPRESS_COUNT = "keypress_count" + MOUSE_ACTIVITY_COUNT = "mouse_activity_count" + ACTIVITY_SCORE = "activity_score" + + class RecordingPropertyFilter(BaseModel): model_config = ConfigDict( extra="forbid", @@ -5343,7 +5356,7 @@ class RecordingsQuery(BaseModel): ) offset: Optional[int] = None operand: Optional[FilterLogicalOperator] = None - order: Union[DurationType, str] + order: Optional[RecordingOrder] = None person_uuid: Optional[str] = None properties: Optional[ list[ diff --git a/posthog/temporal/data_imports/external_data_job.py b/posthog/temporal/data_imports/external_data_job.py index 1f44942a422b7..01367cb710812 100644 --- a/posthog/temporal/data_imports/external_data_job.py +++ b/posthog/temporal/data_imports/external_data_job.py @@ -2,6 +2,7 @@ import datetime as dt import json +import posthoganalytics from temporalio import activity, exceptions, workflow from temporalio.common import RetryPolicy @@ -21,6 +22,7 @@ create_external_data_job_model_activity, ) from posthog.temporal.data_imports.workflow_activities.import_data import ImportDataActivityInputs, import_data_activity +from posthog.utils import get_machine_id from posthog.warehouse.data_load.service import ( a_delete_external_data_schedule, a_external_data_workflow_exists, @@ -37,16 +39,27 @@ ExternalDataJob, get_active_schemas_for_source_id, ExternalDataSource, + get_external_data_source, ) from posthog.temporal.common.logger import bind_temporal_worker_logger from posthog.warehouse.models.external_data_schema import aupdate_should_sync -Non_Retryable_Schema_Errors = [ - "NoSuchTableError", - "401 Client Error: Unauthorized for url: https://api.stripe.com", - "403 Client Error: Forbidden for url: https://api.stripe.com", -] +Non_Retryable_Schema_Errors: dict[ExternalDataSource.Type, list[str]] = { + ExternalDataSource.Type.STRIPE: [ + "401 Client Error: Unauthorized for url: https://api.stripe.com", + "403 Client Error: Forbidden for url: https://api.stripe.com", + ], + ExternalDataSource.Type.POSTGRES: [ + "NoSuchTableError", + "is not permitted to log in", + "Tenant or user not found connection to server", + "FATAL: Tenant or user not found", + "error received from server in SCRAM exchange: Wrong password", + "could not translate host name", + ], + ExternalDataSource.Type.ZENDESK: ["404 Client Error: Not Found for url", "403 Client Error: Forbidden for url"], +} @dataclasses.dataclass @@ -54,6 +67,7 @@ class UpdateExternalDataJobStatusInputs: team_id: int job_id: str | None schema_id: str + source_id: str status: str internal_error: str | None latest_error: str | None @@ -78,10 +92,26 @@ async def update_external_data_job_model(inputs: UpdateExternalDataJobStatusInpu f"External data job failed for external data schema {inputs.schema_id} with error: {inputs.internal_error}" ) - has_non_retryable_error = any(error in inputs.internal_error for error in Non_Retryable_Schema_Errors) - if has_non_retryable_error: - logger.info("Schema has a non-retryable error - turning off syncing") - await aupdate_should_sync(schema_id=inputs.schema_id, team_id=inputs.team_id, should_sync=False) + source: ExternalDataSource = await get_external_data_source(inputs.source_id) + non_retryable_errors = Non_Retryable_Schema_Errors.get(ExternalDataSource.Type(source.source_type)) + + if non_retryable_errors is not None: + has_non_retryable_error = any(error in inputs.internal_error for error in non_retryable_errors) + if has_non_retryable_error: + logger.info("Schema has a non-retryable error - turning off syncing") + posthoganalytics.capture( + get_machine_id(), + "schema non-retryable error", + { + "schemaId": inputs.schema_id, + "sourceId": inputs.source_id, + "sourceType": source.source_type, + "jobId": inputs.job_id, + "teamId": inputs.team_id, + "error": inputs.internal_error, + }, + ) + await aupdate_should_sync(schema_id=inputs.schema_id, team_id=inputs.team_id, should_sync=False) await aupdate_external_job_status( job_id=job_id, @@ -166,6 +196,7 @@ async def run(self, inputs: ExternalDataWorkflowInputs): internal_error=None, team_id=inputs.team_id, schema_id=str(inputs.external_data_schema_id), + source_id=str(inputs.external_data_source_id), ) try: diff --git a/posthog/temporal/tests/data_imports/test_end_to_end.py b/posthog/temporal/tests/data_imports/test_end_to_end.py index cb92492b29c4c..98d81cc153bbe 100644 --- a/posthog/temporal/tests/data_imports/test_end_to_end.py +++ b/posthog/temporal/tests/data_imports/test_end_to_end.py @@ -6,6 +6,7 @@ from asgiref.sync import sync_to_async from django.conf import settings from django.test import override_settings +import posthoganalytics import pytest import pytest_asyncio import psycopg @@ -905,3 +906,56 @@ def get_jobs(): with pytest.raises(Exception): await sync_to_async(execute_hogql_query)("SELECT * FROM stripe_customer", team) + + +@pytest.mark.django_db(transaction=True) +@pytest.mark.asyncio +async def test_non_retryable_error(team, stripe_customer): + source = await sync_to_async(ExternalDataSource.objects.create)( + source_id=uuid.uuid4(), + connection_id=uuid.uuid4(), + destination_id=uuid.uuid4(), + team=team, + status="running", + source_type="Stripe", + job_inputs={"stripe_secret_key": "test-key", "stripe_account_id": "acct_id"}, + ) + + schema = await sync_to_async(ExternalDataSchema.objects.create)( + name="Customer", + team_id=team.pk, + source_id=source.pk, + sync_type=ExternalDataSchema.SyncType.FULL_REFRESH, + sync_type_config={}, + ) + + workflow_id = str(uuid.uuid4()) + inputs = ExternalDataWorkflowInputs( + team_id=team.id, + external_data_source_id=source.pk, + external_data_schema_id=schema.id, + ) + + with ( + mock.patch( + "posthog.temporal.data_imports.workflow_activities.check_billing_limits.list_limited_team_attributes", + ) as mock_list_limited_team_attributes, + mock.patch.object(posthoganalytics, "capture") as capture_mock, + ): + mock_list_limited_team_attributes.side_effect = Exception( + "401 Client Error: Unauthorized for url: https://api.stripe.com" + ) + + with pytest.raises(Exception): + await _execute_run(workflow_id, inputs, stripe_customer["data"]) + + capture_mock.assert_called_once() + + job: ExternalDataJob = await sync_to_async(ExternalDataJob.objects.get)(team_id=team.id, schema_id=schema.pk) + await sync_to_async(schema.refresh_from_db)() + + assert job.status == ExternalDataJob.Status.FAILED + assert schema.should_sync is False + + with pytest.raises(Exception): + await sync_to_async(execute_hogql_query)("SELECT * FROM stripe_customer", team) diff --git a/posthog/temporal/tests/external_data/test_external_data_job.py b/posthog/temporal/tests/external_data/test_external_data_job.py index d5839cd4ce0ef..1b5b3d692d5ed 100644 --- a/posthog/temporal/tests/external_data/test_external_data_job.py +++ b/posthog/temporal/tests/external_data/test_external_data_job.py @@ -252,6 +252,7 @@ async def test_update_external_job_activity(activity_environment, team, **kwargs latest_error=None, internal_error=None, schema_id=str(schema.pk), + source_id=str(new_source.pk), team_id=team.id, ) @@ -296,6 +297,7 @@ async def test_update_external_job_activity_with_retryable_error(activity_enviro latest_error=None, internal_error="Some other retryable error", schema_id=str(schema.pk), + source_id=str(new_source.pk), team_id=team.id, ) @@ -317,11 +319,11 @@ async def test_update_external_job_activity_with_non_retryable_error(activity_en destination_id=uuid.uuid4(), team=team, status="running", - source_type="Stripe", + source_type="Postgres", ) schema = await sync_to_async(ExternalDataSchema.objects.create)( - name=PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING[new_source.source_type][0], + name="test_123", team_id=team.id, source_id=new_source.pk, should_sync=True, @@ -341,6 +343,7 @@ async def test_update_external_job_activity_with_non_retryable_error(activity_en latest_error=None, internal_error="NoSuchTableError: TableA", schema_id=str(schema.pk), + source_id=str(new_source.pk), team_id=team.id, ) with mock.patch("posthog.warehouse.models.external_data_schema.external_data_workflow_exists", return_value=False):