From baea930cbabce7597d577b5257373be0869a263b Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Tue, 3 Dec 2024 13:15:58 +0100 Subject: [PATCH] feat(cdp): site destinations (#26572) --- frontend/src/lib/api.ts | 22 +- frontend/src/lib/constants.tsx | 1 + frontend/src/scenes/actions/actionLogic.ts | 2 +- .../scenes/messaging/functionsTableLogic.tsx | 2 +- .../pipeline/destinations/DestinationTag.tsx | 2 + .../scenes/pipeline/destinations/constants.ts | 4 + .../destinations/destinationsLogic.tsx | 5 +- .../destinations/newDestinationsLogic.tsx | 8 +- .../hogfunctions/HogFunctionConfiguration.tsx | 40 +-- .../hogfunctions/HogFunctionInputs.tsx | 2 +- .../filters/HogFunctionFilters.tsx | 226 ++++++++-------- .../hogFunctionConfigurationLogic.tsx | 7 +- .../list/hogFunctionListLogic.tsx | 7 +- .../scenes/pipeline/pipelineAccessLogic.tsx | 3 +- frontend/src/scenes/pipeline/types.ts | 7 +- frontend/src/types.ts | 13 +- plugin-server/src/cdp/utils.ts | 5 +- plugin-server/tests/cdp/utils.test.ts | 70 ++++- posthog/api/decide.py | 4 +- posthog/api/hog_function.py | 71 +++++- posthog/api/hog_function_template.py | 8 +- posthog/api/site_app.py | 32 +++ .../api/test/__snapshots__/test_decide.ambr | 30 +++ .../api/test/__snapshots__/test_insight.ambr | 8 +- posthog/api/test/test_decide.py | 46 +++- posthog/api/test/test_hog_function.py | 97 +++++++ .../api/test/test_hog_function_templates.py | 6 + posthog/api/test/test_site_app.py | 31 ++- posthog/cdp/filters.py | 5 +- posthog/cdp/site_functions.py | 102 ++++++++ posthog/cdp/templates/__init__.py | 2 + .../cdp/templates/_internal/template_blank.py | 53 ++++ .../cdp/templates/hog_function_template.py | 15 +- posthog/cdp/templates/test_cdp_templates.py | 7 +- posthog/cdp/test/test_filters.py | 5 + posthog/cdp/test/test_site_functions.py | 241 ++++++++++++++++++ posthog/cdp/validation.py | 51 +++- posthog/hogql/compiler/javascript.py | 56 ++-- .../hogql/compiler/test/test_javascript.py | 6 +- .../commands/migrate_action_webhooks.py | 1 + .../0525_hog_function_transpiled.py | 41 +++ posthog/migrations/max_migration.txt | 2 +- posthog/models/hog_functions/hog_function.py | 30 ++- posthog/models/plugin.py | 10 +- posthog/models/test/test_hog_function.py | 15 +- posthog/models/test/test_team_model.py | 6 +- posthog/plugins/site.py | 31 ++- .../test_session_recordings.ambr | 48 ++-- posthog/urls.py | 1 + 49 files changed, 1226 insertions(+), 261 deletions(-) create mode 100644 frontend/src/scenes/pipeline/destinations/constants.ts create mode 100644 posthog/cdp/site_functions.py create mode 100644 posthog/cdp/templates/_internal/template_blank.py create mode 100644 posthog/cdp/test/test_site_functions.py create mode 100644 posthog/migrations/0525_hog_function_transpiled.py diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5a9f9216aef9f..7be5df3d764d6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1764,11 +1764,17 @@ const api = { }, }, hogFunctions: { - async list(params?: { - filters?: any - type?: HogFunctionTypeType - }): Promise> { - return await new ApiRequest().hogFunctions().withQueryString(params).get() + async list( + filters?: any, + type?: HogFunctionTypeType | HogFunctionTypeType[] + ): Promise> { + return await new ApiRequest() + .hogFunctions() + .withQueryString({ + filters: filters, + ...(type ? (Array.isArray(type) ? { types: type.join(',') } : { type }) : {}), + }) + .get() }, async get(id: HogFunctionType['id']): Promise { return await new ApiRequest().hogFunction(id).get() @@ -1797,10 +1803,12 @@ const api = { ): Promise { return await new ApiRequest().hogFunction(id).withAction('metrics/totals').withQueryString(params).get() }, - async listTemplates(type?: HogFunctionTypeType): Promise> { + async listTemplates( + type?: HogFunctionTypeType | HogFunctionTypeType[] + ): Promise> { return new ApiRequest() .hogFunctionTemplates() - .withQueryString({ type: type ?? 'destination' }) + .withQueryString(Array.isArray(type) ? { types: type.join(',') } : { type: type ?? 'destination' }) .get() }, async getTemplate(id: HogFunctionTemplateType['id']): Promise { diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 53c7dcedb5fec..19443eaf33ad7 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -233,6 +233,7 @@ export const FEATURE_FLAGS = { EXPERIMENTS_MULTIPLE_METRICS: 'experiments-multiple-metrics', // owner: @jurajmajerik #team-experiments WEB_ANALYTICS_WARN_CUSTOM_EVENT_NO_SESSION: 'web-analytics-warn-custom-event-no-session', // owner: @robbie-c #team-web-analytics TWO_FACTOR_UI: 'two-factor-ui', // owner: @zach + SITE_DESTINATIONS: 'site-destinations', // owner: @mariusandra #team-cdp } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/scenes/actions/actionLogic.ts b/frontend/src/scenes/actions/actionLogic.ts index 15d95e1475da2..e3a7791b6cdc1 100644 --- a/frontend/src/scenes/actions/actionLogic.ts +++ b/frontend/src/scenes/actions/actionLogic.ts @@ -40,7 +40,7 @@ export const actionLogic = kea([ null as HogFunctionType[] | null, { loadMatchingHogFunctions: async () => { - const res = await api.hogFunctions.list({ filters: { actions: [{ id: `${props.id}` }] } }) + const res = await api.hogFunctions.list({ actions: [{ id: `${props.id}` }] }) return res.results }, diff --git a/frontend/src/scenes/messaging/functionsTableLogic.tsx b/frontend/src/scenes/messaging/functionsTableLogic.tsx index 482cad4dab120..035157dcb2ee0 100644 --- a/frontend/src/scenes/messaging/functionsTableLogic.tsx +++ b/frontend/src/scenes/messaging/functionsTableLogic.tsx @@ -48,7 +48,7 @@ export const functionsTableLogic = kea([ { loadHogFunctions: async () => { // TODO: pagination? - return (await api.hogFunctions.list({ type: props.type ?? 'destination' })).results + return (await api.hogFunctions.list(undefined, props.type ?? 'destination')).results }, deleteHogFunction: async ({ hogFunction }) => { await deleteWithUndo({ diff --git a/frontend/src/scenes/pipeline/destinations/DestinationTag.tsx b/frontend/src/scenes/pipeline/destinations/DestinationTag.tsx index 328751f8a7bf5..0c65ee12f1634 100644 --- a/frontend/src/scenes/pipeline/destinations/DestinationTag.tsx +++ b/frontend/src/scenes/pipeline/destinations/DestinationTag.tsx @@ -11,6 +11,8 @@ export function DestinationTag({ status }: { status: HogFunctionTemplateStatus } return Beta case 'stable': return New // Once Hog Functions are fully released we can remove the new label + case 'client-side': + return Client-Side default: return status ? {capitalizeFirstLetter(status)} : null } diff --git a/frontend/src/scenes/pipeline/destinations/constants.ts b/frontend/src/scenes/pipeline/destinations/constants.ts new file mode 100644 index 0000000000000..0613f531e28f1 --- /dev/null +++ b/frontend/src/scenes/pipeline/destinations/constants.ts @@ -0,0 +1,4 @@ +import { HogFunctionTypeType } from '~/types' + +export const getDestinationTypes = (featureFlagEnabled: boolean): HogFunctionTypeType[] => + featureFlagEnabled ? ['destination', 'site_destination'] : ['destination'] diff --git a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx index fa2352fff872c..a62b36f289b66 100644 --- a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx +++ b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx @@ -29,6 +29,7 @@ import { WebhookDestination, } from '../types' import { captureBatchExportEvent, capturePluginEvent, loadPluginsFromUrl } from '../utils' +import { getDestinationTypes } from './constants' import { destinationsFiltersLogic } from './destinationsFiltersLogic' import type { pipelineDestinationsLogicType } from './destinationsLogicType' @@ -166,8 +167,8 @@ export const pipelineDestinationsLogic = kea([ [] as HogFunctionType[], { loadHogFunctions: async () => { - // TODO: Support pagination? - return (await api.hogFunctions.list({ type: 'destination' })).results + const destinationTypes = getDestinationTypes(!!values.featureFlags[FEATURE_FLAGS.SITE_DESTINATIONS]) + return (await api.hogFunctions.list(undefined, destinationTypes)).results }, deleteNodeHogFunction: async ({ destination }) => { diff --git a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx index 2aed66735b44b..66c49d0570289 100644 --- a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx +++ b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx @@ -20,6 +20,7 @@ import { humanizeBatchExportName } from '../batch-exports/utils' import { HogFunctionIcon } from '../hogfunctions/HogFunctionIcon' import { PipelineBackend } from '../types' import { RenderBatchExportIcon } from '../utils' +import { getDestinationTypes } from './constants' import { destinationsFiltersLogic } from './destinationsFiltersLogic' import type { newDestinationsLogicType } from './newDestinationsLogicType' @@ -43,12 +44,13 @@ export const newDestinationsLogic = kea([ actions({ openFeedbackDialog: true, }), - loaders({ + loaders(({ values }) => ({ hogFunctionTemplates: [ {} as Record, { loadHogFunctionTemplates: async () => { - const templates = await api.hogFunctions.listTemplates() + const destinationTypes = getDestinationTypes(!!values.featureFlags[FEATURE_FLAGS.SITE_DESTINATIONS]) + const templates = await api.hogFunctions.listTemplates(destinationTypes) return templates.results.reduce((acc, template) => { acc[template.id] = template return acc @@ -56,7 +58,7 @@ export const newDestinationsLogic = kea([ }, }, ], - }), + })), selectors(() => ({ loading: [(s) => [s.hogFunctionTemplatesLoading], (hogFunctionTemplatesLoading) => hogFunctionTemplatesLoading], diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx index 2879e88e79e86..f837bc49fe7b3 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx @@ -151,11 +151,14 @@ 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 showFilters = type === 'destination' || type === 'site_destination' || type === 'broadcast' + const showExpectedVolume = type === 'destination' || type === 'site_destination' + const showStatus = type === 'destination' || type === 'email' + const showEnabled = type === 'destination' || type === 'email' || type === 'site_destination' || type === 'site_app' + const canEditSource = + type === 'destination' || type === 'email' || type === 'site_destination' || type === 'site_app' const showPersonsCount = type === 'broadcast' + const showTesting = type === 'destination' || type === 'broadcast' || type === 'email' return (
@@ -210,7 +213,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur {template && }
- {showEnabled && } + {showStatus && } {showEnabled && ( {({ value, onChange }) => ( @@ -236,7 +239,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur - {hogFunction?.template ? ( + {hogFunction?.template && !hogFunction.template.id.startsWith('template-blank-') ? ( {({ value, onChange }) => ( <> - - This is the underlying Hog code that will run whenever the - filters match.{' '} - See the docs for - more info - + {!type.startsWith('site_') ? ( + + This is the underlying Hog code that will run whenever the + filters match.{' '} + See the docs{' '} + for more info + + ) : null} onChange(v ?? '')} globals={globalsWithInputs} @@ -489,8 +494,13 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur ) : null} )} - - {!id || id === 'new' ? : } + {showTesting ? ( + !id || id === 'new' ? ( + + ) : ( + + ) + ) : null}
{saveButtons}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx index 92c1729a080c2..fee79b358a74d 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx @@ -313,7 +313,7 @@ export function HogFunctionInputWithSchema({ schema }: HogFunctionInputWithSchem const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: schema.key }) const { showSource, configuration } = useValues(hogFunctionConfigurationLogic) const { setConfigurationValue } = useActions(hogFunctionConfigurationLogic) - const [editing, setEditing] = useState(showSource) + const [editing, setEditing] = useState(false) const value = configuration.inputs?.[schema.key] diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx index 681e63a6239b2..1dd5588045f54 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx @@ -74,6 +74,8 @@ export function HogFunctionFilters(): JSX.Element { ) } + const showMasking = type === 'destination' + return (
@@ -144,117 +146,119 @@ export function HogFunctionFilters(): JSX.Element { )} - - {({ value, onChange }) => ( -
- - onChange({ - hash: val, - ttl: value?.ttl ?? 60 * 30, - }) - } - /> - {configuration.masking?.hash ? ( - <> -
- of - onChange({ ...value, ttl: val })} - options={[ - { - value: 5 * 60, - label: '5 minutes', - }, - { - value: 15 * 60, - label: '15 minutes', - }, - { - value: 30 * 60, - label: '30 minutes', - }, - { - value: 60 * 60, - label: '1 hour', - }, - { - value: 2 * 60 * 60, - label: '2 hours', - }, - { - value: 4 * 60 * 60, - label: '4 hours', - }, - { - value: 8 * 60 * 60, - label: '8 hours', - }, - { - value: 12 * 60 * 60, - label: '12 hours', - }, - { - value: 24 * 60 * 60, - label: '24 hours', - }, - ]} - /> -
-
- or until - onChange({ ...value, threshold: val })} - options={[ - { - value: null, - label: 'Not set', - }, - { - value: 1000, - label: '1000 events', - }, - { - value: 10000, - label: '10,000 events', - }, - { - value: 100000, - label: '100,000 events', - }, - { - value: 1000000, - label: '1,000,000 events', - }, - ]} - /> -
- - ) : null} -
- )} -
+ {showMasking ? ( + + {({ value, onChange }) => ( +
+ + onChange({ + hash: val, + ttl: value?.ttl ?? 60 * 30, + }) + } + /> + {configuration.masking?.hash ? ( + <> +
+ of + onChange({ ...value, ttl: val })} + options={[ + { + value: 5 * 60, + label: '5 minutes', + }, + { + value: 15 * 60, + label: '15 minutes', + }, + { + value: 30 * 60, + label: '30 minutes', + }, + { + value: 60 * 60, + label: '1 hour', + }, + { + value: 2 * 60 * 60, + label: '2 hours', + }, + { + value: 4 * 60 * 60, + label: '4 hours', + }, + { + value: 8 * 60 * 60, + label: '8 hours', + }, + { + value: 12 * 60 * 60, + label: '12 hours', + }, + { + value: 24 * 60 * 60, + label: '24 hours', + }, + ]} + /> +
+
+ or until + onChange({ ...value, threshold: val })} + options={[ + { + value: null, + label: 'Not set', + }, + { + value: 1000, + label: '1000 events', + }, + { + value: 10000, + label: '10,000 events', + }, + { + value: 100000, + label: '100,000 events', + }, + { + value: 1000000, + label: '1,000,000 events', + }, + ]} + /> +
+ + ) : null} +
+ )} +
+ ) : null}
) } diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx index f4c16e05d9155..229abea7e424d 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx @@ -197,9 +197,10 @@ export const hogFunctionConfigurationLogic = kea ({ error }), }), - reducers({ + reducers(({ props }) => ({ showSource: [ - false, + // Show source by default for blank templates when creating a new function + !!(!props.id && props.templateId?.startsWith('template-blank-')), { setShowSource: (_, { showSource }) => showSource, }, @@ -234,7 +235,7 @@ export const hogFunctionConfigurationLogic = kea error, }, ], - }), + })), loaders(({ actions, props, values }) => ({ template: [ null as HogFunctionTemplateType | null, diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx index 863b14b7ecf8a..44a20fdb1900e 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionListLogic.tsx @@ -74,12 +74,7 @@ export const hogFunctionListLogic = kea([ [] as HogFunctionType[], { loadHogFunctions: async () => { - return ( - await api.hogFunctions.list({ - filters: values.filters?.filters, - type: props.type, - }) - ).results + return (await api.hogFunctions.list(values.filters?.filters, props.type)).results }, deleteHogFunction: async ({ hogFunction }) => { await deleteWithUndo({ diff --git a/frontend/src/scenes/pipeline/pipelineAccessLogic.tsx b/frontend/src/scenes/pipeline/pipelineAccessLogic.tsx index 5c658302553da..58b4129c9523f 100644 --- a/frontend/src/scenes/pipeline/pipelineAccessLogic.tsx +++ b/frontend/src/scenes/pipeline/pipelineAccessLogic.tsx @@ -29,7 +29,8 @@ export const pipelineAccessLogic = kea([ return (destination: Destination | NewDestinationItemType) => { return destination.backend === PipelineBackend.HogFunction ? ('hog_function' in destination - ? destination.hog_function.template?.status === 'free' + ? destination.hog_function.type === 'site_destination' || + destination.hog_function.template?.status === 'free' : destination.status === 'free') || canEnableNewDestinations : canEnableNewDestinations } diff --git a/frontend/src/scenes/pipeline/types.ts b/frontend/src/scenes/pipeline/types.ts index 2ace2b479da5f..757273a940582 100644 --- a/frontend/src/scenes/pipeline/types.ts +++ b/frontend/src/scenes/pipeline/types.ts @@ -76,7 +76,7 @@ export type NewDestinationItemType = { name: string description: string backend: PipelineBackend - status?: 'stable' | 'beta' | 'alpha' | 'free' | 'deprecated' + status?: 'stable' | 'beta' | 'alpha' | 'free' | 'deprecated' | 'client-side' } export type NewDestinationFilters = { @@ -131,7 +131,10 @@ export function convertToPipelineNode( stage: stage as PipelineStage.Destination, backend: PipelineBackend.HogFunction, interval: 'realtime', - id: candidate.type === 'destination' ? `hog-${candidate.id}` : candidate.id, + id: + candidate.type === 'destination' || candidate.type === 'site_destination' + ? `hog-${candidate.id}` + : candidate.id, name: candidate.name, description: candidate.description, enabled: candidate.enabled, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 498eb1f1f5a67..8ad57c8964ec9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4591,7 +4591,16 @@ export interface HogFunctionFiltersType { bytecode_error?: string } -export type HogFunctionTypeType = 'destination' | 'email' | 'sms' | 'push' | 'activity' | 'alert' | 'broadcast' +export type HogFunctionTypeType = + | 'destination' + | 'site_destination' + | 'site_app' + | 'email' + | 'sms' + | 'push' + | 'activity' + | 'alert' + | 'broadcast' export type HogFunctionType = { id: string @@ -4613,7 +4622,7 @@ export type HogFunctionType = { status?: HogFunctionStatus } -export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' +export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' | 'client-side' export type HogFunctionSubTemplateIdType = 'early_access_feature_enrollment' | 'survey_response' export type HogFunctionConfigurationType = Omit< diff --git a/plugin-server/src/cdp/utils.ts b/plugin-server/src/cdp/utils.ts index 6546db471d88f..f4c09b602a514 100644 --- a/plugin-server/src/cdp/utils.ts +++ b/plugin-server/src/cdp/utils.ts @@ -135,12 +135,16 @@ export function convertToHogFunctionFilterGlobal(globals: HogFunctionInvocationG for (const [_groupType, group] of Object.entries(globals.groups || {})) { groups[`group_${group.index}`] = { + key: group.id, + index: group.index, properties: group.properties, } + groups[_groupType] = groups[`group_${group.index}`] } const elementsChain = globals.event.elements_chain ?? globals.event.properties['$elements_chain'] const response = { + ...groups, event: globals.event.event, elements_chain: elementsChain, elements_chain_href: '', @@ -158,7 +162,6 @@ export function convertToHogFunctionFilterGlobal(globals: HogFunctionInvocationG } : undefined, distinct_id: globals.event.distinct_id, - ...groups, } satisfies HogFunctionFilterGlobals // The elements_chain_* fields are stored as materialized columns in ClickHouse. diff --git a/plugin-server/tests/cdp/utils.test.ts b/plugin-server/tests/cdp/utils.test.ts index 6640662b2e79e..c343f8e6461a1 100644 --- a/plugin-server/tests/cdp/utils.test.ts +++ b/plugin-server/tests/cdp/utils.test.ts @@ -1,7 +1,12 @@ import { DateTime } from 'luxon' -import { HogFunctionInvocationResult } from '../../src/cdp/types' -import { gzipObject, prepareLogEntriesForClickhouse, unGzipObject } from '../../src/cdp/utils' +import { HogFunctionInvocationGlobals, HogFunctionInvocationResult } from '../../src/cdp/types' +import { + convertToHogFunctionFilterGlobal, + gzipObject, + prepareLogEntriesForClickhouse, + unGzipObject, +} from '../../src/cdp/utils' import { createHogFunction, createInvocation, insertHogFunction as _insertHogFunction } from './fixtures' describe('Utils', () => { @@ -92,4 +97,65 @@ describe('Utils', () => { `) }) }) + + describe('convertToHogFunctionFilterGlobal', () => { + it('should correctly map groups to response', () => { + const globals: HogFunctionInvocationGlobals = { + project: { + id: 1, + name: 'Test Project', + url: 'http://example.com', + }, + event: { + uuid: 'event_uuid', + event: 'test_event', + distinct_id: 'user_123', + properties: {}, + elements_chain: '', + timestamp: DateTime.now().toISO(), + url: 'http://example.com/event', + }, + person: { + id: 'person_123', + properties: {}, + name: 'Test User', + url: 'http://example.com/person', + }, + groups: { + organization: { + id: 'org_123', + type: 'organization', + index: 0, + properties: { name: 'Acme Corp' }, + url: 'http://example.com/org', + }, + project: { + id: 'proj_456', + type: 'project', + index: 1, + properties: { name: 'Project X' }, + url: 'http://example.com/project', + }, + }, + } + + const response = convertToHogFunctionFilterGlobal(globals) + + // Verify that group_0 and organization are set correctly + expect(response['group_0']).toEqual({ + key: 'org_123', + index: 0, + properties: { name: 'Acme Corp' }, + }) + expect(response['organization']).toBe(response['group_0']) + + // Verify that group_1 and project are set correctly + expect(response['group_1']).toEqual({ + key: 'proj_456', + index: 1, + properties: { name: 'Project X' }, + }) + expect(response['project']).toBe(response['group_1']) + }) + }) }) diff --git a/posthog/api/decide.py b/posthog/api/decide.py index 2a9995ee88df8..c636ac58fcd9e 100644 --- a/posthog/api/decide.py +++ b/posthog/api/decide.py @@ -31,7 +31,7 @@ from posthog.models.feature_flag.flag_analytics import increment_request_count from posthog.models.filters.mixins.utils import process_bool from posthog.models.utils import execute_with_timeout -from posthog.plugins.site import get_decide_site_apps +from posthog.plugins.site import get_decide_site_apps, get_decide_site_functions from posthog.utils import ( get_ip_address, label_for_team_id_to_track, @@ -300,6 +300,8 @@ def get_decide(request: HttpRequest): try: with execute_with_timeout(200, DATABASE_FOR_FLAG_MATCHING): site_apps = get_decide_site_apps(team, using_database=DATABASE_FOR_FLAG_MATCHING) + with execute_with_timeout(200, DATABASE_FOR_FLAG_MATCHING): + site_apps += get_decide_site_functions(team, using_database=DATABASE_FOR_FLAG_MATCHING) except Exception: pass diff --git a/posthog/api/hog_function.py b/posthog/api/hog_function.py index 3d82cb367ef8c..1381d5b351cde 100644 --- a/posthog/api/hog_function.py +++ b/posthog/api/hog_function.py @@ -18,13 +18,22 @@ from posthog.api.routing import TeamAndOrgViewSetMixin from posthog.api.shared import UserBasicSerializer -from posthog.cdp.filters import compile_filters_bytecode +from posthog.cdp.filters import compile_filters_bytecode, compile_filters_expr from posthog.cdp.services.icons import CDPIconsService from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES_BY_ID from posthog.cdp.validation import compile_hog, generate_template_bytecode, validate_inputs, validate_inputs_schema +from posthog.cdp.site_functions import get_transpiled_function from posthog.constants import AvailableFeature +from posthog.hogql.compiler.javascript import JavaScriptCompiler from posthog.models.activity_logging.activity_log import log_activity, changes_between, Detail -from posthog.models.hog_functions.hog_function import HogFunction, HogFunctionState, TYPES_WITH_COMPILED_FILTERS +from posthog.models.hog_functions.hog_function import ( + HogFunction, + HogFunctionState, + TYPES_WITH_COMPILED_FILTERS, + TYPES_WITH_TRANSPILED_FILTERS, + TYPES_WITH_JAVASCRIPT_SOURCE, +) +from posthog.models.plugin import TranspilerError from posthog.plugins.plugin_server_api import create_hog_invocation_test @@ -93,6 +102,7 @@ class Meta: "deleted", "hog", "bytecode", + "transpiled", "inputs_schema", "inputs", "filters", @@ -108,6 +118,7 @@ class Meta: "created_by", "updated_at", "bytecode", + "transpiled", "template", "status", ] @@ -144,6 +155,9 @@ def validate(self, attrs): attrs["inputs_schema"] = template.inputs_schema attrs["hog"] = template.hog + if "type" not in attrs: + attrs["type"] = "destination" + if self.context.get("view") and self.context["view"].action == "create": # Ensure we have sensible defaults when created attrs["filters"] = attrs.get("filters") or {} @@ -161,16 +175,36 @@ def validate(self, attrs): existing_encrypted_inputs = instance.encrypted_inputs attrs["inputs_schema"] = attrs.get("inputs_schema", instance.inputs_schema if instance else []) - attrs["inputs"] = validate_inputs(attrs["inputs_schema"], inputs, existing_encrypted_inputs) + attrs["inputs"] = validate_inputs(attrs["inputs_schema"], inputs, existing_encrypted_inputs, attrs["type"]) + + if "filters" in attrs: + if attrs["type"] in TYPES_WITH_COMPILED_FILTERS: + attrs["filters"] = compile_filters_bytecode(attrs["filters"], team) + elif attrs["type"] in TYPES_WITH_TRANSPILED_FILTERS: + compiler = JavaScriptCompiler() + code = compiler.visit(compile_filters_expr(attrs["filters"], team)) + attrs["filters"]["transpiled"] = {"lang": "ts", "code": code, "stl": list(compiler.stl_functions)} + if "bytecode" in attrs["filters"]: + del attrs["filters"]["bytecode"] if "hog" in attrs: - attrs["bytecode"] = compile_hog(attrs["hog"]) - - if "type" not in attrs: - attrs["type"] = "destination" - - if "filters" in attrs and attrs["type"] in TYPES_WITH_COMPILED_FILTERS: - attrs["filters"] = compile_filters_bytecode(attrs["filters"], team) + if attrs["type"] in TYPES_WITH_JAVASCRIPT_SOURCE: + # Upon creation, this code will be run before the model has an "id". + # If that's the case, the code just makes sure transpilation doesn't throw. We'll re-transpile after creation. + id = str(instance.id) if instance else "__" + try: + attrs["transpiled"] = get_transpiled_function( + id, attrs["hog"], attrs["filters"], attrs["inputs"], team + ) + except TranspilerError: + raise serializers.ValidationError({"hog": f"Error in TypeScript code"}) + attrs["bytecode"] = None + else: + attrs["bytecode"] = compile_hog(attrs["hog"]) + attrs["transpiled"] = None + else: + attrs["bytecode"] = None + attrs["transpiled"] = None return super().validate(attrs) @@ -196,7 +230,13 @@ def to_representation(self, data): def create(self, validated_data: dict, *args, **kwargs) -> HogFunction: request = self.context["request"] validated_data["created_by"] = request.user - return super().create(validated_data=validated_data) + hog_function = super().create(validated_data=validated_data) + if validated_data.get("type") in TYPES_WITH_JAVASCRIPT_SOURCE: + # Re-run the transpilation now that we have an ID + hog_function.transpiled = get_transpiled_function( + str(hog_function.id), hog_function.hog, hog_function.filters, hog_function.inputs, hog_function.team + ) + return hog_function def update(self, instance: HogFunction, validated_data: dict, *args, **kwargs) -> HogFunction: res: HogFunction = super().update(instance, validated_data) @@ -231,8 +271,13 @@ def get_serializer_class(self) -> type[BaseSerializer]: def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: if self.action == "list": - type = self.request.GET.get("type", "destination") - queryset = queryset.filter(deleted=False, type=type) + if "type" in self.request.GET: + types = [self.request.GET.get("type", "destination")] + elif "types" in self.request.GET: + types = self.request.GET.get("types", "destination").split(",") + else: + types = ["destination"] + queryset = queryset.filter(deleted=False, type__in=types) if self.request.GET.get("filters"): try: diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py index 2f68614e50a02..2044affa77075 100644 --- a/posthog/api/hog_function_template.py +++ b/posthog/api/hog_function_template.py @@ -33,8 +33,12 @@ class PublicHogFunctionTemplateViewSet(viewsets.GenericViewSet): serializer_class = HogFunctionTemplateSerializer def list(self, request: Request, *args, **kwargs): - type = self.request.GET.get("type", "destination") - templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type == type] + types = ["destination"] + if "type" in request.GET: + types = [self.request.GET.get("type", "destination")] + elif "types" in request.GET: + types = self.request.GET.get("types", "destination").split(",") + templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type in types] page = self.paginate_queryset(templates) serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) diff --git a/posthog/api/site_app.py b/posthog/api/site_app.py index 6704ff0c7f534..40e1df08ea778 100644 --- a/posthog/api/site_app.py +++ b/posthog/api/site_app.py @@ -8,6 +8,7 @@ from posthog.exceptions import generate_exception_response from posthog.logging.timing import timed +from posthog.models.hog_functions.hog_function import HogFunction from posthog.plugins.site import get_site_config_from_schema, get_transpiled_site_source @@ -36,3 +37,34 @@ def get_site_app(request: HttpRequest, id: int, token: str, hash: str) -> HttpRe type="server_error", status_code=status.HTTP_404_NOT_FOUND, ) + + +@csrf_exempt +@timed("posthog_cloud_site_app_endpoint") +def get_site_function(request: HttpRequest, id: str, hash: str) -> HttpResponse: + try: + # TODO: Should we add a token as well? Is the UUID enough? + function = ( + HogFunction.objects.filter( + id=id, enabled=True, type__in=("site_destination", "site_app"), transpiled__isnull=False + ) + .values_list("transpiled") + .first() + ) + if not function: + raise Exception("No function found") + + response = HttpResponse(content=function[0], content_type="application/javascript") + response["Cache-Control"] = "public, max-age=31536000" # Cache for 1 year + statsd.incr(f"posthog_cloud_raw_endpoint_success", tags={"endpoint": "site_function"}) + return response + except Exception as e: + capture_exception(e, {"data": {"id": id}}) + statsd.incr("posthog_cloud_raw_endpoint_failure", tags={"endpoint": "site_function"}) + return generate_exception_response( + "site_function", + "Unable to serve site function source code.", + code="missing_site_function_source", + type="server_error", + status_code=status.HTTP_404_NOT_FOUND, + ) diff --git a/posthog/api/test/__snapshots__/test_decide.ambr b/posthog/api/test/__snapshots__/test_decide.ambr index a7be20804014f..beb108518df40 100644 --- a/posthog/api/test/__snapshots__/test_decide.ambr +++ b/posthog/api/test/__snapshots__/test_decide.ambr @@ -260,6 +260,7 @@ "posthog_hogfunction"."icon_url", "posthog_hogfunction"."hog", "posthog_hogfunction"."bytecode", + "posthog_hogfunction"."transpiled", "posthog_hogfunction"."inputs_schema", "posthog_hogfunction"."inputs", "posthog_hogfunction"."encrypted_inputs", @@ -510,6 +511,19 @@ AND "posthog_pluginconfig"."team_id" = 99999) ''' # --- +# name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.25 + ''' + SELECT "posthog_hogfunction"."id", + "posthog_hogfunction"."updated_at", + "posthog_hogfunction"."type" + FROM "posthog_hogfunction" + WHERE ("posthog_hogfunction"."enabled" + AND "posthog_hogfunction"."team_id" = 99999 + AND "posthog_hogfunction"."transpiled" IS NOT NULL + AND "posthog_hogfunction"."type" IN ('site_destination', + 'site_app')) + ''' +# --- # name: TestDecide.test_decide_doesnt_error_out_when_database_is_down.3 ''' SELECT "posthog_team"."id", @@ -785,6 +799,7 @@ "posthog_hogfunction"."icon_url", "posthog_hogfunction"."hog", "posthog_hogfunction"."bytecode", + "posthog_hogfunction"."transpiled", "posthog_hogfunction"."inputs_schema", "posthog_hogfunction"."inputs", "posthog_hogfunction"."encrypted_inputs", @@ -1082,6 +1097,7 @@ "posthog_hogfunction"."icon_url", "posthog_hogfunction"."hog", "posthog_hogfunction"."bytecode", + "posthog_hogfunction"."transpiled", "posthog_hogfunction"."inputs_schema", "posthog_hogfunction"."inputs", "posthog_hogfunction"."encrypted_inputs", @@ -1473,6 +1489,7 @@ "posthog_hogfunction"."icon_url", "posthog_hogfunction"."hog", "posthog_hogfunction"."bytecode", + "posthog_hogfunction"."transpiled", "posthog_hogfunction"."inputs_schema", "posthog_hogfunction"."inputs", "posthog_hogfunction"."encrypted_inputs", @@ -1580,3 +1597,16 @@ AND "posthog_pluginconfig"."team_id" = 99999) ''' # --- +# name: TestDecide.test_web_app_queries.6 + ''' + SELECT "posthog_hogfunction"."id", + "posthog_hogfunction"."updated_at", + "posthog_hogfunction"."type" + FROM "posthog_hogfunction" + WHERE ("posthog_hogfunction"."enabled" + AND "posthog_hogfunction"."team_id" = 99999 + AND "posthog_hogfunction"."transpiled" IS NOT NULL + AND "posthog_hogfunction"."type" IN ('site_destination', + 'site_app')) + ''' +# --- diff --git a/posthog/api/test/__snapshots__/test_insight.ambr b/posthog/api/test/__snapshots__/test_insight.ambr index b6d0b7945dd2d..d0cb1a5dc625c 100644 --- a/posthog/api/test/__snapshots__/test_insight.ambr +++ b/posthog/api/test/__snapshots__/test_insight.ambr @@ -1380,12 +1380,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '441' + AND "ee_accesscontrol"."resource_id" = '446' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '441' + AND "ee_accesscontrol"."resource_id" = '446' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -1493,12 +1493,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '441' + AND "ee_accesscontrol"."resource_id" = '446' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '441' + AND "ee_accesscontrol"."resource_id" = '446' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL diff --git a/posthog/api/test/test_decide.py b/posthog/api/test/test_decide.py index f2201798366e3..5af8e61dc3068 100644 --- a/posthog/api/test/test_decide.py +++ b/posthog/api/test/test_decide.py @@ -38,6 +38,7 @@ from posthog.models.cohort.cohort import Cohort from posthog.models.feature_flag.feature_flag import FeatureFlagHashKeyOverride from posthog.models.group.group import Group +from posthog.models.hog_functions.hog_function import HogFunction from posthog.models.organization import Organization, OrganizationMembership from posthog.models.person import PersonDistinctId from posthog.models.personal_api_key import hash_key_value @@ -663,7 +664,7 @@ def test_web_app_queries(self, *args): # caching flag definitions in the above mean fewer queries # 3 of these queries are just for setting transaction scope - with self.assertNumQueries(4): + with self.assertNumQueries(8): response = self._post_decide() self.assertEqual(response.status_code, status.HTTP_200_OK) injected = response.json()["siteApps"] @@ -688,13 +689,52 @@ def test_site_app_injection(self, *args): ) self.team.refresh_from_db() self.assertTrue(self.team.inject_web_apps) - with self.assertNumQueries(5): + with self.assertNumQueries(9): response = self._post_decide() self.assertEqual(response.status_code, status.HTTP_200_OK) injected = response.json()["siteApps"] self.assertEqual(len(injected), 1) self.assertTrue(injected[0]["url"].startswith(f"/site_app/{plugin_config.id}/{plugin_config.web_token}/")) + def test_site_function_injection(self, *args): + # yype: site_app + site_app = HogFunction.objects.create( + team=self.team, + name="my_function", + hog="function onLoad(){}", + type="site_app", + transpiled="function onLoad(){}", + enabled=True, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.inject_web_apps) + with self.assertNumQueries(9): + response = self._post_decide() + self.assertEqual(response.status_code, status.HTTP_200_OK) + injected = response.json()["siteApps"] + self.assertEqual(len(injected), 1) + self.assertTrue(injected[0]["url"].startswith(f"/site_function/{site_app.id}/")) + + # yype: site_destination + site_destination = HogFunction.objects.create( + team=self.team, + name="my_function", + hog="function onLoad(){}", + type="site_destination", + transpiled="function onLoad(){}", + enabled=True, + ) + + self.team.refresh_from_db() + self.assertTrue(self.team.inject_web_apps) + with self.assertNumQueries(8): + response = self._post_decide() + self.assertEqual(response.status_code, status.HTTP_200_OK) + injected = response.json()["siteApps"] + self.assertEqual(len(injected), 2) + self.assertTrue(injected[1]["url"].startswith(f"/site_function/{site_destination.id}/")) + def test_feature_flags(self, *args): self.team.app_urls = ["https://example.com"] self.team.save() @@ -4690,7 +4730,7 @@ def test_site_apps_in_decide_use_replica(self, mock_is_connected): # update caches self._post_decide(api_version=3) - with self.assertNumQueries(4, using="replica"), self.assertNumQueries(0, using="default"): + with self.assertNumQueries(8, using="replica"), self.assertNumQueries(0, using="default"): response = self._post_decide(api_version=3) self.assertEqual(response.status_code, status.HTTP_200_OK) injected = response.json()["siteApps"] diff --git a/posthog/api/test/test_hog_function.py b/posthog/api/test/test_hog_function.py index 18323f7a2341b..18ef5e9cab2a4 100644 --- a/posthog/api/test/test_hog_function.py +++ b/posthog/api/test/test_hog_function.py @@ -174,6 +174,7 @@ def test_create_hog_function(self, *args): "enabled": False, "hog": "fetch(inputs.url);", "bytecode": ["_H", HOGQL_BYTECODE_VERSION, 32, "url", 32, "inputs", 1, 2, 2, "fetch", 1, 35], + "transpiled": None, "inputs_schema": [], "inputs": {}, "filters": {"bytecode": ["_H", HOGQL_BYTECODE_VERSION, 29]}, @@ -985,3 +986,99 @@ def test_list_with_type_filter(self, *args): response = self.client.get(f"/api/projects/{self.team.id}/hog_functions/?type=email") assert len(response.json()["results"]) == 1 + + response = self.client.get(f"/api/projects/{self.team.id}/hog_functions/?types=destination,email") + assert len(response.json()["results"]) == 2 + + def test_create_hog_function_with_site_app_type(self): + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + data={ + "name": "Site App Function", + "hog": "export function onLoad() { console.log('Hello, site_app'); }", + "type": "site_app", + }, + ) + + assert response.status_code == status.HTTP_201_CREATED, response.json() + assert response.json()["bytecode"] is None + assert "Hello, site_app" in response.json()["transpiled"] + + def test_create_hog_function_with_site_destination_type(self): + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + data={ + "name": "Site Destination Function", + "hog": "export function onLoad() { console.log('Hello, site_destination'); }", + "type": "site_destination", + }, + ) + + assert response.status_code == status.HTTP_201_CREATED, response.json() + assert response.json()["bytecode"] is None + assert "Hello, site_destination" in response.json()["transpiled"] + + def test_transpiled_field_not_populated_for_other_types(self): + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + data={ + "name": "Regular Function", + "hog": "fetch(inputs.url);", + "type": "destination", + }, + ) + + assert response.status_code == status.HTTP_201_CREATED, response.json() + assert response.json()["bytecode"] is not None + assert response.json()["transpiled"] is None + + def test_create_hog_function_with_invalid_typescript(self): + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + data={ + "name": "Invalid Site App Function", + "hog": "export function onLoad() { console.log('Missing closing brace');", + "type": "site_app", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST, response.json() + assert "detail" in response.json() + assert "Error in TypeScript code" in response.json()["detail"] + + def test_create_typescript_destination_with_inputs(self): + payload = { + "name": "TypeScript Destination Function", + "hog": "export function onLoad() { console.log(inputs.message); }", + "type": "site_destination", + "inputs_schema": [ + {"key": "message", "type": "string", "label": "Message", "required": True}, + ], + "inputs": { + "message": { + "value": "Hello, TypeScript {arrayMap(a -> a, [1, 2, 3])}!", + }, + }, + } + + response = self.client.post( + f"/api/projects/{self.team.id}/hog_functions/", + data=payload, + ) + result = response.json() + + assert response.status_code == status.HTTP_201_CREATED, response.json() + assert result["bytecode"] is None + assert "Hello, TypeScript" in result["transpiled"] + inputs = result["inputs"] + inputs["message"]["transpiled"]["stl"].sort() + assert result["inputs"] == { + "message": { + "transpiled": { + "code": 'concat("Hello, TypeScript ", arrayMap(__lambda((a) => a), [1, 2, 3]), "!")', + "lang": "ts", + "stl": sorted(["__lambda", "concat", "arrayMap"]), + }, + "value": "Hello, TypeScript {arrayMap(a -> a, [1, 2, 3])}!", + } + } diff --git a/posthog/api/test/test_hog_function_templates.py b/posthog/api/test/test_hog_function_templates.py index cd9479a10b456..956be4de638a9 100644 --- a/posthog/api/test/test_hog_function_templates.py +++ b/posthog/api/test/test_hog_function_templates.py @@ -40,6 +40,12 @@ def test_filter_function_templates(self): assert response2.json()["results"] == response3.json()["results"] assert len(response2.json()["results"]) > 5 + response4 = self.client.get("/api/projects/@current/hog_function_templates/?type=site_destination") + assert len(response4.json()["results"]) > 0 + + response5 = self.client.get("/api/projects/@current/hog_function_templates/?types=site_destination,destination") + assert len(response5.json()["results"]) > 0 + def test_public_list_function_templates(self): self.client.logout() response = self.client.get("/api/public_hog_function_templates/") diff --git a/posthog/api/test/test_site_app.py b/posthog/api/test/test_site_app.py index 92340e67144bd..23408defe752f 100644 --- a/posthog/api/test/test_site_app.py +++ b/posthog/api/test/test_site_app.py @@ -2,7 +2,7 @@ from rest_framework import status from posthog.api.site_app import get_site_config_from_schema -from posthog.models import Plugin, PluginConfig, PluginSourceFile +from posthog.models import Plugin, PluginConfig, PluginSourceFile, HogFunction from posthog.test.base import BaseTest @@ -82,3 +82,32 @@ def test_get_site_config_from_schema(self): config = {"in_site": "123", "not_in_site": "12345"} self.assertEqual(get_site_config_from_schema(schema, config), {"in_site": "123"}) self.assertEqual(get_site_config_from_schema(None, None), {}) + + def test_site_function(self): + # Create a HogFunction object + hog_function = HogFunction.objects.create( + enabled=True, + team=self.team, + type="site_app", + transpiled="function test() {}", + ) + + response = self.client.get( + f"/site_function/{hog_function.id}/somehash/", + HTTP_ORIGIN="http://127.0.0.1:8000", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.content.decode("utf-8"), hog_function.transpiled) + self.assertEqual(response["Cache-Control"], "public, max-age=31536000") + + def test_site_function_not_found(self): + response = self.client.get( + f"/site_function/non-existent-id/somehash/", + HTTP_ORIGIN="http://127.0.0.1:8000", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + response_json = response.json() + self.assertEqual(response_json["code"], "missing_site_function_source") + self.assertEqual(response_json["detail"], "Unable to serve site function source code.") diff --git a/posthog/cdp/filters.py b/posthog/cdp/filters.py index 04fde83e5c532..f95d3f029a0f3 100644 --- a/posthog/cdp/filters.py +++ b/posthog/cdp/filters.py @@ -40,7 +40,10 @@ def hog_function_filters_to_expr(filters: dict, team: Team, actions: dict[int, A # Actions if filter.get("type") == "actions": try: - action = actions[int(filter["id"])] + action_id = int(filter["id"]) + action = actions.get(action_id, None) + if not action: + action = Action.objects.get(id=action_id, team=team) exprs.append(action_to_expr(action)) except KeyError: # If an action doesn't exist, we want to return no events diff --git a/posthog/cdp/site_functions.py b/posthog/cdp/site_functions.py new file mode 100644 index 0000000000000..6f4f7a3d5ffa5 --- /dev/null +++ b/posthog/cdp/site_functions.py @@ -0,0 +1,102 @@ +import json + +from posthog.cdp.filters import hog_function_filters_to_expr +from posthog.cdp.validation import transpile_template_code +from posthog.hogql.compiler.javascript import JavaScriptCompiler +from posthog.models.plugin import transpile +from posthog.models.team.team import Team + + +def get_transpiled_function(id: str, source: str, filters: dict, inputs: dict, team: Team) -> str: + # Wrap in IIFE = Immediately Invoked Function Expression = to avoid polluting global scope + response = "(function() {\n\n" + + # PostHog-JS adds itself to the window object for us to use + response += f"const posthog = window['__$$ph_site_app_{id}_posthog'] || window['__$$ph_site_app_{id}'] || window['posthog'];\n" + response += f"const missedInvocations = window['__$$ph_site_app_{id}_missed_invocations'] || (() => []);\n" + response += f"const callback = window['__$$ph_site_app_{id}_callback'] || (() => {'{}'});\n" + + # Build the inputs in three parts: + # 1) a simple object with constants/scalars + inputs_object: list[str] = [] + # 2) a function with a switch + try/catch that calculates the input from globals + inputs_switch = "" + # 3) code that adds all calculated inputs to the inputs object + inputs_append: list[str] = [] + + compiler = JavaScriptCompiler() + + # TODO: reorder inputs to make dependencies work + for key, input in inputs.items(): + value = input.get("value") + key_string = json.dumps(str(key) or "") + if (isinstance(value, str) and "{" in value) or isinstance(value, dict) or isinstance(value, list): + base_code = transpile_template_code(value, compiler) + inputs_switch += f"case {key_string}: return {base_code};\n" + inputs_append.append(f"inputs[{key_string}] = getInputsKey({json.dumps(key)});") + else: + inputs_object.append(f"{key_string}: {json.dumps(value)}") + + # Convert the filters to code + filters_expr = hog_function_filters_to_expr(filters, team, {}) + filters_code = compiler.visit(filters_expr) + + # Start with the STL functions + response += compiler.get_stl_code() + "\n" + + # A function to calculate the inputs from globals. If "initial" is true, no errors are logged. + response += "function buildInputs(globals, initial) {\n" + + # Add all constant inputs directly + response += "let inputs = {\n" + (",\n".join(inputs_object)) + "};\n" + + # Transpiled Hog code needs a "__getGlobal" function in scope + response += "let __getGlobal = (key) => key === 'inputs' ? inputs : globals[key];\n" + + if inputs_switch: + # We do it this way to be resilient to errors + response += "function getInputsKey(key, initial) { try { switch (key) {\n" + response += inputs_switch + response += "default: return null; }\n" + response += "} catch (e) { if(!initial) {console.error('[POSTHOG-JS] Unable to compute value for inputs', key, e);} return null } }\n" + response += "\n".join(inputs_append) + "\n" + response += "return inputs;}\n" + + # See plugin-transpiler/src/presets.ts + # transpile(source, 'site') == `(function () {let exports={};${code};return exports;})` + response += f"const response = {transpile(source, 'site')}();" + + response += ( + """ + function processEvent(globals) { + if (!('onEvent' in response)) { return; }; + const inputs = buildInputs(globals); + const filterGlobals = { ...globals.groups, ...globals.event, person: globals.person, inputs, pdi: { distinct_id: globals.event.distinct_id, person: globals.person } }; + let __getGlobal = (key) => filterGlobals[key]; + const filterMatches = """ + + filters_code + + """; + if (filterMatches) { response.onEvent({ ...globals, inputs, posthog }); } + } + if ('onLoad' in response) { + const r = response.onLoad({ inputs: buildInputs({}, true), posthog: posthog }); + const done = (success = true) => { + if (success) { + missedInvocations().forEach(processEvent); + posthog.on('eventCaptured', (event) => { processEvent(posthog.siteApps.globalsForEvent(event)) }); + } else { + console.error('[POSTHOG-JS] Site function failed to load', response) + } + callback(success); + }; + if (r && typeof r.then === 'function' && typeof r.finally === 'function') { r.catch(() => done(false)).then(() => done(true)) } else { done(true) } + } else if ('onEvent' in response) { + missedInvocations().forEach(processEvent); + posthog.on('eventCaptured', (event) => { processEvent(posthog.siteApps.globalsForEvent(event)) }) + } + """ + ) + + response += "\n})();" + + return response diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py index 3eff85e98117c..a19d35a837a36 100644 --- a/posthog/cdp/templates/__init__.py +++ b/posthog/cdp/templates/__init__.py @@ -41,9 +41,11 @@ from .airtable.template_airtable import template as airtable from .brevo.template_brevo import template as brevo from ._internal.template_broadcast import template_new_broadcast as _broadcast +from ._internal.template_blank import blank_site_destination HOG_FUNCTION_TEMPLATES = [ _broadcast, + blank_site_destination, slack, webhook, activecampaign, diff --git a/posthog/cdp/templates/_internal/template_blank.py b/posthog/cdp/templates/_internal/template_blank.py new file mode 100644 index 0000000000000..88eb00f2ef582 --- /dev/null +++ b/posthog/cdp/templates/_internal/template_blank.py @@ -0,0 +1,53 @@ +from posthog.cdp.templates.hog_function_template import HogFunctionTemplate + +blank_site_destination: HogFunctionTemplate = HogFunctionTemplate( + status="client-side", + type="site_destination", + id="template-blank-site-destination", + name="New client-side destination", + description="Run code on your website when an event is sent to PostHog. Works only with posthog-js when opt_in_site_apps is set to true.", + icon_url="/static/hedgehog/builder-hog-01.png", + category=["Custom", "Analytics"], + hog=""" +export async function onLoad({ inputs, posthog }) { + console.log('🦔 Loading (takes 1 sec)', { inputs }) + // onEvent will not be called until this function resolves + await new Promise((resolve) => window.setTimeout(resolve, 1000)) + console.log("🦔 Script loaded") +} +export function onEvent({ posthog, ...globals }) { + const { event, person } = globals + console.log(`🦔 Sending event: ${event.event}`, globals) +} +""".strip(), + inputs_schema=[ + { + "key": "name", + "type": "string", + "label": "Name", + "description": "What's your name?", + "default": "Max", + }, + { + "key": "userId", + "type": "string", + "label": "User ID", + "description": "User ID", + "default": "{event.distinct_id}", + "secret": False, + "required": True, + }, + { + "key": "additionalProperties", + "type": "json", + "label": "Additional properties", + "description": "Additional properties for the Exported Object.", + "default": { + "email": "{person.properties.email}", + "browser": "{event.properties.$browser}", + }, + "secret": False, + "required": True, + }, + ], +) diff --git a/posthog/cdp/templates/hog_function_template.py b/posthog/cdp/templates/hog_function_template.py index f0a29fd7d2cff..c3227f9b8eb73 100644 --- a/posthog/cdp/templates/hog_function_template.py +++ b/posthog/cdp/templates/hog_function_template.py @@ -25,8 +25,19 @@ class HogFunctionSubTemplate: @dataclasses.dataclass(frozen=True) class HogFunctionTemplate: - status: Literal["alpha", "beta", "stable", "free"] - type: Literal["destination", "shared", "email", "sms", "push", "broadcast", "activity", "alert"] + status: Literal["alpha", "beta", "stable", "free", "client-side"] + type: Literal[ + "destination", + "site_destination", + "site_app", + "shared", + "email", + "sms", + "push", + "broadcast", + "activity", + "alert", + ] id: str name: str description: str diff --git a/posthog/cdp/templates/test_cdp_templates.py b/posthog/cdp/templates/test_cdp_templates.py index 889f7431e33a1..4c873a9a820ec 100644 --- a/posthog/cdp/templates/test_cdp_templates.py +++ b/posthog/cdp/templates/test_cdp_templates.py @@ -1,5 +1,6 @@ from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES from posthog.cdp.validation import compile_hog, validate_inputs_schema +from posthog.models.hog_functions.hog_function import TYPES_WITH_TRANSPILED_FILTERS from posthog.test.base import BaseTest @@ -9,6 +10,8 @@ def setUp(self): def test_templates_are_valid(self): for template in HOG_FUNCTION_TEMPLATES: - bytecode = compile_hog(template.hog) - assert bytecode[0] == "_H" assert validate_inputs_schema(template.inputs_schema) + + if template.type not in TYPES_WITH_TRANSPILED_FILTERS: + bytecode = compile_hog(template.hog) + assert bytecode[0] == "_H" diff --git a/posthog/cdp/test/test_filters.py b/posthog/cdp/test/test_filters.py index fecb983aa5bf0..b37c015fc16c6 100644 --- a/posthog/cdp/test/test_filters.py +++ b/posthog/cdp/test/test_filters.py @@ -140,6 +140,11 @@ def test_filters_actions(self): ] ) + # Also works if we don't pass the actions dict + expr = hog_function_filters_to_expr(filters={"actions": self.filters["actions"]}, team=self.team, actions={}) + bytecode_2 = create_bytecode(expr).bytecode + assert bytecode == bytecode_2 + def test_filters_properties(self): assert self.filters_to_bytecode(filters={"properties": self.filters["properties"]}) == snapshot( [ diff --git a/posthog/cdp/test/test_site_functions.py b/posthog/cdp/test/test_site_functions.py new file mode 100644 index 0000000000000..9852201353cdc --- /dev/null +++ b/posthog/cdp/test/test_site_functions.py @@ -0,0 +1,241 @@ +from django.test import TestCase +from posthog.cdp.site_functions import get_transpiled_function +from posthog.models.action.action import Action +from posthog.models.organization import Organization +from posthog.models.project import Project +from posthog.models.plugin import TranspilerError +from posthog.models.group_type_mapping import GroupTypeMapping +from posthog.models.user import User + + +class TestSiteFunctions(TestCase): + def setUp(self): + self.organization = Organization.objects.create(name="Test Organization") + self.user = User.objects.create_user(email="testuser@example.com", first_name="Test", password="password") + self.organization.members.add(self.user) + self.project, self.team = Project.objects.create_with_team( + initiating_user=self.user, + organization=self.organization, + name="Test project", + ) + + def test_get_transpiled_function_basic(self): + id = "123" + source = 'export function onLoad() { console.log("Hello, World!"); }' + filters: dict = {} + inputs: dict = {} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn('console.log("Hello, World!")', result) + self.assertIn(f"window['__$$ph_site_app_{id}_posthog']", result) + + def test_get_transpiled_function_with_static_input(self): + id = "123" + source = "export function onLoad() { console.log(inputs.message); }" + filters: dict = {} + inputs = {"message": {"value": "Hello, Inputs!"}} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.message);", result) + self.assertIn("inputs = {", result) + self.assertIn('"message": "Hello, Inputs!"', result) + + def test_get_transpiled_function_with_template_input(self): + id = "123" + source = "export function onLoad() { console.log(inputs.greeting); }" + filters: dict = {} + inputs = {"greeting": {"value": "Hello, {person.properties.name}!"}} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.greeting);", result) + # Check that the input processing code is included + self.assertIn("function getInputsKey", result) + self.assertIn('inputs["greeting"] = getInputsKey("greeting");', result) + self.assertIn('case "greeting": return ', result) + self.assertIn('__getGlobal("person")', result) + + def test_get_transpiled_function_with_filters(self): + id = "123" + source = "export function onEvent(event) { console.log(event.event); }" + filters: dict = {"events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]} + inputs: dict = {} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(event.event);", result) + self.assertIn("const filterMatches = ", result) + self.assertIn('__getGlobal("event") == "$pageview"', result) + self.assertIn("if (filterMatches) { response.onEvent({", result) + + def test_get_transpiled_function_with_invalid_template_input(self): + id = "123" + source = "export function onLoad() { console.log(inputs.greeting); }" + filters: dict = {} + inputs = {"greeting": {"value": "Hello, {person.properties.nonexistent_property}!"}} + team = self.team + + # This should not raise an exception during transpilation + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.greeting);", result) + + def test_get_transpiled_function_with_syntax_error_in_source(self): + id = "123" + source = 'export function onLoad() { console.log("Missing closing brace");' + filters: dict = {} + inputs: dict = {} + team = self.team + + with self.assertRaises(TranspilerError): + get_transpiled_function(id, source, filters, inputs, team) + + def test_get_transpiled_function_with_complex_inputs(self): + id = "123" + source = "export function onLoad() { console.log(inputs.complexInput); }" + filters: dict = {} + inputs = { + "complexInput": { + "value": { + "nested": "{event.properties.url}", + "list": ["{person.properties.name}", "{groups.group_name}"], + } + } + } + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.complexInput);", result) + self.assertIn("function getInputsKey", result) + self.assertIn('inputs["complexInput"] = getInputsKey("complexInput");', result) + + def test_get_transpiled_function_with_empty_inputs(self): + id = "123" + source = 'export function onLoad() { console.log("No inputs"); }' + filters: dict = {} + inputs: dict = {} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn('console.log("No inputs");', result) + self.assertIn("let inputs = {\n};", result) + + def test_get_transpiled_function_with_non_template_string(self): + id = "123" + source = "export function onLoad() { console.log(inputs.staticMessage); }" + filters: dict = {} + inputs = {"staticMessage": {"value": "This is a static message."}} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.staticMessage);", result) + # Since the value does not contain '{', it should be added directly to inputs object + self.assertIn('"staticMessage": "This is a static message."', result) + self.assertNotIn("function getInputsKey", result) + + def test_get_transpiled_function_with_list_inputs(self): + id = "123" + source = "export function onLoad() { console.log(inputs.messages); }" + filters: dict = {} + inputs = {"messages": {"value": ["Hello", "World", "{person.properties.name}"]}} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.messages);", result) + self.assertIn("function getInputsKey", result) + self.assertIn('inputs["messages"] = getInputsKey("messages");', result) + + def test_get_transpiled_function_with_event_filter(self): + id = "123" + source = "export function onEvent(event) { console.log(event.properties.url); }" + filters: dict = { + "events": [{"id": "$pageview", "name": "$pageview", "type": "events"}], + "filter_test_accounts": True, + } + inputs: dict = {} + team = self.team + # Assume that team.test_account_filters is set up + team.test_account_filters = [{"key": "email", "value": "@test.com", "operator": "icontains", "type": "person"}] + team.save() + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(event.properties.url);", result) + self.assertIn("const filterMatches = ", result) + self.assertIn('__getGlobal("event") == "$pageview"', result) + self.assertIn( + '(ilike(__getProperty(__getProperty(__getGlobal("person"), "properties", true), "email", true), "%@test.com%")', + result, + ) + + def test_get_transpiled_function_with_groups(self): + id = "123" + source = "export function onLoad() { console.log(inputs.groupInfo); }" + filters: dict = {} + inputs = {"groupInfo": {"value": "{groups['company']}"}} + team = self.team + + # Set up group type mapping + GroupTypeMapping.objects.create(team=team, group_type="company", group_type_index=0, project=self.project) + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.groupInfo);", result) + self.assertIn('inputs["groupInfo"] = getInputsKey("groupInfo");', result) + self.assertIn('__getProperty(__getGlobal("groups"), "company", false)', result) + + def test_get_transpiled_function_with_missing_group(self): + id = "123" + source = "export function onLoad() { console.log(inputs.groupInfo); }" + filters: dict = {} + inputs = {"groupInfo": {"value": "{groups['nonexistent']}"}} + team = self.team + + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(inputs.groupInfo);", result) + self.assertIn('inputs["groupInfo"] = getInputsKey("groupInfo");', result) + self.assertIn('__getProperty(__getGlobal("groups"), "nonexistent"', result) + + def test_get_transpiled_function_with_complex_filters(self): + action = Action.objects.create(team=self.team, name="Test Action") + action.steps = [{"event": "$pageview", "url": "https://example.com"}] # type: ignore + action.save() + id = "123" + source = "export function onEvent(event) { console.log(event.event); }" + filters: dict = { + "events": [{"id": "$pageview", "name": "$pageview", "type": "events"}], + "actions": [{"id": str(action.pk), "name": "Test Action", "type": "actions"}], + "filter_test_accounts": True, + } + inputs: dict = {} + team = self.team + result = get_transpiled_function(id, source, filters, inputs, team) + + self.assertIsInstance(result, str) + self.assertIn("console.log(event.event);", result) + self.assertIn("const filterMatches = ", result) + self.assertIn('__getGlobal("event") == "$pageview"', result) + self.assertIn("https://example.com", result) diff --git a/posthog/cdp/validation.py b/posthog/cdp/validation.py index 0b2bbe2237dd6..9c71c894e93db 100644 --- a/posthog/cdp/validation.py +++ b/posthog/cdp/validation.py @@ -1,9 +1,12 @@ +import json import logging from typing import Any, Optional from rest_framework import serializers from posthog.hogql.compiler.bytecode import create_bytecode +from posthog.hogql.compiler.javascript import JavaScriptCompiler from posthog.hogql.parser import parse_program, parse_string_template +from posthog.models.hog_functions.hog_function import TYPES_WITH_JAVASCRIPT_SOURCE logger = logging.getLogger(__name__) @@ -23,6 +26,31 @@ def generate_template_bytecode(obj: Any) -> Any: return obj +def transpile_template_code(obj: Any, compiler: JavaScriptCompiler) -> str: + """ + Clones an object, compiling any string values to bytecode templates + """ + if isinstance(obj, dict): + return ( + "{" + + ( + ", ".join( + [ + f"{json.dumps(str(key))}: {transpile_template_code(value, compiler)}" + for key, value in obj.items() + ] + ) + ) + + "}" + ) + elif isinstance(obj, list): + return "[" + (", ".join([transpile_template_code(item, compiler) for item in obj])) + "]" + elif isinstance(obj, str): + return compiler.visit(parse_string_template(obj)) + else: + return json.dumps(obj) + + class InputsSchemaItemSerializer(serializers.Serializer): type = serializers.ChoiceField( choices=["string", "boolean", "dictionary", "choice", "json", "integration", "integration_field", "email"] @@ -55,6 +83,7 @@ class InputsItemSerializer(serializers.Serializer): def validate(self, attrs): schema = self.context["schema"] + function_type = self.context["function_type"] value = attrs.get("value") name: str = schema["key"] @@ -96,7 +125,16 @@ def validate(self, attrs): # We want to exclude the "design" property value = {key: value[key] for key in value if key != "design"} - attrs["bytecode"] = generate_template_bytecode(value) + if function_type in TYPES_WITH_JAVASCRIPT_SOURCE: + compiler = JavaScriptCompiler() + code = transpile_template_code(value, compiler) + attrs["transpiled"] = {"lang": "ts", "code": code, "stl": list(compiler.stl_functions)} + if "bytecode" in attrs: + del attrs["bytecode"] + else: + attrs["bytecode"] = generate_template_bytecode(value) + if "transpiled" in attrs: + del attrs["transpiled"] except Exception as e: raise serializers.ValidationError({"inputs": {name: f"Invalid template: {str(e)}"}}) @@ -115,7 +153,12 @@ def validate_inputs_schema(value: list) -> list: return serializer.validated_data or [] -def validate_inputs(inputs_schema: list, inputs: dict, existing_secret_inputs: Optional[dict] = None) -> dict: +def validate_inputs( + inputs_schema: list, + inputs: dict, + existing_secret_inputs: Optional[dict] = None, + function_type: Optional[str] = None, +) -> dict: """ Tricky: We want to allow overriding the secret inputs, but not return them. If we have a given input then we use it, otherwise we pull it from the existing secrets @@ -129,7 +172,9 @@ def validate_inputs(inputs_schema: list, inputs: dict, existing_secret_inputs: O if schema.get("secret") and existing_secret_inputs and value and value.get("secret"): value = existing_secret_inputs.get(schema["key"]) or {} - serializer = InputsItemSerializer(data=value, context={"schema": schema}) + serializer = InputsItemSerializer( + data=value, context={"schema": schema, "function_type": function_type or "destination"} + ) if not serializer.is_valid(): raise serializers.ValidationError(serializer.errors) diff --git a/posthog/hogql/compiler/javascript.py b/posthog/hogql/compiler/javascript.py index 6d27567fa11a4..a70b9eeb54a1f 100644 --- a/posthog/hogql/compiler/javascript.py +++ b/posthog/hogql/compiler/javascript.py @@ -75,12 +75,14 @@ class Local: def to_js_program(code: str) -> str: compiler = JavaScriptCompiler() code = compiler.visit(parse_program(code)) - imports = compiler.get_inlined_stl() + imports = compiler.get_stl_code() return imports + ("\n\n" if imports else "") + code -def to_js_expr(expr: str) -> str: - return JavaScriptCompiler().visit(parse_expr(expr)) +def to_js_expr(expr: str | ast.Expr) -> str: + if isinstance(expr, str): + expr = parse_expr(expr) + return JavaScriptCompiler().visit(expr) def _as_block(node: ast.Statement) -> ast.Block: @@ -113,14 +115,14 @@ def __init__( self.scope_depth = 0 self.args = args or [] self.indent_level = 0 - self.inlined_stl: set[str] = set() + self.stl_functions: set[str] = set() # Initialize locals with function arguments for arg in self.args: self._declare_local(arg) - def get_inlined_stl(self) -> str: - return import_stl_functions(self.inlined_stl) + def get_stl_code(self) -> str: + return import_stl_functions(self.stl_functions) def _start_scope(self): self.scope_depth += 1 @@ -172,28 +174,28 @@ def visit_compare_operation(self, node: ast.CompareOperation): elif op == ast.CompareOperationOp.NotIn: return f"(!{right_code}.includes({left_code}))" elif op == ast.CompareOperationOp.Like: - self.inlined_stl.add("like") + self.stl_functions.add("like") return f"like({left_code}, {right_code})" elif op == ast.CompareOperationOp.ILike: - self.inlined_stl.add("ilike") + self.stl_functions.add("ilike") return f"ilike({left_code}, {right_code})" elif op == ast.CompareOperationOp.NotLike: - self.inlined_stl.add("like") + self.stl_functions.add("like") return f"!like({left_code}, {right_code})" elif op == ast.CompareOperationOp.NotILike: - self.inlined_stl.add("ilike") + self.stl_functions.add("ilike") return f"!ilike({left_code}, {right_code})" elif op == ast.CompareOperationOp.Regex: - self.inlined_stl.add("match") + self.stl_functions.add("match") return f"match({left_code}, {right_code})" elif op == ast.CompareOperationOp.IRegex: - self.inlined_stl.add("__imatch") + self.stl_functions.add("__imatch") return f"__imatch({left_code}, {right_code})" elif op == ast.CompareOperationOp.NotRegex: - self.inlined_stl.add("match") + self.stl_functions.add("match") return f"!match({left_code}, {right_code})" elif op == ast.CompareOperationOp.NotIRegex: - self.inlined_stl.add("__imatch") + self.stl_functions.add("__imatch") return f"!__imatch({left_code}, {right_code})" elif op == ast.CompareOperationOp.InCohort or op == ast.CompareOperationOp.NotInCohort: cohort_name = "" @@ -229,14 +231,14 @@ def visit_field(self, node: ast.Field): if found_local: array_code = _sanitize_identifier(element) elif element in STL_FUNCTIONS: - self.inlined_stl.add(str(element)) + self.stl_functions.add(str(element)) array_code = f"{_sanitize_identifier(element)}" else: array_code = f"{_JS_GET_GLOBAL}({json.dumps(element)})" continue if (isinstance(element, int) and not isinstance(element, bool)) or isinstance(element, str): - self.inlined_stl.add("__getProperty") + self.stl_functions.add("__getProperty") array_code = f"__getProperty({array_code}, {json.dumps(element)}, true)" else: raise QueryError(f"Unsupported element: {element} ({type(element)})") @@ -245,13 +247,13 @@ def visit_field(self, node: ast.Field): def visit_tuple_access(self, node: ast.TupleAccess): tuple_code = self.visit(node.tuple) index_code = str(node.index) - self.inlined_stl.add("__getProperty") + self.stl_functions.add("__getProperty") return f"__getProperty({tuple_code}, {index_code}, {json.dumps(node.nullish)})" def visit_array_access(self, node: ast.ArrayAccess): array_code = self.visit(node.array) property_code = self.visit(node.property) - self.inlined_stl.add("__getProperty") + self.stl_functions.add("__getProperty") return f"__getProperty({array_code}, {property_code}, {json.dumps(node.nullish)})" def visit_constant(self, node: ast.Constant): @@ -307,7 +309,7 @@ def build_nested_if(args): return f"({expr_code} ?? {if_null_code})" if node.name in STL_FUNCTIONS: - self.inlined_stl.add(node.name) + self.stl_functions.add(node.name) name = _sanitize_identifier(node.name) args_code = ", ".join(self.visit(arg) for arg in node.args) return f"{name}({args_code})" @@ -422,7 +424,7 @@ def visit_for_in_statement(self, node: ast.ForInStatement): self._declare_local(node.keyVar) self._declare_local(node.valueVar) body_code = self.visit(_as_block(node.body)) - self.inlined_stl.add("keys") + self.stl_functions.add("keys") resp = f"for (let {_sanitize_identifier(node.keyVar)} of keys({expr_code})) {{ let {_sanitize_identifier(node.valueVar)} = {expr_code}[{_sanitize_identifier(node.keyVar)}]; {body_code} }}" self._end_scope() return resp @@ -430,7 +432,7 @@ def visit_for_in_statement(self, node: ast.ForInStatement): self._start_scope() self._declare_local(node.valueVar) body_code = self.visit(_as_block(node.body)) - self.inlined_stl.add("values") + self.stl_functions.add("values") resp = f"for (let {_sanitize_identifier(node.valueVar)} of values({expr_code})) {body_code}" self._end_scope() return resp @@ -450,14 +452,14 @@ def visit_variable_assignment(self, node: ast.VariableAssignment): tuple_code = self.visit(node.left.tuple) index = node.left.index right_code = self.visit(node.right) - self.inlined_stl.add("__setProperty") + self.stl_functions.add("__setProperty") return f"__setProperty({tuple_code}, {index}, {right_code});" elif isinstance(node.left, ast.ArrayAccess): array_code = self.visit(node.left.array) property_code = self.visit(node.left.property) right_code = self.visit(node.right) - self.inlined_stl.add("__setProperty") + self.stl_functions.add("__setProperty") return f"__setProperty({array_code}, {property_code}, {right_code});" elif isinstance(node.left, ast.Field): @@ -475,10 +477,10 @@ def visit_variable_assignment(self, node: ast.VariableAssignment): elif (isinstance(element, int) and not isinstance(element, bool)) or isinstance(element, str): if index == len(chain) - 1: right_code = self.visit(node.right) - self.inlined_stl.add("__setProperty") + self.stl_functions.add("__setProperty") array_code = f"__setProperty({array_code}, {json.dumps(element)}, {right_code})" else: - self.inlined_stl.add("__getProperty") + self.stl_functions.add("__getProperty") array_code = f"__getProperty({array_code}, {json.dumps(element)}, true)" else: raise QueryError(f"Unsupported element: {element} ({type(element)})") @@ -518,7 +520,7 @@ def visit_lambda(self, node: ast.Lambda): else: expr_code = self.visit(node.expr) self._end_scope() - self.inlined_stl.add("__lambda") + self.stl_functions.add("__lambda") # we wrap it in __lambda() to make the function anonymous (a true lambda without a name) return f"__lambda(({params_code}) => {expr_code})" @@ -539,7 +541,7 @@ def visit_array(self, node: ast.Array): def visit_tuple(self, node: ast.Tuple): items_code = ", ".join([self.visit(expr) for expr in node.exprs]) - self.inlined_stl.add("tuple") + self.stl_functions.add("tuple") return f"tuple({items_code})" def visit_hogqlx_tag(self, node: ast.HogQLXTag): diff --git a/posthog/hogql/compiler/test/test_javascript.py b/posthog/hogql/compiler/test/test_javascript.py index cf577ff25d41b..c23707701c4ff 100644 --- a/posthog/hogql/compiler/test/test_javascript.py +++ b/posthog/hogql/compiler/test/test_javascript.py @@ -113,10 +113,10 @@ def test_visit_lambda(self): code = to_js_expr("x -> x + 1") self.assertTrue(code.startswith("__lambda((x) => (x + 1))")) - def test_inlined_stl(self): + def test_stl_code(self): compiler = JavaScriptCompiler() - compiler.inlined_stl.add("concat") - stl_code = compiler.get_inlined_stl() + compiler.stl_functions.add("concat") + stl_code = compiler.get_stl_code() self.assertIn("function concat", stl_code) def test_sanitize_keywords(self): diff --git a/posthog/management/commands/migrate_action_webhooks.py b/posthog/management/commands/migrate_action_webhooks.py index 3b307300c143f..c35a90f61232b 100644 --- a/posthog/management/commands/migrate_action_webhooks.py +++ b/posthog/management/commands/migrate_action_webhooks.py @@ -135,6 +135,7 @@ def convert_to_hog_function(action: Action, inert=False) -> Optional[HogFunction inputs=validate_inputs( webhook_template.inputs_schema, {"url": {"value": webhook_url}, "method": {"value": "POST"}, "body": {"value": body}}, + function_type="destination", ), inputs_schema=webhook_template.inputs_schema, template_id=webhook_template.id, diff --git a/posthog/migrations/0525_hog_function_transpiled.py b/posthog/migrations/0525_hog_function_transpiled.py new file mode 100644 index 0000000000000..5205eb627baca --- /dev/null +++ b/posthog/migrations/0525_hog_function_transpiled.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.15 on 2024-11-19 12:03 + +from django.db import migrations, models +from django.contrib.postgres.operations import AddIndexConcurrently + + +class Migration(migrations.Migration): + atomic = False # Added to support concurrent index creation + dependencies = [("posthog", "0524_datawarehousejoin_configuration")] + + operations = [ + migrations.AddField( + model_name="hogfunction", + name="transpiled", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="hogfunction", + name="type", + field=models.CharField( + blank=True, + choices=[ + ("destination", "Destination"), + ("site_destination", "Site Destination"), + ("site_app", "Site App"), + ("email", "Email"), + ("sms", "Sms"), + ("push", "Push"), + ("activity", "Activity"), + ("alert", "Alert"), + ("broadcast", "Broadcast"), + ], + max_length=24, + null=True, + ), + ), + AddIndexConcurrently( + model_name="hogfunction", + index=models.Index(fields=["type", "enabled", "team"], name="posthog_hog_type_6f8967_idx"), + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index 12da4d602a5a2..85a418de0b4c1 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0524_datawarehousejoin_configuration +0525_hog_function_transpiled diff --git a/posthog/models/hog_functions/hog_function.py b/posthog/models/hog_functions/hog_function.py index 980481e5288d6..48e3db90a9dcd 100644 --- a/posthog/models/hog_functions/hog_function.py +++ b/posthog/models/hog_functions/hog_function.py @@ -2,13 +2,15 @@ from typing import Optional from django.db import models -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch.dispatcher import receiver import structlog from posthog.cdp.templates.hog_function_template import HogFunctionTemplate from posthog.helpers.encrypted_fields import EncryptedJSONStringField from posthog.models.action.action import Action +from posthog.models.plugin import sync_team_inject_web_apps +from posthog.models.signals import mutable_receiver from posthog.models.team.team import Team from posthog.models.utils import UUIDModel from posthog.plugins.plugin_server_api import ( @@ -33,6 +35,8 @@ class HogFunctionState(enum.Enum): class HogFunctionType(models.TextChoices): DESTINATION = "destination" + SITE_DESTINATION = "site_destination" + SITE_APP = "site_app" EMAIL = "email" SMS = "sms" PUSH = "push" @@ -43,9 +47,16 @@ class HogFunctionType(models.TextChoices): TYPES_THAT_RELOAD_PLUGIN_SERVER = (HogFunctionType.DESTINATION, HogFunctionType.EMAIL) TYPES_WITH_COMPILED_FILTERS = (HogFunctionType.DESTINATION,) +TYPES_WITH_TRANSPILED_FILTERS = (HogFunctionType.SITE_DESTINATION, HogFunctionType.SITE_APP) +TYPES_WITH_JAVASCRIPT_SOURCE = (HogFunctionType.SITE_DESTINATION, HogFunctionType.SITE_APP) class HogFunction(UUIDModel): + class Meta: + indexes = [ + models.Index(fields=["type", "enabled", "team"]), + ] + team = models.ForeignKey("Team", on_delete=models.CASCADE) name = models.CharField(max_length=400, null=True, blank=True) description = models.TextField(blank=True, default="") @@ -57,8 +68,14 @@ class HogFunction(UUIDModel): type = models.CharField(max_length=24, choices=HogFunctionType.choices, null=True, blank=True) icon_url = models.TextField(null=True, blank=True) + + # Hog source, except for the "site_*" types, when it contains TypeScript Source hog = models.TextField() + # Used when the source language is Hog (everything except the "site_*" types) bytecode = models.JSONField(null=True, blank=True) + # Transpiled JavasScript. Used with the "site_*" types + transpiled = models.TextField(null=True, blank=True) + inputs_schema = models.JSONField(null=True) inputs = models.JSONField(null=True) encrypted_inputs: EncryptedJSONStringField = EncryptedJSONStringField(null=True, blank=True) @@ -175,3 +192,14 @@ def team_saved(sender, instance: Team, created, **kwargs): from posthog.tasks.hog_functions import refresh_affected_hog_functions refresh_affected_hog_functions.delay(team_id=instance.id) + + +@mutable_receiver([post_save, post_delete], sender=HogFunction) +def team_inject_web_apps_changd(sender, instance, created=None, **kwargs): + try: + team = instance.team + except Team.DoesNotExist: + team = None + if team is not None: + # This controls whether /decide makes extra queries to get the site apps or not + sync_team_inject_web_apps(instance.team) diff --git a/posthog/models/plugin.py b/posthog/models/plugin.py index d32a18f974939..75362578e366c 100644 --- a/posthog/models/plugin.py +++ b/posthog/models/plugin.py @@ -22,7 +22,7 @@ from posthog.models.team import Team from posthog.plugins.access import can_configure_plugins, can_install_plugins from posthog.plugins.plugin_server_api import populate_plugin_capabilities_on_workers, reload_plugins_on_workers -from posthog.plugins.site import get_decide_site_apps +from posthog.plugins.site import get_decide_site_apps, get_decide_site_functions from posthog.plugins.utils import ( download_plugin_archive, extract_plugin_code, @@ -303,6 +303,10 @@ class PluginLogEntryType(StrEnum): ERROR = "ERROR" +class TranspilerError(Exception): + pass + + def transpile(input_string: str, type: Literal["site", "frontend"] = "site") -> Optional[str]: from posthog.settings.base_variables import BASE_DIR @@ -317,7 +321,7 @@ def transpile(input_string: str, type: Literal["site", "frontend"] = "site") -> if process.returncode != 0: error = stderr.decode() - raise Exception(error) + raise TranspilerError(error) return stdout.decode() @@ -584,7 +588,7 @@ def plugin_config_reload_needed(sender, instance, created=None, **kwargs): def sync_team_inject_web_apps(team: Team): - inject_web_apps = len(get_decide_site_apps(team)) > 0 + inject_web_apps = len(get_decide_site_apps(team)) > 0 or len(get_decide_site_functions(team)) > 0 if inject_web_apps != team.inject_web_apps: team.inject_web_apps = inject_web_apps team.save(update_fields=["inject_web_apps"]) diff --git a/posthog/models/test/test_hog_function.py b/posthog/models/test/test_hog_function.py index 7cbc441e083df..bfbfa7e6f74cd 100644 --- a/posthog/models/test/test_hog_function.py +++ b/posthog/models/test/test_hog_function.py @@ -4,7 +4,7 @@ from hogvm.python.operation import HOGQL_BYTECODE_VERSION from posthog.models.action.action import Action -from posthog.models.hog_functions.hog_function import HogFunction +from posthog.models.hog_functions.hog_function import HogFunction, HogFunctionType from posthog.models.user import User from posthog.test.base import QueryMatchingTest @@ -34,13 +34,14 @@ def test_hog_function_team_no_filters_compilation(self): assert json_filters["bytecode"] == ["_H", HOGQL_BYTECODE_VERSION, 29] # TRUE def test_hog_function_filters_compilation(self): + action = Action.objects.create(team=self.team, name="Test Action") item = HogFunction.objects.create( name="Test", - type="destination", + type=HogFunctionType.DESTINATION, team=self.team, filters={ "events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}], - "actions": [{"id": "9", "name": "Test Action", "type": "actions", "order": 1}], + "actions": [{"id": str(action.pk), "name": "Test Action", "type": "actions", "order": 1}], "filter_test_accounts": True, }, ) @@ -49,7 +50,7 @@ def test_hog_function_filters_compilation(self): json_filters = to_dict(item.filters) assert json_filters == { "events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}], - "actions": [{"id": "9", "name": "Test Action", "type": "actions", "order": 1}], + "actions": [{"id": str(action.pk), "name": "Test Action", "type": "actions", "order": 1}], "filter_test_accounts": True, "bytecode": [ "_H", @@ -103,11 +104,7 @@ def test_hog_function_filters_compilation(self): 35, 33, 1, - 33, - 2, - 33, - 1, - 11, + 29, 3, 2, 4, diff --git a/posthog/models/test/test_team_model.py b/posthog/models/test/test_team_model.py index 102eb62449a71..82cd8140f9cfd 100644 --- a/posthog/models/test/test_team_model.py +++ b/posthog/models/test/test_team_model.py @@ -12,7 +12,7 @@ def test_all_users_with_access_simple_org_membership(self): all_user_with_access_ids = list(self.team.all_users_with_access().values_list("id", flat=True)) - assert all_user_with_access_ids == [self.user.id, another_user.id] + assert sorted(all_user_with_access_ids) == sorted([self.user.id, another_user.id]) def test_all_users_with_access_simple_org_membership_and_redundant_team_one(self): self.organization_membership.level = OrganizationMembership.Level.MEMBER @@ -22,7 +22,9 @@ def test_all_users_with_access_simple_org_membership_and_redundant_team_one(self all_user_with_access_ids = list(self.team.all_users_with_access().values_list("id", flat=True)) - assert all_user_with_access_ids == [self.user.id, another_user.id] # self.user should only be listed once + assert sorted(all_user_with_access_ids) == sorted( + [self.user.id, another_user.id] + ) # self.user should only be listed once def test_all_users_with_access_while_access_control_org_membership(self): self.organization_membership.level = OrganizationMembership.Level.ADMIN diff --git a/posthog/plugins/site.py b/posthog/plugins/site.py index bf1d7e376c4b1..af59c2b5dfef6 100644 --- a/posthog/plugins/site.py +++ b/posthog/plugins/site.py @@ -19,6 +19,7 @@ class WebJsSource: class WebJsUrl: id: int url: str + type: str def get_transpiled_site_source(id: int, token: str) -> Optional[WebJsSource]: @@ -73,7 +74,35 @@ def site_app_url(source: tuple) -> str: hash = md5(f"{source[2]}-{source[3]}-{source[4]}".encode()).hexdigest() return f"/site_app/{source[0]}/{source[1]}/{hash}/" - return [asdict(WebJsUrl(source[0], site_app_url(source))) for source in sources] + return [asdict(WebJsUrl(source[0], site_app_url(source), "site_app")) for source in sources] + + +def get_decide_site_functions(team: "Team", using_database: str = "default") -> list[dict]: + from posthog.models import HogFunction + + sources = ( + HogFunction.objects.db_manager(using_database) + .filter( + team=team, + enabled=True, + type__in=("site_destination", "site_app"), + transpiled__isnull=False, + ) + .values_list( + "id", + "updated_at", + "type", + ) + .all() + ) + + def site_function_url(source: tuple) -> str: + hash = md5(str(source[1]).encode()).hexdigest() + return f"/site_function/{source[0]}/{hash}/" + + return [ + asdict(WebJsUrl(source[0], site_function_url(source), source[2] or "site_destination")) for source in sources + ] def get_site_config_from_schema(config_schema: Optional[list[dict]], config: Optional[dict]): diff --git a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr index fb63b5473790c..179684d289047 100644 --- a/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr +++ b/posthog/session_recordings/test/__snapshots__/test_session_recordings.ambr @@ -640,12 +640,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '435' + AND "ee_accesscontrol"."resource_id" = '450' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '435' + AND "ee_accesscontrol"."resource_id" = '450' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -1690,12 +1690,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -2445,12 +2445,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -3136,12 +3136,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -3890,12 +3890,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -4608,12 +4608,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -5408,12 +5408,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -5673,12 +5673,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -6107,12 +6107,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -6573,12 +6573,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -7267,12 +7267,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL @@ -8018,12 +8018,12 @@ LEFT OUTER JOIN "posthog_organizationmembership" ON ("ee_accesscontrol"."organization_member_id" = "posthog_organizationmembership"."id") WHERE (("ee_accesscontrol"."organization_member_id" IS NULL AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("posthog_organizationmembership"."user_id" = 99999 AND "ee_accesscontrol"."resource" = 'project' - AND "ee_accesscontrol"."resource_id" = '442' + AND "ee_accesscontrol"."resource_id" = '457' AND "ee_accesscontrol"."role_id" IS NULL AND "ee_accesscontrol"."team_id" = 99999) OR ("ee_accesscontrol"."organization_member_id" IS NULL diff --git a/posthog/urls.py b/posthog/urls.py index 80a47cd42a5d8..210287c149392 100644 --- a/posthog/urls.py +++ b/posthog/urls.py @@ -210,6 +210,7 @@ def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> UR sharing.SharingViewerPageViewSet.as_view({"get": "retrieve"}), ), path("site_app////", site_app.get_site_app), + path("site_function///", site_app.get_site_function), re_path(r"^demo.*", login_required(demo_route)), # ingestion # NOTE: When adding paths here that should be public make sure to update ALWAYS_ALLOWED_ENDPOINTS in middleware.py