diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nuxt.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nuxt.tsx
index e57a46e534d61..baa4947ad4950 100644
--- a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nuxt.tsx
+++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nuxt.tsx
@@ -35,7 +35,7 @@ import posthog from 'posthog-js'
export default defineNuxtPlugin(nuxtApp => {
const runtimeConfig = useRuntimeConfig();
const posthogClient = posthog.init(runtimeConfig.public.posthogPublicKey, {
- api_host: runtimeConfig.public.posthogHost',
+ api_host: runtimeConfig.public.posthogHost,
${
isPersonProfilesDisabled
? ``
diff --git a/frontend/src/scenes/pipeline/Destinations.tsx b/frontend/src/scenes/pipeline/Destinations.tsx
index 61af57f3061c5..500111bdc5d1b 100644
--- a/frontend/src/scenes/pipeline/Destinations.tsx
+++ b/frontend/src/scenes/pipeline/Destinations.tsx
@@ -57,10 +57,14 @@ export function DestinationsTable({ inOverview = false }: { inOverview?: boolean
title: 'App',
width: 0,
render: function RenderAppInfo(_, destination) {
- if (destination.backend === 'plugin') {
- return
+ switch (destination.backend) {
+ case 'plugin':
+ return
+ case 'batch_export':
+ return
+ default:
+ return null
}
- return
},
},
{
diff --git a/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx b/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx
index 61d09e36015c4..926469a85ffd6 100644
--- a/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx
+++ b/frontend/src/scenes/pipeline/PipelineNodeConfiguration.tsx
@@ -1,6 +1,7 @@
import { useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
+import { PipelineHogFunctionConfiguration } from './hogfunctions/PipelineHogFunctionConfiguration'
import { PipelineBatchExportConfiguration } from './PipelineBatchExportConfiguration'
import { pipelineNodeLogic } from './pipelineNodeLogic'
import { PipelinePluginConfiguration } from './PipelinePluginConfiguration'
@@ -15,7 +16,9 @@ export function PipelineNodeConfiguration(): JSX.Element {
return (
- {node.backend === PipelineBackend.Plugin ? (
+ {node.backend === PipelineBackend.HogFunction ? (
+
+ ) : node.backend === PipelineBackend.Plugin ? (
) : (
diff --git a/frontend/src/scenes/pipeline/PipelineNodeNew.tsx b/frontend/src/scenes/pipeline/PipelineNodeNew.tsx
index 30504da3fe51d..76eea7518a6b0 100644
--- a/frontend/src/scenes/pipeline/PipelineNodeNew.tsx
+++ b/frontend/src/scenes/pipeline/PipelineNodeNew.tsx
@@ -1,17 +1,20 @@
import { IconPlusSmall } from '@posthog/icons'
import { useValues } from 'kea'
+import { combineUrl, router } from 'kea-router'
import { NotFound } from 'lib/components/NotFound'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonTable } from 'lib/lemon-ui/LemonTable'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
-import { AvailableFeature, BatchExportService, PipelineStage, PluginType } from '~/types'
+import { AvailableFeature, BatchExportService, HogFunctionTemplateType, PipelineStage, PluginType } from '~/types'
import { pipelineDestinationsLogic } from './destinationsLogic'
import { frontendAppsLogic } from './frontendAppsLogic'
+import { PipelineHogFunctionConfiguration } from './hogfunctions/PipelineHogFunctionConfiguration'
import { PipelineBatchExportConfiguration } from './PipelineBatchExportConfiguration'
import { PIPELINE_TAB_TO_NODE_STAGE } from './PipelineNode'
import { pipelineNodeNewLogic, PipelineNodeNewLogicProps } from './pipelineNodeNewLogic'
@@ -21,21 +24,20 @@ import { PipelineBackend } from './types'
import { getBatchExportUrl, RenderApp, RenderBatchExportIcon } from './utils'
const paramsToProps = ({
- params: { stage, pluginIdOrBatchExportDestination },
+ params: { stage, id },
}: {
- params: { stage?: string; pluginIdOrBatchExportDestination?: string }
+ params: { stage?: string; id?: string }
}): PipelineNodeNewLogicProps => {
- const numericId =
- pluginIdOrBatchExportDestination && /^\d+$/.test(pluginIdOrBatchExportDestination)
- ? parseInt(pluginIdOrBatchExportDestination)
- : undefined
+ const numericId = id && /^\d+$/.test(id) ? parseInt(id) : undefined
const pluginId = numericId && !isNaN(numericId) ? numericId : null
- const batchExportDestination = pluginId ? null : pluginIdOrBatchExportDestination ?? null
+ const hogFunctionId = pluginId ? null : id?.startsWith('hog-') ? id.slice(4) : null
+ const batchExportDestination = hogFunctionId ? null : id ?? null
return {
stage: PIPELINE_TAB_TO_NODE_STAGE[stage + 's'] || null, // pipeline tab has stage plural here we have singular
- pluginId: pluginId,
- batchExportDestination: batchExportDestination,
+ pluginId,
+ batchExportDestination,
+ hogFunctionId,
}
}
@@ -45,32 +47,22 @@ export const scene: SceneExport = {
paramsToProps,
}
-type PluginEntry = {
- backend: PipelineBackend.Plugin
- id: number
+type TableEntry = {
+ backend: PipelineBackend
+ id: string | number
name: string
description: string
- plugin: PluginType
url?: string
+ icon: JSX.Element
}
-type BatchExportEntry = {
- backend: PipelineBackend.BatchExport
- id: BatchExportService['type']
- name: string
- description: string
- url: string
-}
-
-type TableEntry = PluginEntry | BatchExportEntry
-
function convertPluginToTableEntry(plugin: PluginType): TableEntry {
return {
backend: PipelineBackend.Plugin,
id: plugin.id,
name: plugin.name,
description: plugin.description || '',
- plugin: plugin,
+ icon:
,
// TODO: ideally we'd link to docs instead of GitHub repo, so it can open in panel
// Same for transformations and destinations tables
url: plugin.url,
@@ -80,17 +72,26 @@ function convertPluginToTableEntry(plugin: PluginType): TableEntry {
function convertBatchExportToTableEntry(service: BatchExportService['type']): TableEntry {
return {
backend: PipelineBackend.BatchExport,
- id: service,
+ id: service as string,
name: service,
description: `${service} batch export`,
+ icon:
,
url: getBatchExportUrl(service),
}
}
-export function PipelineNodeNew(
- params: { stage?: string; pluginIdOrBatchExportDestination?: string } = {}
-): JSX.Element {
- const { stage, pluginId, batchExportDestination } = paramsToProps({ params })
+function convertHogFunctionToTableEntry(hogFunction: HogFunctionTemplateType): TableEntry {
+ return {
+ backend: PipelineBackend.HogFunction,
+ id: `hog-${hogFunction.id}`, // TODO: This weird identifier thing isn't great
+ name: hogFunction.name,
+ description: hogFunction.description,
+ icon:
🦔,
+ }
+}
+
+export function PipelineNodeNew(params: { stage?: string; id?: string } = {}): JSX.Element {
+ const { stage, pluginId, batchExportDestination, hogFunctionId } = paramsToProps({ params })
if (!stage) {
return
@@ -103,6 +104,7 @@ export function PipelineNodeNew(
}
return res
}
+
if (batchExportDestination) {
if (stage !== PipelineStage.Destination) {
return
@@ -114,6 +116,14 @@ export function PipelineNodeNew(
)
}
+ if (hogFunctionId) {
+ const res =
+ if (stage === PipelineStage.Destination) {
+ return
{res}
+ }
+ return res
+ }
+
if (stage === PipelineStage.Transformation) {
return
} else if (stage === PipelineStage.Destination) {
@@ -135,11 +145,15 @@ function TransformationOptionsTable(): JSX.Element {
}
function DestinationOptionsTable(): JSX.Element {
+ const hogFunctionsEnabled = !!useFeatureFlag('HOG_FUNCTIONS')
const { batchExportServiceNames } = useValues(pipelineNodeNewLogic)
- const { plugins, loading } = useValues(pipelineDestinationsLogic)
+ const { plugins, loading, hogFunctionTemplates } = useValues(pipelineDestinationsLogic)
const pluginTargets = Object.values(plugins).map(convertPluginToTableEntry)
const batchExportTargets = Object.values(batchExportServiceNames).map(convertBatchExportToTableEntry)
- const targets = [...batchExportTargets, ...pluginTargets]
+ const hogFunctionTargets = hogFunctionsEnabled
+ ? Object.values(hogFunctionTemplates).map(convertHogFunctionToTableEntry)
+ : []
+ const targets = [...batchExportTargets, ...pluginTargets, ...hogFunctionTargets]
return
}
@@ -158,6 +172,7 @@ function NodeOptionsTable({
targets: TableEntry[]
loading: boolean
}): JSX.Element {
+ const { hashParams } = useValues(router)
return (
<>
- }
- return
+ return target.icon
},
},
{
@@ -198,7 +210,8 @@ function NodeOptionsTable({
type="primary"
data-attr={`new-${stage}-${target.id}`}
icon={
}
- to={urls.pipelineNodeNew(stage, target.id)}
+ // Preserve hash params to pass config in
+ to={combineUrl(urls.pipelineNodeNew(stage, target.id), {}, hashParams).url}
>
Create
diff --git a/frontend/src/scenes/pipeline/destinationsLogic.tsx b/frontend/src/scenes/pipeline/destinationsLogic.tsx
index d50e6b5de5091..05d4e2ff1d1e6 100644
--- a/frontend/src/scenes/pipeline/destinationsLogic.tsx
+++ b/frontend/src/scenes/pipeline/destinationsLogic.tsx
@@ -8,6 +8,8 @@ import { userLogic } from 'scenes/userLogic'
import {
BatchExportConfiguration,
+ HogFunctionTemplateType,
+ HogFunctionType,
PipelineStage,
PluginConfigTypeNew,
PluginConfigWithPluginInfoNew,
@@ -16,6 +18,7 @@ import {
} from '~/types'
import type { pipelineDestinationsLogicType } from './destinationsLogicType'
+import { HOG_FUNCTION_TEMPLATES } from './hogfunctions/templates/hog-templates'
import { pipelineAccessLogic } from './pipelineAccessLogic'
import { BatchExportDestination, convertToPipelineNode, Destination, PipelineBackend } from './types'
import { captureBatchExportEvent, capturePluginEvent, loadPluginsFromUrl } from './utils'
@@ -116,28 +119,68 @@ export const pipelineDestinationsLogic = kea
([
},
},
],
+
+ hogFunctionTemplates: [
+ {} as Record,
+ {
+ loadHogFunctionTemplates: async () => {
+ return HOG_FUNCTION_TEMPLATES.reduce((acc, template) => {
+ acc[template.id] = template
+ return acc
+ }, {} as Record)
+ },
+ },
+ ],
+ hogFunctions: [
+ [] as HogFunctionType[],
+ {
+ loadHogFunctions: async () => {
+ // TODO: Support pagination?
+ return (await api.hogFunctions.list()).results
+ },
+ },
+ ],
})),
selectors({
loading: [
- (s) => [s.pluginsLoading, s.pluginConfigsLoading, s.batchExportConfigsLoading],
- (pluginsLoading, pluginConfigsLoading, batchExportConfigsLoading) =>
- pluginsLoading || pluginConfigsLoading || batchExportConfigsLoading,
+ (s) => [
+ s.pluginsLoading,
+ s.pluginConfigsLoading,
+ s.batchExportConfigsLoading,
+ s.hogFunctionTemplatesLoading,
+ s.hogFunctionsLoading,
+ ],
+ (
+ pluginsLoading,
+ pluginConfigsLoading,
+ batchExportConfigsLoading,
+ hogFunctionTemplatesLoading,
+ hogFunctionsLoading
+ ) =>
+ pluginsLoading ||
+ pluginConfigsLoading ||
+ batchExportConfigsLoading ||
+ hogFunctionTemplatesLoading ||
+ hogFunctionsLoading,
],
destinations: [
- (s) => [s.pluginConfigs, s.plugins, s.batchExportConfigs, s.user],
- (pluginConfigs, plugins, batchExportConfigs, user): Destination[] => {
+ (s) => [s.pluginConfigs, s.plugins, s.batchExportConfigs, s.hogFunctions, s.user],
+ (pluginConfigs, plugins, batchExportConfigs, hogFunctions, user): Destination[] => {
// Migrations are shown only in impersonation mode, for us to be able to trigger them.
const rawBatchExports = Object.values(batchExportConfigs).filter(
(config) => config.destination.type !== 'HTTP' || user?.is_impersonated
)
- const rawDestinations: (PluginConfigWithPluginInfoNew | BatchExportConfiguration)[] = Object.values(
- pluginConfigs
- )
- .map((pluginConfig) => ({
- ...pluginConfig,
- plugin_info: plugins[pluginConfig.plugin] || null,
- }))
- .concat(rawBatchExports)
+
+ const rawDestinations: (PluginConfigWithPluginInfoNew | BatchExportConfiguration | HogFunctionType)[] =
+ Object.values(pluginConfigs)
+ .map(
+ (pluginConfig) => ({
+ ...pluginConfig,
+ plugin_info: plugins[pluginConfig.plugin] || null,
+ })
+ )
+ .concat(rawBatchExports)
+ .concat(hogFunctions)
const convertedDestinations = rawDestinations.map((d) =>
convertToPipelineNode(d, PipelineStage.Destination)
)
@@ -183,5 +226,7 @@ export const pipelineDestinationsLogic = kea([
actions.loadPlugins()
actions.loadPluginConfigs()
actions.loadBatchExports()
+ actions.loadHogFunctionTemplates()
+ actions.loadHogFunctions()
}),
])
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx
new file mode 100644
index 0000000000000..2f3cc63865729
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx
@@ -0,0 +1,287 @@
+import { Monaco } from '@monaco-editor/react'
+import { IconPencil, IconPlus, IconX } from '@posthog/icons'
+import { LemonButton, LemonCheckbox, LemonInput, LemonSelect } from '@posthog/lemon-ui'
+import { useValues } from 'kea'
+import { CodeEditor } from 'lib/components/CodeEditors'
+import { languages } from 'monaco-editor'
+import { useEffect, useMemo, useState } from 'react'
+
+import { groupsModel } from '~/models/groupsModel'
+import { HogFunctionInputSchemaType } from '~/types'
+
+export type HogFunctionInputProps = {
+ schema: HogFunctionInputSchemaType
+ value?: any
+ onChange?: (value: any) => void
+ disabled?: boolean
+}
+
+const SECRET_FIELD_VALUE = '********'
+
+function useAutocompleteOptions(): languages.CompletionItem[] {
+ const { groupTypes } = useValues(groupsModel)
+
+ return useMemo(() => {
+ const options = [
+ ['event', 'The entire event payload as a JSON object'],
+ ['event.name', 'The name of the event e.g. $pageview'],
+ ['event.distinct_id', 'The distinct_id of the event'],
+ ['event.timestamp', 'The timestamp of the event'],
+ ['event.url', 'URL to the event in PostHog'],
+ ['event.properties', 'Properties of the event'],
+ ['event.properties.', 'The individual property of the event'],
+ ['person', 'The entire person payload as a JSON object'],
+ ['project.uuid', 'The UUID of the Person in PostHog'],
+ ['person.url', 'URL to the person in PostHog'],
+ ['person.properties', 'Properties of the person'],
+ ['person.properties.', 'The individual property of the person'],
+ ['project.id', 'ID of the project in PostHog'],
+ ['project.name', 'Name of the project'],
+ ['project.url', 'URL to the project in PostHog'],
+ ['source.name', 'Name of the source of this message'],
+ ['source.url', 'URL to the source of this message in PostHog'],
+ ]
+
+ groupTypes.forEach((groupType) => {
+ options.push([`groups.${groupType.group_type}`, `The entire group payload as a JSON object`])
+ options.push([`groups.${groupType.group_type}.id`, `The ID or 'key' of the group`])
+ options.push([`groups.${groupType.group_type}.url`, `URL to the group in PostHog`])
+ options.push([`groups.${groupType.group_type}.properties`, `Properties of the group`])
+ options.push([`groups.${groupType.group_type}.properties.`, `The individual property of the group`])
+ options.push([`groups.${groupType.group_type}.index`, `Index of the group`])
+ })
+
+ const items: languages.CompletionItem[] = options.map(([key, value]) => {
+ return {
+ label: key,
+ kind: languages.CompletionItemKind.Variable,
+ detail: value,
+ insertText: key,
+ range: {
+ startLineNumber: 1,
+ endLineNumber: 1,
+ startColumn: 0,
+ endColumn: 0,
+ },
+ }
+ })
+
+ return items
+ }, [groupTypes])
+}
+
+function JsonConfigField(props: {
+ onChange?: (value: string) => void
+ className: string
+ autoFocus: boolean
+ value?: string | object
+}): JSX.Element {
+ const suggestions = useAutocompleteOptions()
+ const [monaco, setMonaco] = useState()
+
+ useEffect(() => {
+ if (!monaco) {
+ return
+ }
+ monaco.languages.setLanguageConfiguration('json', {
+ wordPattern: /[a-zA-Z0-9_\-.]+/,
+ })
+
+ const provider = monaco.languages.registerCompletionItemProvider('json', {
+ triggerCharacters: ['{', '{{'],
+ provideCompletionItems: async (model, position) => {
+ const word = model.getWordUntilPosition(position)
+
+ const wordWithTrigger = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 0,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ })
+
+ if (wordWithTrigger.indexOf('{') === -1) {
+ return { suggestions: [] }
+ }
+
+ const localSuggestions = suggestions.map((x) => ({
+ ...x,
+ insertText: x.insertText,
+ range: {
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber,
+ startColumn: word.startColumn,
+ endColumn: word.endColumn,
+ },
+ }))
+
+ return {
+ suggestions: localSuggestions,
+ incomplete: false,
+ }
+ },
+ })
+
+ return () => provider.dispose()
+ }, [suggestions, monaco])
+
+ return (
+ props.onChange?.(v ?? '')}
+ options={{
+ lineNumbers: 'off',
+ minimap: {
+ enabled: false,
+ },
+ quickSuggestions: {
+ other: true,
+ strings: true,
+ },
+ suggest: {
+ showWords: false,
+ showFields: false,
+ showKeywords: false,
+ },
+ scrollbar: {
+ vertical: 'hidden',
+ verticalScrollbarSize: 0,
+ },
+ }}
+ onMount={(_editor, monaco) => {
+ setMonaco(monaco)
+ }}
+ />
+ )
+}
+
+function DictionaryField({ onChange, value }: { onChange?: (value: any) => void; value: any }): JSX.Element {
+ const [entries, setEntries] = useState<[string, string][]>(Object.entries(value ?? {}))
+
+ useEffect(() => {
+ // NOTE: Filter out all empty entries as fetch will throw if passed in
+ const val = Object.fromEntries(entries.filter(([key, val]) => key.trim() !== '' || val.trim() !== ''))
+ onChange?.(val)
+ }, [entries])
+
+ return (
+
+ {entries.map(([key, val], index) => (
+
+ {
+ const newEntries = [...entries]
+ newEntries[index] = [key, newEntries[index][1]]
+ setEntries(newEntries)
+ }}
+ placeholder="Key"
+ />
+
+ {
+ const newEntries = [...entries]
+ newEntries[index] = [newEntries[index][0], val]
+ setEntries(newEntries)
+ }}
+ placeholder="Value"
+ />
+
+ }
+ size="small"
+ onClick={() => {
+ const newEntries = [...entries]
+ newEntries.splice(index, 1)
+ setEntries(newEntries)
+ }}
+ />
+
+ ))}
+
}
+ size="small"
+ type="secondary"
+ onClick={() => {
+ setEntries([...entries, ['', '']])
+ }}
+ >
+ Add entry
+
+
+ )
+}
+
+export function HogFunctionInput({ value, onChange, schema, disabled }: HogFunctionInputProps): JSX.Element {
+ const [editingSecret, setEditingSecret] = useState(false)
+ if (
+ schema.secret &&
+ !editingSecret &&
+ value &&
+ (value === SECRET_FIELD_VALUE || value.name === SECRET_FIELD_VALUE)
+ ) {
+ return (
+ }
+ onClick={() => {
+ onChange?.(schema.default || '')
+ setEditingSecret(true)
+ }}
+ disabled={disabled}
+ >
+ Reset secret variable
+
+ )
+ }
+
+ switch (schema.type) {
+ case 'string':
+ return (
+
+ )
+ case 'json':
+ return (
+
+ )
+ case 'choice':
+ return (
+
+ )
+ case 'dictionary':
+ return
+
+ case 'boolean':
+ return onChange?.(checked)} disabled={disabled} />
+ default:
+ return (
+
+ Unknown field type "{schema.type}
".
+
+ You may need to upgrade PostHog!
+
+ )
+ }
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputsEditor.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputsEditor.tsx
new file mode 100644
index 0000000000000..59bc0fbfffaa1
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputsEditor.tsx
@@ -0,0 +1,116 @@
+import { IconPlus, IconX } from '@posthog/icons'
+import { LemonButton, LemonCheckbox, LemonInput, LemonInputSelect, LemonSelect } from '@posthog/lemon-ui'
+import { capitalizeFirstLetter } from 'kea-forms'
+import { useEffect, useState } from 'react'
+
+import { HogFunctionInputSchemaType } from '~/types'
+
+const typeList = ['string', 'boolean', 'dictionary', 'choice', 'json'] as const
+
+export type HogFunctionInputsEditorProps = {
+ value?: HogFunctionInputSchemaType[]
+ onChange?: (value: HogFunctionInputSchemaType[]) => void
+}
+
+export function HogFunctionInputsEditor({ value, onChange }: HogFunctionInputsEditorProps): JSX.Element {
+ const [inputs, setInputs] = useState(value ?? [])
+
+ useEffect(() => {
+ onChange?.(inputs)
+ }, [inputs])
+
+ return (
+
+ {inputs.map((input, index) => {
+ const _onChange = (data: Partial
): void => {
+ setInputs((inputs) => {
+ const newInputs = [...inputs]
+ newInputs[index] = { ...newInputs[index], ...data }
+ return newInputs
+ })
+ }
+
+ return (
+
+
+ _onChange({ key })}
+ placeholder="Variable name"
+ />
+ ({
+ label: capitalizeFirstLetter(type),
+ value: type,
+ }))}
+ value={input.type}
+ className="w-30"
+ onChange={(type) => _onChange({ type })}
+ />
+
+ _onChange({ label })}
+ placeholder="Display label"
+ />
+ _onChange({ required })}
+ label="Required"
+ bordered
+ />
+ _onChange({ secret })}
+ label="Secret"
+ bordered
+ />
+ {input.type === 'choice' && (
+ choice.value)}
+ onChange={(choices) =>
+ _onChange({ choices: choices.map((value) => ({ label: value, value })) })
+ }
+ placeholder="Choices"
+ />
+ )}
+
+
}
+ size="small"
+ onClick={() => {
+ const newInputs = [...inputs]
+ newInputs.splice(index, 1)
+ setInputs(newInputs)
+ }}
+ />
+
+ )
+ })}
+
+
+ }
+ size="small"
+ type="secondary"
+ onClick={() => {
+ setInputs([
+ ...inputs,
+ { type: 'string', key: `input_${inputs.length + 1}`, label: '', required: false },
+ ])
+ }}
+ >
+ Add input variable
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/PipelineHogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/PipelineHogFunctionConfiguration.tsx
new file mode 100644
index 0000000000000..caeda41d63eef
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/PipelineHogFunctionConfiguration.tsx
@@ -0,0 +1,247 @@
+import { LemonButton, LemonInput, LemonSwitch, LemonTextArea, SpinnerOverlay } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { Form } from 'kea-forms'
+import { NotFound } from 'lib/components/NotFound'
+import { PageHeader } from 'lib/components/PageHeader'
+import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
+import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+import { HogQueryEditor } from 'scenes/debug/HogDebug'
+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 { EntityTypes } from '~/types'
+
+import { HogFunctionInput } from './HogFunctionInputs'
+import { HogFunctionInputsEditor } from './HogFunctionInputsEditor'
+import { pipelineHogFunctionConfigurationLogic } from './pipelineHogFunctionConfigurationLogic'
+
+export function PipelineHogFunctionConfiguration({
+ templateId,
+ id,
+}: {
+ templateId?: string
+ id?: string
+}): JSX.Element {
+ const logicProps = { templateId, id }
+ const logic = pipelineHogFunctionConfigurationLogic(logicProps)
+ const { isConfigurationSubmitting, configurationChanged, showSource, configuration, loading, loaded } =
+ useValues(logic)
+ const { submitConfiguration, resetForm, setShowSource } = useActions(logic)
+
+ const hogFunctionsEnabled = !!useFeatureFlag('HOG_FUNCTIONS')
+ const { groupsTaxonomicTypes } = useValues(groupsModel)
+
+ if (loading && !loaded) {
+ return
+ }
+
+ if (!loaded) {
+ return
+ }
+
+ if (!hogFunctionsEnabled && !id) {
+ return (
+
+
+
Feature not enabled
+
Hog functions are not enabled for you yet. If you think they should be, contact support.
+
+
+ )
+ }
+ const buttons = (
+ <>
+ resetForm()}
+ disabledReason={
+ !configurationChanged ? 'No changes' : isConfigurationSubmitting ? 'Saving in progress…' : undefined
+ }
+ >
+ Clear changes
+
+
+ {templateId ? 'Create' : 'Save'}
+
+ >
+ )
+
+ return (
+
+ )
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/pipelineHogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/pipelineHogFunctionConfigurationLogic.tsx
new file mode 100644
index 0000000000000..8c90cb6e93334
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/pipelineHogFunctionConfigurationLogic.tsx
@@ -0,0 +1,250 @@
+import { actions, afterMount, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { forms } from 'kea-forms'
+import { loaders } from 'kea-loaders'
+import { router } from 'kea-router'
+import { subscriptions } from 'kea-subscriptions'
+import api from 'lib/api'
+import { urls } from 'scenes/urls'
+
+import {
+ FilterType,
+ HogFunctionTemplateType,
+ HogFunctionType,
+ PipelineNodeTab,
+ PipelineStage,
+ PluginConfigFilters,
+ PluginConfigTypeNew,
+} from '~/types'
+
+import type { pipelineHogFunctionConfigurationLogicType } from './pipelineHogFunctionConfigurationLogicType'
+import { HOG_FUNCTION_TEMPLATES } from './templates/hog-templates'
+
+export interface PipelineHogFunctionConfigurationLogicProps {
+ templateId?: string
+ id?: string
+}
+
+function sanitizeFilters(filters?: FilterType): PluginConfigTypeNew['filters'] {
+ if (!filters) {
+ return null
+ }
+ const sanitized: PluginConfigFilters = {}
+
+ if (filters.events) {
+ sanitized.events = filters.events.map((f) => ({
+ id: f.id,
+ type: 'events',
+ name: f.name,
+ order: f.order,
+ properties: f.properties,
+ }))
+ }
+
+ if (filters.actions) {
+ sanitized.actions = filters.actions.map((f) => ({
+ id: f.id,
+ type: 'actions',
+ name: f.name,
+ order: f.order,
+ properties: f.properties,
+ }))
+ }
+
+ if (filters.filter_test_accounts) {
+ sanitized.filter_test_accounts = filters.filter_test_accounts
+ }
+
+ return Object.keys(sanitized).length > 0 ? sanitized : undefined
+}
+
+// Should likely be somewhat similar to pipelineBatchExportConfigurationLogic
+export const pipelineHogFunctionConfigurationLogic = kea([
+ props({} as PipelineHogFunctionConfigurationLogicProps),
+ key(({ id, templateId }: PipelineHogFunctionConfigurationLogicProps) => {
+ return id ?? templateId ?? 'new'
+ }),
+ path((id) => ['scenes', 'pipeline', 'pipelineHogFunctionConfigurationLogic', id]),
+ actions({
+ setShowSource: (showSource: boolean) => ({ showSource }),
+ resetForm: true,
+ }),
+ reducers({
+ showSource: [
+ false,
+ {
+ setShowSource: (_, { showSource }) => showSource,
+ },
+ ],
+ }),
+ loaders(({ props }) => ({
+ template: [
+ null as HogFunctionTemplateType | null,
+ {
+ loadTemplate: async () => {
+ if (!props.templateId) {
+ return null
+ }
+ const res = HOG_FUNCTION_TEMPLATES.find((template) => template.id === props.templateId)
+
+ if (!res) {
+ throw new Error('Template not found')
+ }
+ return res
+ },
+ },
+ ],
+
+ hogFunction: [
+ null as HogFunctionType | null,
+ {
+ loadHogFunction: async () => {
+ if (!props.id) {
+ return null
+ }
+
+ return await api.hogFunctions.get(props.id)
+ },
+ },
+ ],
+ })),
+ forms(({ values, props, actions }) => ({
+ configuration: {
+ defaults: {} as HogFunctionType,
+ alwaysShowErrors: true,
+ errors: (data) => {
+ return {
+ name: !data.name ? 'Name is required' : null,
+ ...values.inputFormErrors,
+ }
+ },
+ submit: async (data) => {
+ const sanitizedInputs = {}
+
+ data.inputs_schema?.forEach((input) => {
+ if (input.type === 'json' && typeof data.inputs[input.key].value === 'string') {
+ try {
+ sanitizedInputs[input.key] = {
+ value: JSON.parse(data.inputs[input.key].value),
+ }
+ } catch (e) {
+ // Ignore
+ }
+ } else {
+ sanitizedInputs[input.key] = {
+ value: data.inputs[input.key].value,
+ }
+ }
+ })
+
+ const payload = {
+ ...data,
+ filters: data.filters ? sanitizeFilters(data.filters) : null,
+ inputs: sanitizedInputs,
+ }
+
+ try {
+ if (!props.id) {
+ return await api.hogFunctions.create(payload)
+ }
+ return await api.hogFunctions.update(props.id, payload)
+ } catch (e) {
+ const maybeValidationError = (e as any).data
+ if (maybeValidationError?.type === 'validation_error') {
+ if (maybeValidationError.attr.includes('inputs__')) {
+ actions.setConfigurationManualErrors({
+ inputs: {
+ [maybeValidationError.attr.split('__')[1]]: maybeValidationError.detail,
+ },
+ })
+ } else {
+ actions.setConfigurationManualErrors({
+ [maybeValidationError.attr]: maybeValidationError.detail,
+ })
+ }
+ }
+ throw e
+ }
+ },
+ },
+ })),
+ selectors(() => ({
+ loading: [
+ (s) => [s.hogFunctionLoading, s.templateLoading],
+ (hogFunctionLoading, templateLoading) => hogFunctionLoading || templateLoading,
+ ],
+ loaded: [(s) => [s.hogFunction, s.template], (hogFunction, template) => !!hogFunction || !!template],
+
+ inputFormErrors: [
+ (s) => [s.configuration],
+ (configuration) => {
+ const inputs = configuration.inputs ?? {}
+ const inputErrors = {}
+
+ configuration.inputs_schema?.forEach((input) => {
+ if (input.required && !inputs[input.key]) {
+ inputErrors[input.key] = 'This field is required'
+ }
+
+ if (input.type === 'json' && typeof inputs[input.key] === 'string') {
+ try {
+ JSON.parse(inputs[input.key].value)
+ } catch (e) {
+ inputErrors[input.key] = 'Invalid JSON'
+ }
+ }
+ })
+
+ return Object.keys(inputErrors).length > 0
+ ? {
+ inputs: inputErrors,
+ }
+ : null
+ },
+ ],
+ })),
+
+ listeners(({ actions, values, cache, props }) => ({
+ loadTemplateSuccess: () => actions.resetForm(),
+ loadHogFunctionSuccess: () => actions.resetForm(),
+ resetForm: () => {
+ const savedValue = values.hogFunction ?? values.template
+ actions.resetConfiguration({
+ ...savedValue,
+ inputs: (savedValue as any)?.inputs ?? {},
+ ...(cache.configFromUrl || {}),
+ })
+ },
+
+ submitConfigurationSuccess: ({ configuration }) => {
+ if (!props.id) {
+ router.actions.replace(
+ urls.pipelineNode(
+ PipelineStage.Destination,
+ `hog-${configuration.id}`,
+ PipelineNodeTab.Configuration
+ )
+ )
+ }
+ },
+ })),
+ afterMount(({ props, actions, cache }) => {
+ if (props.templateId) {
+ cache.configFromUrl = router.values.hashParams.configuration
+ actions.loadTemplate() // comes with plugin info
+ } else if (props.id) {
+ actions.loadHogFunction()
+ }
+ }),
+
+ subscriptions(({ props, cache }) => ({
+ configuration: (configuration) => {
+ if (props.templateId) {
+ // Sync state to the URL bar if new
+ cache.ignoreUrlChange = true
+ router.actions.replace(router.values.location.pathname, undefined, {
+ configuration,
+ })
+ }
+ },
+ })),
+])
diff --git a/frontend/src/scenes/pipeline/hogfunctions/templates/hog-templates.tsx b/frontend/src/scenes/pipeline/hogfunctions/templates/hog-templates.tsx
new file mode 100644
index 0000000000000..cf76222fb16a0
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/templates/hog-templates.tsx
@@ -0,0 +1,58 @@
+import { HogFunctionTemplateType } from '~/types'
+
+export const HOG_FUNCTION_TEMPLATES: HogFunctionTemplateType[] = [
+ {
+ id: 'template-webhook',
+ name: 'HogHook',
+ description: 'Sends a webhook templated by the incoming event data',
+ hog: "fetch(inputs.url, {\n 'headers': inputs.headers,\n 'body': inputs.payload,\n 'method': inputs.method,\n 'payload': inputs.payload\n});",
+ inputs_schema: [
+ {
+ key: 'url',
+ type: 'string',
+ label: 'Webhook URL',
+ secret: false,
+ required: true,
+ },
+ {
+ key: 'method',
+ type: 'choice',
+ label: 'Method',
+ secret: false,
+ choices: [
+ {
+ label: 'POST',
+ value: 'POST',
+ },
+ {
+ label: 'PUT',
+ value: 'PUT',
+ },
+ {
+ label: 'GET',
+ value: 'GET',
+ },
+ {
+ label: 'DELETE',
+ value: 'DELETE',
+ },
+ ],
+ required: false,
+ },
+ {
+ key: 'payload',
+ type: 'json',
+ label: 'JSON Payload',
+ secret: false,
+ required: false,
+ },
+ {
+ key: 'headers',
+ type: 'dictionary',
+ label: 'Headers',
+ secret: false,
+ required: false,
+ },
+ ],
+ },
+]
diff --git a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
index 38d5acba5fcd3..f9a4d7d66b824 100644
--- a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
+++ b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
@@ -24,7 +24,11 @@ type BatchExportNodeId = {
backend: PipelineBackend.BatchExport
id: string
}
-export type PipelineNodeLimitedType = PluginNodeId | BatchExportNodeId
+type HogFunctionNodeId = {
+ backend: PipelineBackend.HogFunction
+ id: string
+}
+export type PipelineNodeLimitedType = PluginNodeId | BatchExportNodeId | HogFunctionNodeId
export const pipelineNodeLogic = kea([
props({} as PipelineNodeLogicProps),
@@ -61,18 +65,23 @@ export const pipelineNodeLogic = kea([
},
],
],
+
+ nodeBackend: [
+ (s) => [s.node],
+ (node): PipelineBackend => {
+ return node.backend
+ },
+ ],
node: [
(_, p) => [p.id],
(id): PipelineNodeLimitedType => {
return typeof id === 'string'
- ? { backend: PipelineBackend.BatchExport, id: id }
- : { backend: PipelineBackend.Plugin, id: id }
+ ? id.indexOf('hog-') === 0
+ ? { backend: PipelineBackend.HogFunction, id: `${id}`.replace('hog-', '') }
+ : { backend: PipelineBackend.BatchExport, id }
+ : { backend: PipelineBackend.Plugin, id }
},
],
- nodeBackend: [
- (_, p) => [p.id],
- (id): PipelineBackend => (typeof id === 'string' ? PipelineBackend.BatchExport : PipelineBackend.Plugin),
- ],
tabs: [
(s) => [s.nodeBackend],
(nodeBackend) => {
diff --git a/frontend/src/scenes/pipeline/pipelineNodeNewLogic.tsx b/frontend/src/scenes/pipeline/pipelineNodeNewLogic.tsx
index 395055b913a9b..81e45ff15d394 100644
--- a/frontend/src/scenes/pipeline/pipelineNodeNewLogic.tsx
+++ b/frontend/src/scenes/pipeline/pipelineNodeNewLogic.tsx
@@ -18,6 +18,7 @@ export interface PipelineNodeNewLogicProps {
stage: PipelineStage | null
pluginId: number | null
batchExportDestination: string | null
+ hogFunctionId: string | null
}
export const pipelineNodeNewLogic = kea([
@@ -25,12 +26,7 @@ export const pipelineNodeNewLogic = kea([
connect({
values: [userLogic, ['user']],
}),
- path((pluginIdOrBatchExportDestination) => [
- 'scenes',
- 'pipeline',
- 'pipelineNodeNewLogic',
- pluginIdOrBatchExportDestination,
- ]),
+ path((id) => ['scenes', 'pipeline', 'pipelineNodeNewLogic', id]),
actions({
createNewButtonPressed: (stage: PipelineStage, id: number | BatchExportService['type']) => ({ stage, id }),
}),
diff --git a/frontend/src/scenes/pipeline/types.ts b/frontend/src/scenes/pipeline/types.ts
index dc6ac93442df9..f958ebb887ca6 100644
--- a/frontend/src/scenes/pipeline/types.ts
+++ b/frontend/src/scenes/pipeline/types.ts
@@ -1,6 +1,7 @@
import {
BatchExportConfiguration,
BatchExportService,
+ HogFunctionType,
PipelineStage,
PluginConfigWithPluginInfoNew,
PluginType,
@@ -9,6 +10,7 @@ import {
export enum PipelineBackend {
BatchExport = 'batch_export',
Plugin = 'plugin',
+ HogFunction = 'hog_function',
}
// Base - we're taking a discriminated union approach here, so that TypeScript can discern types for free
@@ -39,6 +41,11 @@ export interface BatchExportBasedNode extends PipelineNodeBase {
interval: BatchExportConfiguration['interval']
}
+export interface HogFunctionBasedNode extends PipelineNodeBase {
+ backend: PipelineBackend.HogFunction
+ id: string
+}
+
// Stage: Transformations
export interface Transformation extends PluginBasedNode {
@@ -55,7 +62,11 @@ export interface WebhookDestination extends PluginBasedNode {
export interface BatchExportDestination extends BatchExportBasedNode {
stage: PipelineStage.Destination
}
-export type Destination = BatchExportDestination | WebhookDestination
+export interface FunctionDestination extends HogFunctionBasedNode {
+ stage: PipelineStage.Destination
+ interval: 'realtime'
+}
+export type Destination = BatchExportDestination | WebhookDestination | FunctionDestination
export interface DataImportApp extends PluginBasedNode {
stage: PipelineStage.DataImport
@@ -84,7 +95,7 @@ function isPluginConfig(
}
export function convertToPipelineNode(
- candidate: PluginConfigWithPluginInfoNew | BatchExportConfiguration,
+ candidate: PluginConfigWithPluginInfoNew | BatchExportConfiguration | HogFunctionType,
stage: S
): S extends PipelineStage.Transformation
? Transformation
@@ -98,7 +109,20 @@ export function convertToPipelineNode(
? ImportApp
: never {
let node: PipelineNode
- if (isPluginConfig(candidate)) {
+ // check if type is a hog function
+ if ('hog' in candidate) {
+ node = {
+ stage: stage as PipelineStage.Destination,
+ backend: PipelineBackend.HogFunction,
+ interval: 'realtime',
+ id: `hog-${candidate.id}`,
+ name: candidate.name,
+ description: candidate.description,
+ enabled: candidate.enabled,
+ created_at: candidate.created_at,
+ updated_at: candidate.created_at,
+ }
+ } else if (isPluginConfig(candidate)) {
const almostNode: Omit<
Transformation | WebhookDestination | SiteApp | ImportApp | DataImportApp,
'frequency' | 'order'
diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts
index c80897e8dd192..1dcb3f8af312b 100644
--- a/frontend/src/scenes/sceneTypes.ts
+++ b/frontend/src/scenes/sceneTypes.ts
@@ -9,6 +9,7 @@ export enum Scene {
Error404 = '404',
ErrorNetwork = '4xx',
ErrorProjectUnavailable = 'ProjectUnavailable',
+ ErrorTracking = 'ErrorTracking',
Dashboards = 'Dashboards',
Dashboard = 'Dashboard',
Insight = 'Insight',
diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts
index 94983524158f6..f4ef644d8665c 100644
--- a/frontend/src/scenes/scenes.ts
+++ b/frontend/src/scenes/scenes.ts
@@ -53,6 +53,10 @@ export const sceneConfigurations: Record = {
activityScope: ActivityScope.DASHBOARD,
defaultDocsPath: '/docs/product-analytics/dashboards',
},
+ [Scene.ErrorTracking]: {
+ projectBased: true,
+ name: 'Error tracking',
+ },
[Scene.Insight]: {
projectBased: true,
name: 'Insights',
@@ -408,7 +412,6 @@ export const sceneConfigurations: Record = {
[Scene.Heatmaps]: {
projectBased: true,
name: 'Heatmaps',
- hideProjectNotice: true,
},
}
@@ -529,7 +532,7 @@ export const routes: Record = {
[urls.persons()]: Scene.PersonsManagement,
[urls.pipelineNodeDataWarehouseNew()]: Scene.pipelineNodeDataWarehouseNew,
[urls.pipelineNodeNew(':stage')]: Scene.PipelineNodeNew,
- [urls.pipelineNodeNew(':stage', ':pluginIdOrBatchExportDestination')]: Scene.PipelineNodeNew,
+ [urls.pipelineNodeNew(':stage', ':id')]: Scene.PipelineNodeNew,
[urls.pipeline(':tab')]: Scene.Pipeline,
[urls.pipelineNode(':stage', ':id', ':nodeTab')]: Scene.PipelineNode,
[urls.groups(':groupTypeIndex')]: Scene.PersonsManagement,
@@ -541,6 +544,7 @@ export const routes: Record = {
[urls.experiment(':id')]: Scene.Experiment,
[urls.earlyAccessFeatures()]: Scene.EarlyAccessFeatures,
[urls.earlyAccessFeature(':id')]: Scene.EarlyAccessFeature,
+ [urls.errorTracking()]: Scene.ErrorTracking,
[urls.surveys()]: Scene.Surveys,
[urls.survey(':id')]: Scene.Survey,
[urls.surveyTemplates()]: Scene.SurveyTemplates,
diff --git a/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx b/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
new file mode 100644
index 0000000000000..2625549c1b53d
--- /dev/null
+++ b/frontend/src/scenes/session-recordings/filters/RecordingsUniversalFilters.tsx
@@ -0,0 +1,138 @@
+import { useActions, useMountedLogic, useValues } from 'kea'
+import { DateFilter } from 'lib/components/DateFilter/DateFilter'
+import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
+import UniversalFilters from 'lib/components/UniversalFilters/UniversalFilters'
+import { universalFiltersLogic } from 'lib/components/UniversalFilters/universalFiltersLogic'
+import { isUniversalGroupFilterLike } from 'lib/components/UniversalFilters/utils'
+import { TestAccountFilter } from 'scenes/insights/filters/TestAccountFilter'
+
+import { actionsModel } from '~/models/actionsModel'
+import { cohortsModel } from '~/models/cohortsModel'
+import { AndOrFilterSelect } from '~/queries/nodes/InsightViz/PropertyGroupFilters/AndOrFilterSelect'
+
+import { sessionRecordingsPlaylistLogic } from '../playlist/sessionRecordingsPlaylistLogic'
+import { DurationFilter } from './DurationFilter'
+
+export const RecordingsUniversalFilters = (): JSX.Element => {
+ useMountedLogic(cohortsModel)
+ useMountedLogic(actionsModel)
+ const { universalFilters } = useValues(sessionRecordingsPlaylistLogic)
+ const { setUniversalFilters } = useActions(sessionRecordingsPlaylistLogic)
+
+ const durationFilter = universalFilters.duration[0]
+
+ return (
+
+
+
+ {
+ setUniversalFilters({
+ ...universalFilters,
+ date_from: changedDateFrom,
+ date_to: changedDateTo,
+ })
+ }}
+ dateOptions={[
+ { key: 'Custom', values: [] },
+ { key: 'Last 24 hours', values: ['-24h'] },
+ { key: 'Last 3 days', values: ['-3d'] },
+ { key: 'Last 7 days', values: ['-7d'] },
+ { key: 'Last 30 days', values: ['-30d'] },
+ { key: 'All time', values: ['-90d'] },
+ ]}
+ dropdownPlacement="bottom-start"
+ size="small"
+ />
+ {
+ setUniversalFilters({
+ duration: [{ ...newRecordingDurationFilter, key: newDurationType }],
+ })
+ }}
+ recordingDurationFilter={durationFilter}
+ durationTypeFilter={durationFilter.key}
+ pageKey="session-recordings"
+ />
+
+ setUniversalFilters({
+ ...universalFilters,
+ filter_test_accounts: testFilters.filter_test_accounts,
+ })
+ }
+ />
+
+
+
{
+ setUniversalFilters({
+ ...universalFilters,
+ filter_group: {
+ type: type,
+ values: universalFilters.filter_group.values,
+ },
+ })
+ }}
+ disabledReason="'Or' filtering is not supported yet"
+ topLevelFilter={true}
+ suffix={['filter', 'filters']}
+ />
+
+
+
+ {
+ setUniversalFilters({
+ ...universalFilters,
+ filter_group: filterGroup,
+ })
+ }}
+ >
+
+
+
+
+ )
+}
+
+const RecordingsUniversalFilterGroup = (): JSX.Element => {
+ const { filterGroup } = useValues(universalFiltersLogic)
+ const { replaceGroupValue, removeGroupValue } = useActions(universalFiltersLogic)
+
+ return (
+ <>
+ {filterGroup.values.map((filterOrGroup, index) => {
+ return isUniversalGroupFilterLike(filterOrGroup) ? (
+
+
+
+
+ ) : (
+ removeGroupValue(index)}
+ onChange={(value) => replaceGroupValue(index, value)}
+ />
+ )
+ })}
+ >
+ )
+}
diff --git a/frontend/src/scenes/session-recordings/filters/ReplayTaxonomicFilters.tsx b/frontend/src/scenes/session-recordings/filters/ReplayTaxonomicFilters.tsx
new file mode 100644
index 0000000000000..345f66b1c90b6
--- /dev/null
+++ b/frontend/src/scenes/session-recordings/filters/ReplayTaxonomicFilters.tsx
@@ -0,0 +1,109 @@
+import { IconTrash } from '@posthog/icons'
+import { LemonButton, Popover } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
+import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter'
+import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types'
+import { universalFiltersLogic } from 'lib/components/UniversalFilters/universalFiltersLogic'
+import { useState } from 'react'
+
+import { PropertyFilterType } from '~/types'
+
+import { playerSettingsLogic } from '../player/playerSettingsLogic'
+
+export interface ReplayTaxonomicFiltersProps {
+ onChange: (value: TaxonomicFilterValue, item?: any) => void
+}
+
+export function ReplayTaxonomicFilters({ onChange }: ReplayTaxonomicFiltersProps): JSX.Element {
+ const {
+ filterGroup: { values: filters },
+ } = useValues(universalFiltersLogic)
+
+ const hasConsoleLogLevelFilter = filters.find(
+ (f) => f.type === PropertyFilterType.Recording && f.key === 'console_log_level'
+ )
+ const hasConsoleLogQueryFilter = filters.find(
+ (f) => f.type === PropertyFilterType.Recording && f.key === 'console_log_query'
+ )
+
+ return (
+
+
+ Session properties
+
+ onChange('console_log_level', {})}
+ disabledReason={hasConsoleLogLevelFilter ? 'Log level filter already added' : undefined}
+ >
+ Console log level
+
+ onChange('console_log_query', {})}
+ disabledReason={hasConsoleLogQueryFilter ? 'Log text filter already added' : undefined}
+ >
+ Console log text
+
+
+
+
+
+
+ )
+}
+
+const PersonProperties = ({ onChange }: { onChange: ReplayTaxonomicFiltersProps['onChange'] }): JSX.Element => {
+ const { quickFilterProperties: properties } = useValues(playerSettingsLogic)
+ const { setQuickFilterProperties } = useActions(playerSettingsLogic)
+
+ const [showPropertySelector, setShowPropertySelector] = useState(false)
+
+ return (
+
+ Person properties
+
+ {properties.map((property) => (
+ {
+ const newProperties = properties.filter((p) => p != property)
+ setQuickFilterProperties(newProperties)
+ },
+ icon: ,
+ }}
+ onClick={() => onChange(property, { propertyFilterType: PropertyFilterType.Person })}
+ >
+
+
+ ))}
+ setShowPropertySelector(false)}
+ placement="right-start"
+ overlay={
+ {
+ properties.push(value as string)
+ setQuickFilterProperties([...properties])
+ setShowPropertySelector(false)
+ }}
+ taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]}
+ excludedProperties={{ [TaxonomicFilterGroupType.PersonProperties]: properties }}
+ />
+ }
+ >
+ setShowPropertySelector(!showPropertySelector)} fullWidth>
+ Add property
+
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/session-recordings/player/controller/Seekbar.scss b/frontend/src/scenes/session-recordings/player/controller/Seekbar.scss
index 943f17aa977ba..8549d99d48dde 100644
--- a/frontend/src/scenes/session-recordings/player/controller/Seekbar.scss
+++ b/frontend/src/scenes/session-recordings/player/controller/Seekbar.scss
@@ -47,7 +47,7 @@
.PlayerSeekbar__currentbar {
z-index: 3;
- background-color: var(--recording-seekbar-red);
+ background-color: var(--primary-3000);
border-radius: var(--bar-height) 0 0 var(--bar-height);
}
@@ -76,7 +76,7 @@
width: var(--thumb-size);
height: var(--thumb-size);
margin-top: calc(var(--thumb-size) / 2 * -1);
- background-color: var(--recording-seekbar-red);
+ background-color: var(--primary-3000);
border: 2px solid var(--bg-light);
border-radius: 50%;
transition: top 150ms ease-in-out;
diff --git a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx
index e851684d58874..9131ba82271d2 100644
--- a/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx
+++ b/frontend/src/scenes/session-recordings/player/inspector/components/ItemEvent.tsx
@@ -68,7 +68,7 @@ export function ItemEvent({ item, expanded, setExpanded }: ItemEventProps): JSX.
{item.data.fullyLoaded ? (
item.data.event === '$exception' ? (
-
+
) : (
)
diff --git a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
index 65829d1257afd..965e6f33382e3 100644
--- a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
+++ b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
@@ -1,5 +1,6 @@
-import { actions, kea, listeners, path, reducers, selectors } from 'kea'
+import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
+import { teamLogic } from 'scenes/teamLogic'
import { AutoplayDirection, DurationType, SessionRecordingPlayerTab } from '~/types'
@@ -191,7 +192,10 @@ export const playerSettingsLogic = kea([
setQuickFilterProperties: (properties: string[]) => ({ properties }),
setTimestampFormat: (format: TimestampFormat) => ({ format }),
}),
- reducers(() => ({
+ connect({
+ values: [teamLogic, ['currentTeam']],
+ }),
+ reducers(({ values }) => ({
showFilters: [
true,
{
@@ -211,7 +215,7 @@ export const playerSettingsLogic = kea([
},
],
quickFilterProperties: [
- ['$geoip_country_name'] as string[],
+ ['$geoip_country_name', ...(values.currentTeam?.person_display_name_properties || [])] as string[],
{
persist: true,
},
diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
index 40d3d356bd447..17ae678e31792 100644
--- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
+++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx
@@ -23,6 +23,7 @@ import { urls } from 'scenes/urls'
import { ReplayTabs, SessionRecordingType } from '~/types'
+import { RecordingsUniversalFilters } from '../filters/RecordingsUniversalFilters'
import { SessionRecordingsFilters } from '../filters/SessionRecordingsFilters'
import { SessionRecordingPlayer } from '../player/SessionRecordingPlayer'
import { SessionRecordingPreview, SessionRecordingPreviewSkeleton } from './SessionRecordingPreview'
@@ -118,6 +119,7 @@ function RecordingsLists(): JSX.Element {
recordingsCount,
isRecordingsListCollapsed,
sessionSummaryLoading,
+ useUniversalFiltering,
} = useValues(sessionRecordingsPlaylistLogic)
const {
setSelectedRecordingId,
@@ -205,25 +207,27 @@ function RecordingsLists(): JSX.Element {
-
-
-
- }
- onClick={() => {
- if (notebookNode) {
- notebookNode.actions.toggleEditing()
- } else {
- setShowFilters(!showFilters)
+ {(!useUniversalFiltering || notebookNode) && (
+
+
+
}
- }}
- >
- Filter
-
+ onClick={() => {
+ if (notebookNode) {
+ notebookNode.actions.toggleEditing()
+ } else {
+ setShowFilters(!showFilters)
+ }
+ }}
+ >
+ Filter
+
+ )}
-
+
+
+ {useUniversalFiltering &&
}
+
-
- >
+
+
)
}
diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts
index 6f128876501c8..7d8b34203a403 100644
--- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts
+++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts
@@ -4,6 +4,10 @@ import { loaders } from 'kea-loaders'
import { actionToUrl, router, urlToAction } from 'kea-router'
import { subscriptions } from 'kea-subscriptions'
import api from 'lib/api'
+import { isAnyPropertyfilter } from 'lib/components/PropertyFilters/utils'
+import { UniversalFiltersGroup, UniversalFilterValue } from 'lib/components/UniversalFilters/UniversalFilters'
+import { DEFAULT_UNIVERSAL_GROUP_FILTER } from 'lib/components/UniversalFilters/universalFiltersLogic'
+import { isActionFilter, isEventFilter } from 'lib/components/UniversalFilters/utils'
import { FEATURE_FLAGS } from 'lib/constants'
import { now } from 'lib/dayjs'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
@@ -12,11 +16,15 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import posthog from 'posthog-js'
import {
+ AnyPropertyFilter,
DurationType,
+ FilterableLogLevel,
+ FilterType,
PropertyFilterType,
PropertyOperator,
RecordingDurationFilter,
RecordingFilters,
+ RecordingUniversalFilters,
ReplayTabs,
SessionRecordingId,
SessionRecordingsResponse,
@@ -85,6 +93,14 @@ export const DEFAULT_RECORDING_FILTERS: RecordingFilters = {
console_search_query: '',
}
+export const DEFAULT_RECORDING_UNIVERSAL_FILTERS: RecordingUniversalFilters = {
+ live_mode: false,
+ filter_test_accounts: false,
+ date_from: '-3d',
+ filter_group: { ...DEFAULT_UNIVERSAL_GROUP_FILTER },
+ duration: [defaultRecordingDurationFilter],
+}
+
const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = {
...DEFAULT_RECORDING_FILTERS,
date_from: '-30d',
@@ -106,6 +122,47 @@ const capturePartialFilters = (filters: Partial): void => {
...partialFilters,
})
}
+function convertUniversalFiltersToLegacyFilters(universalFilters: RecordingUniversalFilters): RecordingFilters {
+ const nestedFilters = universalFilters.filter_group.values[0] as UniversalFiltersGroup
+ const filters = nestedFilters.values as UniversalFilterValue[]
+
+ const properties: AnyPropertyFilter[] = []
+ const events: FilterType['events'] = []
+ const actions: FilterType['actions'] = []
+ let console_logs: FilterableLogLevel[] = []
+ let console_search_query = ''
+
+ filters.forEach((f) => {
+ if (isEventFilter(f)) {
+ events.push(f)
+ } else if (isActionFilter(f)) {
+ actions.push(f)
+ } else if (isAnyPropertyfilter(f)) {
+ if (f.type === PropertyFilterType.Recording) {
+ if (f.key === 'console_log_level') {
+ console_logs = f.value as FilterableLogLevel[]
+ } else if (f.key === 'console_log_query') {
+ console_search_query = (f.value || '') as string
+ }
+ } else {
+ properties.push(f)
+ }
+ }
+ })
+
+ const durationFilter = universalFilters.duration[0]
+
+ return {
+ ...universalFilters,
+ properties,
+ events,
+ actions,
+ session_recording_duration: { ...durationFilter, key: 'duration' },
+ duration_type_filter: durationFilter.key,
+ console_search_query,
+ console_logs,
+ }
+}
export interface SessionRecordingPlaylistLogicProps {
logicKey?: string
@@ -113,6 +170,7 @@ export interface SessionRecordingPlaylistLogicProps {
updateSearchParams?: boolean
autoPlay?: boolean
hideSimpleFilters?: boolean
+ universalFilters?: RecordingUniversalFilters
advancedFilters?: RecordingFilters
simpleFilters?: RecordingFilters
onFiltersChange?: (filters: RecordingFilters) => void
@@ -148,6 +206,7 @@ export const sessionRecordingsPlaylistLogic = kea) => ({ filters }),
setAdvancedFilters: (filters: Partial) => ({ filters }),
setSimpleFilters: (filters: SimpleFiltersType) => ({ filters }),
setShowFilters: (showFilters: boolean) => ({ showFilters }),
@@ -355,6 +414,18 @@ export const sessionRecordingsPlaylistLogic = kea getDefaultFilters(props.personUUID),
},
],
+ universalFilters: [
+ props.universalFilters ?? DEFAULT_RECORDING_UNIVERSAL_FILTERS,
+ {
+ setUniversalFilters: (state, { filters }) => {
+ return {
+ ...state,
+ ...filters,
+ }
+ },
+ resetFilters: () => DEFAULT_RECORDING_UNIVERSAL_FILTERS,
+ },
+ ],
showFilters: [
true,
{
@@ -465,6 +536,12 @@ export const sessionRecordingsPlaylistLogic = kea {
+ actions.loadSessionRecordings()
+ props.onFiltersChange?.(values.filters)
+ capturePartialFilters(filters)
+ actions.loadEventsHaveSessionId()
+ },
setOrderBy: () => {
actions.loadSessionRecordings()
@@ -512,12 +589,20 @@ export const sessionRecordingsPlaylistLogic = kea [s.featureFlags],
(featureFlags) => !!featureFlags[FEATURE_FLAGS.SESSION_REPLAY_HOG_QL_FILTERING],
],
+ useUniversalFiltering: [
+ (s) => [s.featureFlags],
+ (featureFlags) => !!featureFlags[FEATURE_FLAGS.SESSION_REPLAY_UNIVERSAL_FILTERS],
+ ],
logicProps: [() => [(_, props) => props], (props): SessionRecordingPlaylistLogicProps => props],
filters: [
- (s) => [s.simpleFilters, s.advancedFilters],
- (simpleFilters, advancedFilters): RecordingFilters => {
+ (s) => [s.simpleFilters, s.advancedFilters, s.universalFilters, s.featureFlags],
+ (simpleFilters, advancedFilters, universalFilters, featureFlags): RecordingFilters => {
+ if (featureFlags[FEATURE_FLAGS.SESSION_REPLAY_UNIVERSAL_FILTERS]) {
+ return convertUniversalFiltersToLegacyFilters(universalFilters)
+ }
+
return {
...advancedFilters,
events: [...(simpleFilters?.events || []), ...(advancedFilters?.events || [])],
diff --git a/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx b/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx
index d0da4e1b4f7ee..caf5e06889346 100644
--- a/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx
+++ b/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx
@@ -306,7 +306,9 @@ export const personalAPIKeysLogic = kea([
<>
You can now use key "{key.label}" for authentication:
- {value}
+
+ {value}
+
For security reasons the value above will never be shown again.
diff --git a/frontend/src/scenes/surveys/QuestionBranchingInput.tsx b/frontend/src/scenes/surveys/QuestionBranchingInput.tsx
new file mode 100644
index 0000000000000..96c6ea55912d6
--- /dev/null
+++ b/frontend/src/scenes/surveys/QuestionBranchingInput.tsx
@@ -0,0 +1,68 @@
+import './EditSurvey.scss'
+
+import { LemonSelect } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+
+import { MultipleSurveyQuestion, RatingSurveyQuestion, SurveyQuestionBranchingType } from '~/types'
+
+import { surveyLogic } from './surveyLogic'
+
+export function QuestionBranchingInput({
+ questionIndex,
+ question,
+}: {
+ questionIndex: number
+ question: RatingSurveyQuestion | MultipleSurveyQuestion
+}): JSX.Element {
+ const { survey, getBranchingDropdownValue } = useValues(surveyLogic)
+ const { setQuestionBranching } = useActions(surveyLogic)
+
+ const availableNextQuestions = survey.questions
+ .map((question, questionIndex) => ({
+ ...question,
+ questionIndex,
+ }))
+ .filter((_, idx) => questionIndex !== idx)
+ const branchingDropdownValue = getBranchingDropdownValue(questionIndex, question)
+
+ return (
+ <>
+
+ setQuestionBranching(questionIndex, value)}
+ options={[
+ ...(questionIndex < survey.questions.length - 1
+ ? [
+ {
+ label: 'Next question',
+ value: SurveyQuestionBranchingType.NextQuestion,
+ },
+ ]
+ : []),
+ {
+ label: 'Confirmation message',
+ value: SurveyQuestionBranchingType.ConfirmationMessage,
+ },
+ {
+ label: 'Specific question based on answer',
+ value: SurveyQuestionBranchingType.ResponseBased,
+ },
+ ...availableNextQuestions.map((question) => ({
+ label: `${question.questionIndex + 1}. ${question.question}`,
+ value: `${SurveyQuestionBranchingType.SpecificQuestion}:${question.questionIndex}`,
+ })),
+ ]}
+ />
+
+ {branchingDropdownValue === SurveyQuestionBranchingType.ResponseBased && (
+
+ TODO: dropdowns for the response-based branching
+
+ )}
+ >
+ )
+}
diff --git a/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx b/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx
index 4b43be07bcf46..ec3b03d54b41d 100644
--- a/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx
+++ b/frontend/src/scenes/surveys/SurveyEditQuestionRow.tsx
@@ -7,12 +7,15 @@ import { IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonButton, LemonCheckbox, LemonInput, LemonSelect } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Group } from 'kea-forms'
+import { FEATURE_FLAGS } from 'lib/constants'
import { SortableDragIcon } from 'lib/lemon-ui/icons'
import { LemonField } from 'lib/lemon-ui/LemonField'
+import { featureFlagLogic as enabledFeaturesLogic } from 'lib/logic/featureFlagLogic'
import { Survey, SurveyQuestionType } from '~/types'
import { defaultSurveyFieldValues, NewSurvey, SurveyQuestionLabel } from './constants'
+import { QuestionBranchingInput } from './QuestionBranchingInput'
import { HTMLEditor } from './SurveyAppearanceUtils'
import { surveyLogic } from './surveyLogic'
@@ -85,6 +88,10 @@ export function SurveyEditQuestionHeader({
export function SurveyEditQuestionGroup({ index, question }: { index: number; question: any }): JSX.Element {
const { survey, descriptionContentType } = useValues(surveyLogic)
const { setDefaultForQuestionType, setSurveyValue } = useActions(surveyLogic)
+ const { featureFlags } = useValues(enabledFeaturesLogic)
+ const hasBranching =
+ featureFlags[FEATURE_FLAGS.SURVEYS_BRANCHING_LOGIC] &&
+ (question.type === SurveyQuestionType.Rating || question.type === SurveyQuestionType.SingleChoice)
const initialDescriptionContentType = descriptionContentType(index) ?? 'text'
@@ -332,6 +339,7 @@ export function SurveyEditQuestionGroup({ index, question }: { index: number; qu
}
/>
+ {hasBranching && }
)
diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx
index c976e7f5dfb53..9b3964b64fdf3 100644
--- a/frontend/src/scenes/surveys/surveyLogic.tsx
+++ b/frontend/src/scenes/surveys/surveyLogic.tsx
@@ -16,10 +16,13 @@ import { hogql } from '~/queries/utils'
import {
Breadcrumb,
FeatureFlagFilters,
+ MultipleSurveyQuestion,
PropertyFilterType,
PropertyOperator,
+ RatingSurveyQuestion,
Survey,
SurveyQuestionBase,
+ SurveyQuestionBranchingType,
SurveyQuestionType,
SurveyUrlMatchType,
} from '~/types'
@@ -154,6 +157,7 @@ export const surveyLogic = kea