([])
useEffect(() => {
@@ -45,7 +47,8 @@ export function ErrorProjectUnavailable(): JSX.Element {
{!user?.organization ? (
- ) : user?.team && !user.organization?.teams.some((team) => team.id === user?.team?.id) ? (
+ ) : (user?.team && !user.organization?.teams.some((team) => team.id === user?.team?.id || user.team)) ||
+ currentTeam?.user_access_level === 'none' ? (
<>
Project access has been removed
diff --git a/frontend/src/layout/GlobalModals.tsx b/frontend/src/layout/GlobalModals.tsx
index 803bb2c5e8237..81bdae758064a 100644
--- a/frontend/src/layout/GlobalModals.tsx
+++ b/frontend/src/layout/GlobalModals.tsx
@@ -5,14 +5,12 @@ import { TimeSensitiveAuthenticationModal } from 'lib/components/TimeSensitiveAu
import { UpgradeModal } from 'lib/components/UpgradeModal/UpgradeModal'
import { TwoFactorSetupModal } from 'scenes/authentication/TwoFactorSetupModal'
import { CreateOrganizationModal } from 'scenes/organization/CreateOrganizationModal'
-import { membersLogic } from 'scenes/organization/membersLogic'
import { CreateEnvironmentModal } from 'scenes/project/CreateEnvironmentModal'
import { CreateProjectModal } from 'scenes/project/CreateProjectModal'
import { SessionPlayerModal } from 'scenes/session-recordings/player/modal/SessionPlayerModal'
import { inviteLogic } from 'scenes/settings/organization/inviteLogic'
import { InviteModal } from 'scenes/settings/organization/InviteModal'
import { PreviewingCustomCssModal } from 'scenes/themes/PreviewingCustomCssModal'
-import { userLogic } from 'scenes/userLogic'
import type { globalModalsLogicType } from './GlobalModalsType'
@@ -58,7 +56,6 @@ export function GlobalModals(): JSX.Element {
useActions(globalModalsLogic)
const { isInviteModalShown } = useValues(inviteLogic)
const { hideInviteModal } = useActions(inviteLogic)
- const { user } = useValues(userLogic)
return (
<>
@@ -71,17 +68,7 @@ export function GlobalModals(): JSX.Element {
- {user && user.organization?.enforce_2fa && !user.is_2fa_enabled && (
- {
- userLogic.actions.loadUser()
- membersLogic.actions.loadAllMembers()
- }}
- forceOpen
- closable={false}
- required={true}
- />
- )}
+
>
)
diff --git a/frontend/src/layout/navigation-3000/Navigation.scss b/frontend/src/layout/navigation-3000/Navigation.scss
index df5f78ab272c6..42bb779a54d82 100644
--- a/frontend/src/layout/navigation-3000/Navigation.scss
+++ b/frontend/src/layout/navigation-3000/Navigation.scss
@@ -175,7 +175,7 @@
.Sidebar3000 {
--sidebar-slider-padding: 0.125rem;
--sidebar-horizontal-padding: 0.5rem;
- --sidebar-row-height: 2.5rem;
+ --sidebar-row-height: 3rem;
--sidebar-background: var(--bg-3000);
position: relative;
@@ -533,8 +533,6 @@
position: relative;
display: flex;
- flex-direction: column;
- justify-content: center;
width: 100%;
height: 100%;
color: inherit;
@@ -549,7 +547,9 @@
}
.SidebarListItem__link {
+ flex-direction: column;
row-gap: 1px;
+ justify-content: center;
padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset);
color: inherit !important; // Disable link color
.SidebarListItem[aria-disabled='true'] & {
@@ -558,17 +558,33 @@
}
.SidebarListItem__button {
+ flex-direction: row;
+ gap: 0.25rem;
row-gap: 1px;
+ align-items: center;
padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset);
+ font-size: 1.125rem; // Make icons bigger
color: inherit !important; // Disable link color
cursor: pointer;
&:hover {
background: var(--border-3000);
}
+
+ .SidebarListItem__icon {
+ flex-shrink: 0;
+ }
+
+ .SidebarListItem__name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
}
.SidebarListItem__rename {
+ flex-direction: column;
+ justify-content: center;
+
// Pseudo-elements don't work on inputs, so we use a wrapper div
background: var(--bg-light);
diff --git a/frontend/src/layout/navigation-3000/components/SidebarList.tsx b/frontend/src/layout/navigation-3000/components/SidebarList.tsx
index 2b63b9a61e9c6..65cd05d65c4d4 100644
--- a/frontend/src/layout/navigation-3000/components/SidebarList.tsx
+++ b/frontend/src/layout/navigation-3000/components/SidebarList.tsx
@@ -232,7 +232,8 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP
if (isItemClickable(item)) {
content = (
- {item.name}
+ {item.icon && {item.icon}
}
+ {item.name}
)
} else if (!save || (!isItemTentative(item) && newName === null)) {
diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx
index 6f6105aaa9c54..6616814c0d8df 100644
--- a/frontend/src/layout/navigation-3000/navigationLogic.tsx
+++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx
@@ -427,6 +427,7 @@ export const navigation3000Logic = kea([
label: 'Max AI',
icon: ,
to: urls.max(),
+ tag: 'beta' as const,
})
}
@@ -514,29 +515,20 @@ export const navigation3000Logic = kea([
to: urls.earlyAccessFeatures(),
}
: null,
- {
- identifier: Scene.DataWarehouse,
- label: 'Data warehouse',
- icon: ,
- to: isUsingSidebar ? undefined : urls.dataWarehouse(),
- },
featureFlags[FEATURE_FLAGS.SQL_EDITOR]
? {
identifier: Scene.SQLEditor,
- label: 'SQL Editor',
+ label: 'SQL editor',
icon: ,
to: urls.sqlEditor(),
logic: editorSidebarLogic,
}
- : null,
- featureFlags[FEATURE_FLAGS.DATA_MODELING] && hasOnboardedAnyProduct
- ? {
- identifier: Scene.DataModel,
- label: 'Data model',
- icon: ,
- to: isUsingSidebar ? undefined : urls.dataModel(),
- }
- : null,
+ : {
+ identifier: Scene.DataWarehouse,
+ label: 'Data warehouse',
+ icon: ,
+ to: isUsingSidebar ? undefined : urls.dataWarehouse(),
+ },
hasOnboardedAnyProduct
? {
identifier: Scene.Pipeline,
diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx
index 34c18f4fc6ff2..a99679f92f88d 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx
+++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx
@@ -1,6 +1,6 @@
import './SidePanel.scss'
-import { IconEllipsis, IconFeatures, IconGear, IconInfo, IconNotebook, IconSupport } from '@posthog/icons'
+import { IconEllipsis, IconFeatures, IconGear, IconInfo, IconLock, IconNotebook, IconSupport } from '@posthog/icons'
import { LemonButton, LemonMenu, LemonMenuItems, LemonModal } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
@@ -16,6 +16,7 @@ import {
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
import { SidePanelTab } from '~/types'
+import { SidePanelAccessControl } from './panels/access_control/SidePanelAccessControl'
import { SidePanelActivation, SidePanelActivationIcon } from './panels/activation/SidePanelActivation'
import { SidePanelActivity, SidePanelActivityIcon } from './panels/activity/SidePanelActivity'
import { SidePanelDiscussion, SidePanelDiscussionIcon } from './panels/discussion/SidePanelDiscussion'
@@ -87,6 +88,11 @@ export const SIDE_PANEL_TABS: Record<
Content: SidePanelStatus,
noModalSupport: true,
},
+ [SidePanelTab.AccessControl]: {
+ label: 'Access control',
+ Icon: IconLock,
+ Content: SidePanelAccessControl,
+ },
}
const DEFAULT_WIDTH = 512
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx
index d2bbaca004f2a..7701538ffd36c 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/SidePanelSupport.tsx
@@ -18,6 +18,8 @@ import { LemonBanner, LemonButton, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { SupportForm } from 'lib/components/Support/SupportForm'
import { getPublicSupportSnippet, supportLogic } from 'lib/components/Support/supportLogic'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import React from 'react'
import { billingLogic } from 'scenes/billing/billingLogic'
import { organizationLogic } from 'scenes/organizationLogic'
@@ -95,8 +97,14 @@ const Section = ({ title, children }: { title: string; children: React.ReactNode
)
}
+// In order to set these turn on the `support-message-override` feature flag.
+const SUPPORT_MESSAGE_OVERRIDE_TITLE = 'š š
Support during the holidays š ā'
+const SUPPORT_MESSAGE_OVERRIDE_BODY =
+ "We're offering reduced support while we celebrate the holidays. Responses may be slower than normal over the holiday period (23rd December to the 6th January), and between the 25th and 27th of December we'll only be responding to critical issues. Thanks for your patience!"
+
const SupportFormBlock = ({ onCancel }: { onCancel: () => void }): JSX.Element => {
const { supportPlans, hasSupportAddonPlan } = useValues(billingLogic)
+ const { featureFlags } = useValues(featureFlagLogic)
return (
@@ -123,36 +131,46 @@ const SupportFormBlock = ({ onCancel }: { onCancel: () => void }): JSX.Element =
Cancel
-
-
- {/* If placing a support message, replace the line below with explanation */}
-
Avg support response times
-
-
Explore options
+ {featureFlags[FEATURE_FLAGS.SUPPORT_MESSAGE_OVERRIDE] ? (
+
+
{SUPPORT_MESSAGE_OVERRIDE_TITLE}
+
{SUPPORT_MESSAGE_OVERRIDE_BODY}
+
+ ) : (
+
+
+ {/* If placing a support message, replace the line below with explanation */}
+
Avg support response times
+
+
+ Explore options
+
+
+ {/* If placing a support message, comment out (don't remove) the section below */}
+ {supportPlans?.map((plan) => {
+ // If they have an addon plan, only show the addon plan
+ const currentPlan =
+ plan.current_plan && (!hasSupportAddonPlan || plan.plan_key?.includes('addon'))
+ return (
+
+
+ {plan.name}
+ {currentPlan && (
+ <>
+ {' '}
+ (your plan)
+ >
+ )}
+
+
+ {plan.features.find((f) => f.key == AvailableFeature.SUPPORT_RESPONSE_TIME)?.note}
+
+
+ )
+ })}
- {/* If placing a support message, comment out (don't remove) the section below */}
- {supportPlans?.map((plan) => {
- // If they have an addon plan, only show the addon plan
- const currentPlan = plan.current_plan && (!hasSupportAddonPlan || plan.plan_key?.includes('addon'))
- return (
-
-
- {plan.name}
- {currentPlan && (
- <>
- {' '}
- (your plan)
- >
- )}
-
-
- {plan.features.find((f) => f.key == AvailableFeature.SUPPORT_RESPONSE_TIME)?.note}
-
-
- )
- })}
-
+ )}
)
}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx
new file mode 100644
index 0000000000000..93e14755e12d5
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject.tsx
@@ -0,0 +1,383 @@
+import { IconX } from '@posthog/icons'
+import {
+ LemonBanner,
+ LemonButton,
+ LemonDialog,
+ LemonInputSelect,
+ LemonSelect,
+ LemonSelectProps,
+ LemonTable,
+} from '@posthog/lemon-ui'
+import { BindLogic, useActions, useAsyncActions, useValues } from 'kea'
+import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
+import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic'
+import { UserSelectItem } from 'lib/components/UserSelectItem'
+import { LemonTableColumns } from 'lib/lemon-ui/LemonTable'
+import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
+import { ProfileBubbles, ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
+import { capitalizeFirstLetter } from 'lib/utils'
+import { useEffect, useState } from 'react'
+import { urls } from 'scenes/urls'
+import { userLogic } from 'scenes/userLogic'
+
+import {
+ AccessControlType,
+ AccessControlTypeMember,
+ AccessControlTypeRole,
+ AvailableFeature,
+ OrganizationMemberType,
+} from '~/types'
+
+import { accessControlLogic, AccessControlLogicProps } from './accessControlLogic'
+
+export function AccessControlObject(props: AccessControlLogicProps): JSX.Element | null {
+ const { canEditAccessControls, humanReadableResource } = useValues(accessControlLogic(props))
+
+ const suffix = `this ${humanReadableResource}`
+
+ return (
+
+
+ {canEditAccessControls === false ? (
+
+ You don't have permission to edit access controls for {suffix}.
+
+ You must be the creator of it, a Project Admin, or an Organization Admin.
+
+ ) : null}
+
Default access to {suffix}
+
+
+
Members
+
+
+
+
+
Roles
+
+
+
+
+
+ )
+}
+
+function AccessControlObjectDefaults(): JSX.Element | null {
+ const { accessControlDefault, accessControlDefaultOptions, accessControlsLoading, canEditAccessControls } =
+ useValues(accessControlLogic)
+ const { updateAccessControlDefault } = useActions(accessControlLogic)
+ const { guardAvailableFeature } = useValues(upgradeModalLogic)
+
+ return (
+
{
+ guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING, () => {
+ updateAccessControlDefault(newValue)
+ })
+ }}
+ disabledReason={
+ accessControlsLoading ? 'Loadingā¦' : !canEditAccessControls ? 'You cannot edit this' : undefined
+ }
+ dropdownMatchSelectWidth={false}
+ options={accessControlDefaultOptions}
+ />
+ )
+}
+
+function AccessControlObjectUsers(): JSX.Element | null {
+ const { user } = useValues(userLogic)
+ const { membersById, addableMembers, accessControlMembers, accessControlsLoading, availableLevels } =
+ useValues(accessControlLogic)
+ const { updateAccessControlMembers } = useAsyncActions(accessControlLogic)
+ const { guardAvailableFeature } = useValues(upgradeModalLogic)
+
+ if (!user) {
+ return null
+ }
+
+ const member = (ac: AccessControlTypeMember): OrganizationMemberType => {
+ return membersById[ac.organization_member]
+ }
+
+ // TODO: WHAT A MESS - Fix this to do the index mapping beforehand...
+ const columns: LemonTableColumns = [
+ {
+ key: 'user_profile_picture',
+ render: function ProfilePictureRender(_, ac) {
+ return
+ },
+ width: 32,
+ },
+ {
+ title: 'Name',
+ key: 'user_first_name',
+ render: (_, ac) => (
+
+ {member(ac)?.user.uuid == user.uuid
+ ? `${member(ac)?.user.first_name} (you)`
+ : member(ac)?.user.first_name}
+
+ ),
+ sorter: (a, b) => member(a)?.user.first_name.localeCompare(member(b)?.user.first_name),
+ },
+ {
+ title: 'Email',
+ key: 'user_email',
+ render: (_, ac) => member(ac)?.user.email,
+ sorter: (a, b) => member(a)?.user.email.localeCompare(member(b)?.user.email),
+ },
+ {
+ title: 'Level',
+ key: 'level',
+ width: 0,
+ render: function LevelRender(_, { access_level, organization_member }) {
+ return (
+
+
+ void updateAccessControlMembers([{ member: organization_member, level }])
+ }
+ />
+
+ )
+ },
+ },
+ {
+ key: 'remove',
+ width: 0,
+ render: (_, { organization_member }) => {
+ return (
+
+ void updateAccessControlMembers([{ member: organization_member, level: null }])
+ }
+ />
+ )
+ },
+ },
+ ]
+
+ return (
+
+
{
+ if (guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING)) {
+ await updateAccessControlMembers(newValues.map((member) => ({ member, level })))
+ }
+ }}
+ options={addableMembers.map((member) => ({
+ key: member.id,
+ label: `${member.user.first_name} ${member.user.email}`,
+ labelComponent: ,
+ }))}
+ />
+
+
+
+ )
+}
+
+function AccessControlObjectRoles(): JSX.Element | null {
+ const { accessControlRoles, accessControlsLoading, addableRoles, rolesById, availableLevels } =
+ useValues(accessControlLogic)
+ const { updateAccessControlRoles } = useAsyncActions(accessControlLogic)
+ const { guardAvailableFeature } = useValues(upgradeModalLogic)
+
+ const columns: LemonTableColumns = [
+ {
+ title: 'Role',
+ key: 'role',
+ width: 0,
+ render: (_, { role }) => (
+
+
+
+ ),
+ },
+ {
+ title: 'Members',
+ key: 'members',
+ render: (_, { role }) => {
+ return (
+ ({
+ email: member.user.email,
+ name: member.user.first_name,
+ title: `${member.user.first_name} <${member.user.email}>`,
+ })) ?? []
+ }
+ />
+ )
+ },
+ },
+ {
+ title: 'Level',
+ key: 'level',
+ width: 0,
+ render: (_, { access_level, role }) => {
+ return (
+
+ void updateAccessControlRoles([{ role, level }])}
+ />
+
+ )
+ },
+ },
+ {
+ key: 'remove',
+ width: 0,
+ render: (_, { role }) => {
+ return (
+ void updateAccessControlRoles([{ role, level: null }])}
+ />
+ )
+ },
+ },
+ ]
+
+ return (
+
+
{
+ if (guardAvailableFeature(AvailableFeature.PROJECT_BASED_PERMISSIONING)) {
+ await updateAccessControlRoles(newValues.map((role) => ({ role, level })))
+ }
+ }}
+ options={addableRoles.map((role) => ({
+ key: role.id,
+ label: role.name,
+ }))}
+ />
+
+
+
+ )
+}
+
+function SimplLevelComponent(props: {
+ size?: LemonSelectProps['size']
+ level: AccessControlType['access_level'] | null
+ levels: AccessControlType['access_level'][]
+ onChange: (newValue: AccessControlType['access_level']) => void
+}): JSX.Element | null {
+ const { canEditAccessControls } = useValues(accessControlLogic)
+
+ return (
+ props.onChange(newValue)}
+ disabledReason={!canEditAccessControls ? 'You cannot edit this' : undefined}
+ options={props.levels.map((level) => ({
+ value: level,
+ label: capitalizeFirstLetter(level ?? ''),
+ }))}
+ />
+ )
+}
+
+function RemoveAccessButton({
+ onConfirm,
+ subject,
+}: {
+ onConfirm: () => void
+ subject: 'member' | 'role'
+}): JSX.Element {
+ const { canEditAccessControls } = useValues(accessControlLogic)
+
+ return (
+ }
+ status="danger"
+ size="small"
+ disabledReason={!canEditAccessControls ? 'You cannot edit this' : undefined}
+ onClick={() =>
+ LemonDialog.open({
+ title: 'Remove access',
+ content: `Are you sure you want to remove this ${subject}'s explicit access?`,
+ primaryButton: {
+ children: 'Remove',
+ status: 'danger',
+ onClick: () => onConfirm(),
+ },
+ })
+ }
+ />
+ )
+}
+
+function AddItemsControls(props: {
+ placeholder: string
+ onAdd: (newValues: string[], level: AccessControlType['access_level']) => Promise
+ options: {
+ key: string
+ label: string
+ }[]
+}): JSX.Element | null {
+ const { availableLevels, canEditAccessControls } = useValues(accessControlLogic)
+ // TODO: Move this into a form logic
+ const [items, setItems] = useState([])
+ const [level, setLevel] = useState(availableLevels[0] ?? null)
+
+ useEffect(() => {
+ setLevel(availableLevels[0] ?? null)
+ }, [availableLevels])
+
+ const onSubmit =
+ items.length && level
+ ? (): void =>
+ void props.onAdd(items, level).then(() => {
+ setItems([])
+ setLevel(availableLevels[0] ?? null)
+ })
+ : undefined
+
+ return (
+
+
+ setItems(newValues)}
+ mode="multiple"
+ options={props.options}
+ disabled={!canEditAccessControls}
+ />
+
+
+
+
+ Add
+
+
+ )
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx
new file mode 100644
index 0000000000000..c235eeacb01ea
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls.tsx
@@ -0,0 +1,323 @@
+import { IconPlus } from '@posthog/icons'
+import {
+ LemonButton,
+ LemonDialog,
+ LemonInput,
+ LemonInputSelect,
+ LemonModal,
+ LemonSelect,
+ LemonTable,
+ LemonTableColumns,
+ ProfileBubbles,
+ ProfilePicture,
+} from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { capitalizeFirstLetter, Form } from 'kea-forms'
+import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
+import { usersLemonSelectOptions } from 'lib/components/UserSelectItem'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
+import { fullName } from 'lib/utils'
+import { useMemo, useState } from 'react'
+import { userLogic } from 'scenes/userLogic'
+
+import { AvailableFeature } from '~/types'
+
+import { roleBasedAccessControlLogic, RoleWithResourceAccessControls } from './roleBasedAccessControlLogic'
+
+export type RolesAndResourceAccessControlsProps = {
+ noAccessControls?: boolean
+}
+
+export function RolesAndResourceAccessControls({ noAccessControls }: RolesAndResourceAccessControlsProps): JSX.Element {
+ const {
+ rolesWithResourceAccessControls,
+ rolesLoading,
+ roleBasedAccessControlsLoading,
+ resources,
+ availableLevels,
+ selectedRoleId,
+ defaultAccessLevel,
+ } = useValues(roleBasedAccessControlLogic)
+
+ const { updateRoleBasedAccessControls, selectRoleId, setEditingRoleId } = useActions(roleBasedAccessControlLogic)
+
+ const roleColumns = noAccessControls
+ ? []
+ : resources.map((resource) => ({
+ title: resource.replace(/_/g, ' ') + 's',
+ key: resource,
+ width: 0,
+ render: (_: any, { accessControlByResource, role }: RoleWithResourceAccessControls) => {
+ const ac = accessControlByResource[resource]
+
+ return (
+
+ updateRoleBasedAccessControls([
+ {
+ resource,
+ role: role?.id ?? null,
+ access_level: newValue,
+ },
+ ])
+ }
+ options={availableLevels.map((level) => ({
+ value: level,
+ label: capitalizeFirstLetter(level ?? ''),
+ }))}
+ />
+ )
+ },
+ }))
+
+ const columns: LemonTableColumns = [
+ {
+ title: 'Role',
+ key: 'role',
+ width: 0,
+ render: (_, { role }) => (
+
+ (role.id === selectedRoleId ? selectRoleId(null) : selectRoleId(role.id))
+ : undefined
+ }
+ title={role?.name ?? 'Default'}
+ />
+
+ ),
+ },
+ {
+ title: 'Members',
+ key: 'members',
+ render: (_, { role }) => {
+ return role ? (
+ role.members.length ? (
+ ({
+ email: member.user.email,
+ name: member.user.first_name,
+ title: `${member.user.first_name} <${member.user.email}>`,
+ }))}
+ onClick={() => (role.id === selectedRoleId ? selectRoleId(null) : selectRoleId(role.id))}
+ />
+ ) : (
+ 'No members'
+ )
+ ) : (
+ 'All members'
+ )
+ },
+ },
+
+ ...roleColumns,
+ ]
+
+ return (
+
+
Use roles to group your organization members and assign them permissions.
+
+
+
+ !!selectedRoleId && role?.id === selectedRoleId,
+ onRowExpand: ({ role }) => (role ? selectRoleId(role.id) : undefined),
+ onRowCollapse: () => selectRoleId(null),
+ expandedRowRender: ({ role }) => (role ? : null),
+ rowExpandable: ({ role }) => !!role,
+ }}
+ />
+
+ setEditingRoleId('new')} icon={ }>
+ Add a role
+
+
+
+
+
+ )
+}
+
+function RoleDetails({ roleId }: { roleId: string }): JSX.Element | null {
+ const { user } = useValues(userLogic)
+ const { sortedMembers, roles, canEditRoleBasedAccessControls } = useValues(roleBasedAccessControlLogic)
+ const { addMembersToRole, removeMemberFromRole, setEditingRoleId } = useActions(roleBasedAccessControlLogic)
+ const [membersToAdd, setMembersToAdd] = useState([])
+
+ const role = roles?.find((role) => role.id === roleId)
+
+ const onSubmit = membersToAdd.length
+ ? () => {
+ role && addMembersToRole(role, membersToAdd)
+ setMembersToAdd([])
+ }
+ : undefined
+
+ const membersNotInRole = useMemo(() => {
+ const membersInRole = new Set(role?.members.map((member) => member.user.uuid))
+ return sortedMembers?.filter((member) => !membersInRole.has(member.user.uuid)) ?? []
+ }, [role?.members, sortedMembers])
+
+ if (!role) {
+ // This is mostly for typing
+ return null
+ }
+
+ return (
+
+
+
+
+ setMembersToAdd(newValues)}
+ mode="multiple"
+ disabled={!canEditRoleBasedAccessControls}
+ options={usersLemonSelectOptions(
+ membersNotInRole.map((member) => member.user),
+ 'uuid'
+ )}
+ />
+
+
+
+ Add members
+
+
+
+ setEditingRoleId(role.id)}
+ disabledReason={!canEditRoleBasedAccessControls ? 'You cannot edit this' : undefined}
+ >
+ Edit
+
+
+
+
+
+ },
+ width: 32,
+ },
+ {
+ title: 'Name',
+ key: 'user_name',
+ render: (_, member) =>
+ member.user.uuid == user?.uuid ? `${fullName(member.user)} (you)` : fullName(member.user),
+ sorter: (a, b) => fullName(a.user).localeCompare(fullName(b.user)),
+ },
+ {
+ title: 'Email',
+ key: 'user_email',
+ render: (_, member) => {
+ return <>{member.user.email}>
+ },
+ sorter: (a, b) => a.user.email.localeCompare(b.user.email),
+ },
+ {
+ key: 'actions',
+ width: 0,
+ render: (_, member) => {
+ return (
+
+ removeMemberFromRole(role, member.id)}
+ >
+ Remove
+
+
+ )
+ },
+ },
+ ]}
+ dataSource={role.members}
+ />
+
+ )
+}
+
+function RoleModal(): JSX.Element {
+ const { editingRoleId } = useValues(roleBasedAccessControlLogic)
+ const { setEditingRoleId, submitEditingRole, deleteRole } = useActions(roleBasedAccessControlLogic)
+ const isEditing = editingRoleId !== 'new'
+
+ const onDelete = (): void => {
+ LemonDialog.open({
+ title: 'Delete role',
+ content: 'Are you sure you want to delete this role? This action cannot be undone.',
+ primaryButton: {
+ children: 'Delete permanently',
+ onClick: () => deleteRole(editingRoleId as string),
+ status: 'danger',
+ },
+ secondaryButton: {
+ children: 'Cancel',
+ },
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx
new file mode 100644
index 0000000000000..266b012ebcd77
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/SidePanelAccessControl.tsx
@@ -0,0 +1,25 @@
+import { useValues } from 'kea'
+
+import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader'
+import { sidePanelContextLogic } from '../sidePanelContextLogic'
+import { AccessControlObject } from './AccessControlObject'
+
+export const SidePanelAccessControl = (): JSX.Element => {
+ const { sceneSidePanelContext } = useValues(sidePanelContextLogic)
+
+ return (
+
+
+
+ {sceneSidePanelContext.access_control_resource && sceneSidePanelContext.access_control_resource_id ? (
+
+ ) : (
+
Not supported
+ )}
+
+
+ )
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts
new file mode 100644
index 0000000000000..8182b41c2b602
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/accessControlLogic.ts
@@ -0,0 +1,250 @@
+import { LemonSelectOption } from '@posthog/lemon-ui'
+import { actions, afterMount, connect, kea, key, listeners, path, props, selectors } from 'kea'
+import { loaders } from 'kea-loaders'
+import api from 'lib/api'
+import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic'
+import { toSentenceCase } from 'lib/utils'
+import { membersLogic } from 'scenes/organization/membersLogic'
+import { teamLogic } from 'scenes/teamLogic'
+
+import {
+ AccessControlResponseType,
+ AccessControlType,
+ AccessControlTypeMember,
+ AccessControlTypeProject,
+ AccessControlTypeRole,
+ AccessControlUpdateType,
+ APIScopeObject,
+ OrganizationMemberType,
+ RoleType,
+} from '~/types'
+
+import type { accessControlLogicType } from './accessControlLogicType'
+import { roleBasedAccessControlLogic } from './roleBasedAccessControlLogic'
+
+export type AccessControlLogicProps = {
+ resource: APIScopeObject
+ resource_id: string
+}
+
+export const accessControlLogic = kea([
+ props({} as AccessControlLogicProps),
+ key((props) => `${props.resource}-${props.resource_id}`),
+ path((key) => ['scenes', 'accessControl', 'accessControlLogic', key]),
+ connect({
+ values: [
+ membersLogic,
+ ['sortedMembers'],
+ teamLogic,
+ ['currentTeam'],
+ roleBasedAccessControlLogic,
+ ['roles'],
+ upgradeModalLogic,
+ ['guardAvailableFeature'],
+ ],
+ actions: [membersLogic, ['ensureAllMembersLoaded']],
+ }),
+ actions({
+ updateAccessControl: (
+ accessControl: Pick
+ ) => ({ accessControl }),
+ updateAccessControlDefault: (level: AccessControlType['access_level']) => ({
+ level,
+ }),
+ updateAccessControlRoles: (
+ accessControls: {
+ role: RoleType['id']
+ level: AccessControlType['access_level']
+ }[]
+ ) => ({ accessControls }),
+ updateAccessControlMembers: (
+ accessControls: {
+ member: OrganizationMemberType['id']
+ level: AccessControlType['access_level']
+ }[]
+ ) => ({ accessControls }),
+ }),
+ loaders(({ values }) => ({
+ accessControls: [
+ null as AccessControlResponseType | null,
+ {
+ loadAccessControls: async () => {
+ try {
+ const response = await api.get(values.endpoint)
+ return response
+ } catch (error) {
+ // Return empty access controls
+ return {
+ access_controls: [],
+ available_access_levels: ['none', 'viewer', 'editor'],
+ user_access_level: 'none',
+ default_access_level: 'none',
+ user_can_edit_access_levels: false,
+ }
+ }
+ },
+
+ updateAccessControlDefault: async ({ level }) => {
+ await api.put(values.endpoint, {
+ access_level: level,
+ })
+
+ return values.accessControls
+ },
+
+ updateAccessControlRoles: async ({ accessControls }) => {
+ for (const { role, level } of accessControls) {
+ await api.put(values.endpoint, {
+ role: role,
+ access_level: level,
+ })
+ }
+
+ return values.accessControls
+ },
+
+ updateAccessControlMembers: async ({ accessControls }) => {
+ for (const { member, level } of accessControls) {
+ await api.put(values.endpoint, {
+ organization_member: member,
+ access_level: level,
+ })
+ }
+
+ return values.accessControls
+ },
+ },
+ ],
+ })),
+ listeners(({ actions }) => ({
+ updateAccessControlDefaultSuccess: () => actions.loadAccessControls(),
+ updateAccessControlRolesSuccess: () => actions.loadAccessControls(),
+ updateAccessControlMembersSuccess: () => actions.loadAccessControls(),
+ })),
+ selectors({
+ endpoint: [
+ () => [(_, props) => props],
+ (props): string => {
+ // TODO: This is far from perfect... but it's a start
+ if (props.resource === 'project') {
+ return `api/projects/@current/access_controls`
+ }
+ return `api/projects/@current/${props.resource}s/${props.resource_id}/access_controls`
+ },
+ ],
+ humanReadableResource: [
+ () => [(_, props) => props],
+ (props): string => {
+ return props.resource.replace(/_/g, ' ')
+ },
+ ],
+
+ availableLevelsWithNone: [
+ (s) => [s.accessControls],
+ (accessControls): string[] => {
+ return accessControls?.available_access_levels ?? []
+ },
+ ],
+
+ availableLevels: [
+ (s) => [s.availableLevelsWithNone],
+ (availableLevelsWithNone): string[] => {
+ return availableLevelsWithNone.filter((level) => level !== 'none')
+ },
+ ],
+
+ canEditAccessControls: [
+ (s) => [s.accessControls],
+ (accessControls): boolean | null => {
+ return accessControls?.user_can_edit_access_levels ?? null
+ },
+ ],
+
+ accessControlDefaultLevel: [
+ (s) => [s.accessControls],
+ (accessControls): string | null => {
+ return accessControls?.default_access_level ?? null
+ },
+ ],
+
+ accessControlDefaultOptions: [
+ (s) => [s.availableLevelsWithNone, (_, props) => props.resource],
+ (availableLevelsWithNone): LemonSelectOption[] => {
+ const options = availableLevelsWithNone.map((level) => ({
+ value: level,
+ // TODO: Correct "a" and "an"
+ label: level === 'none' ? 'No access' : toSentenceCase(level),
+ }))
+
+ return options
+ },
+ ],
+ accessControlDefault: [
+ (s) => [s.accessControls, s.accessControlDefaultLevel],
+ (accessControls, accessControlDefaultLevel): AccessControlTypeProject => {
+ const found = accessControls?.access_controls?.find(
+ (accessControl) => !accessControl.organization_member && !accessControl.role
+ ) as AccessControlTypeProject
+ return (
+ found ?? {
+ access_level: accessControlDefaultLevel,
+ }
+ )
+ },
+ ],
+
+ accessControlMembers: [
+ (s) => [s.accessControls],
+ (accessControls): AccessControlTypeMember[] => {
+ return (accessControls?.access_controls || []).filter(
+ (accessControl) => !!accessControl.organization_member
+ ) as AccessControlTypeMember[]
+ },
+ ],
+
+ accessControlRoles: [
+ (s) => [s.accessControls],
+ (accessControls): AccessControlTypeRole[] => {
+ return (accessControls?.access_controls || []).filter(
+ (accessControl) => !!accessControl.role
+ ) as AccessControlTypeRole[]
+ },
+ ],
+
+ rolesById: [
+ (s) => [s.roles],
+ (roles): Record => {
+ return Object.fromEntries((roles || []).map((role) => [role.id, role]))
+ },
+ ],
+
+ addableRoles: [
+ (s) => [s.roles, s.accessControlRoles],
+ (roles, accessControlRoles): RoleType[] => {
+ return roles ? roles.filter((role) => !accessControlRoles.find((ac) => ac.role === role.id)) : []
+ },
+ ],
+
+ membersById: [
+ (s) => [s.sortedMembers],
+ (members): Record => {
+ return Object.fromEntries((members || []).map((member) => [member.id, member]))
+ },
+ ],
+
+ addableMembers: [
+ (s) => [s.sortedMembers, s.accessControlMembers],
+ (members, accessControlMembers): any[] => {
+ return members
+ ? members.filter(
+ (member) => !accessControlMembers.find((ac) => ac.organization_member === member.id)
+ )
+ : []
+ },
+ ],
+ }),
+ afterMount(({ actions }) => {
+ actions.loadAccessControls()
+ actions.ensureAllMembersLoaded()
+ }),
+])
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts
new file mode 100644
index 0000000000000..87d885844bfb1
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/access_control/roleBasedAccessControlLogic.ts
@@ -0,0 +1,269 @@
+import { lemonToast } from '@posthog/lemon-ui'
+import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
+import { forms } from 'kea-forms'
+import { loaders } from 'kea-loaders'
+import { actionToUrl, router } from 'kea-router'
+import api from 'lib/api'
+import { membersLogic } from 'scenes/organization/membersLogic'
+import { teamLogic } from 'scenes/teamLogic'
+import { userLogic } from 'scenes/userLogic'
+
+import {
+ AccessControlResponseType,
+ AccessControlType,
+ AccessControlTypeRole,
+ AccessControlUpdateType,
+ APIScopeObject,
+ AvailableFeature,
+ RoleType,
+} from '~/types'
+
+import type { roleBasedAccessControlLogicType } from './roleBasedAccessControlLogicType'
+
+export type RoleWithResourceAccessControls = {
+ role?: RoleType
+ accessControlByResource: Record
+}
+
+export const roleBasedAccessControlLogic = kea([
+ path(['scenes', 'accessControl', 'roleBasedAccessControlLogic']),
+ connect({
+ values: [membersLogic, ['sortedMembers'], teamLogic, ['currentTeam'], userLogic, ['hasAvailableFeature']],
+ actions: [membersLogic, ['ensureAllMembersLoaded']],
+ }),
+ actions({
+ updateRoleBasedAccessControls: (
+ accessControls: Pick[]
+ ) => ({ accessControls }),
+ selectRoleId: (roleId: RoleType['id'] | null) => ({ roleId }),
+ deleteRole: (roleId: RoleType['id']) => ({ roleId }),
+ removeMemberFromRole: (role: RoleType, roleMemberId: string) => ({ role, roleMemberId }),
+ addMembersToRole: (role: RoleType, members: string[]) => ({ role, members }),
+ setEditingRoleId: (roleId: string | null) => ({ roleId }),
+ }),
+ reducers({
+ selectedRoleId: [
+ null as string | null,
+ {
+ selectRoleId: (_, { roleId }) => roleId,
+ },
+ ],
+ editingRoleId: [
+ null as string | null,
+ {
+ setEditingRoleId: (_, { roleId }) => roleId,
+ },
+ ],
+ }),
+ loaders(({ values }) => ({
+ roleBasedAccessControls: [
+ null as AccessControlResponseType | null,
+ {
+ loadRoleBasedAccessControls: async () => {
+ const response = await api.get(
+ 'api/projects/@current/global_access_controls'
+ )
+ return response
+ },
+
+ updateRoleBasedAccessControls: async ({ accessControls }) => {
+ for (const control of accessControls) {
+ await api.put('api/projects/@current/global_access_controls', {
+ ...control,
+ })
+ }
+
+ return values.roleBasedAccessControls
+ },
+ },
+ ],
+
+ roles: [
+ null as RoleType[] | null,
+ {
+ loadRoles: async () => {
+ const response = await api.roles.list()
+ return response?.results || []
+ },
+ addMembersToRole: async ({ role, members }) => {
+ if (!values.roles) {
+ return null
+ }
+ const newMembers = await Promise.all(
+ members.map(async (userUuid: string) => await api.roles.members.create(role.id, userUuid))
+ )
+
+ role.members = [...role.members, ...newMembers]
+
+ return [...values.roles]
+ },
+ removeMemberFromRole: async ({ role, roleMemberId }) => {
+ if (!values.roles) {
+ return null
+ }
+ await api.roles.members.delete(role.id, roleMemberId)
+ role.members = role.members.filter((roleMember) => roleMember.id !== roleMemberId)
+ return [...values.roles]
+ },
+ deleteRole: async ({ roleId }) => {
+ const role = values.roles?.find((r) => r.id === roleId)
+ if (!role) {
+ return values.roles
+ }
+ await api.roles.delete(role.id)
+ lemonToast.success(`Role "${role.name}" deleted`)
+ return values.roles?.filter((r) => r.id !== role.id) || []
+ },
+ },
+ ],
+ })),
+
+ forms(({ values, actions }) => ({
+ editingRole: {
+ defaults: {
+ name: '',
+ },
+ errors: ({ name }) => {
+ return {
+ name: !name ? 'Please choose a name for the role' : null,
+ }
+ },
+ submit: async ({ name }) => {
+ if (!values.editingRoleId) {
+ return
+ }
+ let role: RoleType | null = null
+ if (values.editingRoleId === 'new') {
+ role = await api.roles.create(name)
+ } else {
+ role = await api.roles.update(values.editingRoleId, { name })
+ }
+
+ actions.loadRoles()
+ actions.setEditingRoleId(null)
+ actions.selectRoleId(role.id)
+ },
+ },
+ })),
+
+ listeners(({ actions, values }) => ({
+ updateRoleBasedAccessControlsSuccess: () => actions.loadRoleBasedAccessControls(),
+ loadRolesSuccess: () => {
+ if (router.values.hashParams.role) {
+ actions.selectRoleId(router.values.hashParams.role)
+ }
+ },
+ deleteRoleSuccess: () => {
+ actions.loadRoles()
+ actions.setEditingRoleId(null)
+ actions.selectRoleId(null)
+ },
+
+ setEditingRoleId: () => {
+ const existingRole = values.roles?.find((role) => role.id === values.editingRoleId)
+ actions.resetEditingRole({
+ name: existingRole?.name || '',
+ })
+ },
+ })),
+
+ selectors({
+ availableLevels: [
+ (s) => [s.roleBasedAccessControls],
+ (roleBasedAccessControls): string[] => {
+ return roleBasedAccessControls?.available_access_levels ?? []
+ },
+ ],
+
+ defaultAccessLevel: [
+ (s) => [s.roleBasedAccessControls],
+ (roleBasedAccessControls): string | null => {
+ return roleBasedAccessControls?.default_access_level ?? null
+ },
+ ],
+
+ defaultResourceAccessControls: [
+ (s) => [s.roleBasedAccessControls],
+ (roleBasedAccessControls): RoleWithResourceAccessControls => {
+ const accessControls = roleBasedAccessControls?.access_controls ?? []
+
+ // Find all acs without a roles (they are the default ones)
+ const accessControlByResource = accessControls
+ .filter((control) => !control.role)
+ .reduce(
+ (acc, control) => ({
+ ...acc,
+ [control.resource]: control,
+ }),
+ {} as Record
+ )
+
+ return { accessControlByResource }
+ },
+ ],
+
+ rolesWithResourceAccessControls: [
+ (s) => [s.roles, s.roleBasedAccessControls, s.defaultResourceAccessControls],
+ (roles, roleBasedAccessControls, defaultResourceAccessControls): RoleWithResourceAccessControls[] => {
+ if (!roles) {
+ return []
+ }
+
+ const accessControls = roleBasedAccessControls?.access_controls ?? []
+
+ return [
+ defaultResourceAccessControls,
+ ...roles.map((role) => {
+ const accessControlByResource = accessControls
+ .filter((control) => control.role === role.id)
+ .reduce(
+ (acc, control) => ({
+ ...acc,
+ [control.resource]: control,
+ }),
+ {} as Record
+ )
+
+ return { role, accessControlByResource }
+ }),
+ ]
+ },
+ ],
+
+ resources: [
+ () => [],
+ (): AccessControlType['resource'][] => {
+ // TODO: Sync this as an enum
+ return ['feature_flag', 'dashboard', 'insight', 'notebook']
+ },
+ ],
+
+ canEditRoleBasedAccessControls: [
+ (s) => [s.roleBasedAccessControls],
+ (roleBasedAccessControls): boolean | null => {
+ return roleBasedAccessControls?.user_can_edit_access_levels ?? null
+ },
+ ],
+ }),
+ afterMount(({ actions, values }) => {
+ if (values.hasAvailableFeature(AvailableFeature.ROLE_BASED_ACCESS)) {
+ actions.loadRoles()
+ actions.loadRoleBasedAccessControls()
+ actions.ensureAllMembersLoaded()
+ }
+ }),
+
+ actionToUrl(({ values }) => ({
+ selectRoleId: () => {
+ const { currentLocation } = router.values
+ return [
+ currentLocation.pathname,
+ currentLocation.searchParams,
+ {
+ ...currentLocation.hashParams,
+ role: values.selectedRoleId ?? undefined,
+ },
+ ]
+ },
+ })),
+])
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx
index 21a52abe26936..9151d3ff03207 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivity.tsx
@@ -32,6 +32,7 @@ import { ActivityScope, AvailableFeature } from '~/types'
import { SidePanelPaneHeader } from '../../components/SidePanelPaneHeader'
import { SidePanelActivityMetalytics } from './SidePanelActivityMetalytics'
+import { SidePanelActivitySubscriptions } from './SidePanelActivitySubscriptions'
const SCROLL_TRIGGER_OFFSET = 100
@@ -152,6 +153,14 @@ export const SidePanelActivity = (): JSX.Element => {
},
]
: []),
+ ...(featureFlags[FEATURE_FLAGS.CDP_ACTIVITY_LOG_NOTIFICATIONS]
+ ? [
+ {
+ key: SidePanelActivityTab.Subscriptions,
+ label: 'Subscriptions',
+ },
+ ]
+ : []),
]}
/>
@@ -280,6 +289,8 @@ export const SidePanelActivity = (): JSX.Element => {
>
) : activeTab === SidePanelActivityTab.Metalytics ? (
+ ) : activeTab === SidePanelActivityTab.Subscriptions ? (
+
) : null}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx
new file mode 100644
index 0000000000000..d450e2641e1f5
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/SidePanelActivitySubscriptions.tsx
@@ -0,0 +1,22 @@
+import { LinkedHogFunctions } from 'scenes/pipeline/hogfunctions/list/LinkedHogFunctions'
+
+export function SidePanelActivitySubscriptions(): JSX.Element {
+ return (
+
+
Get notified of your team's activity
+
+
+
+ )
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx
index 244e42c52d936..83ab2604734bc 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/activity/sidePanelActivityLogic.tsx
@@ -10,12 +10,21 @@ import { toParams } from 'lib/utils'
import posthog from 'posthog-js'
import { projectLogic } from 'scenes/projectLogic'
+import { ActivityScope, UserBasicType } from '~/types'
+
import { sidePanelStateLogic } from '../../sidePanelStateLogic'
-import { ActivityFilters, activityForSceneLogic } from './activityForSceneLogic'
+import { SidePanelSceneContext } from '../../types'
+import { sidePanelContextLogic } from '../sidePanelContextLogic'
import type { sidePanelActivityLogicType } from './sidePanelActivityLogicType'
const POLL_TIMEOUT = 5 * 60 * 1000
+export type ActivityFilters = {
+ scope?: ActivityScope
+ item_id?: ActivityLogItem['item_id']
+ user?: UserBasicType['id']
+}
+
export interface ChangelogFlagPayload {
notificationDate: dayjs.Dayjs
markdown: string
@@ -31,12 +40,13 @@ export enum SidePanelActivityTab {
Unread = 'unread',
All = 'all',
Metalytics = 'metalytics',
+ Subscriptions = 'subscriptions',
}
export const sidePanelActivityLogic = kea([
path(['scenes', 'navigation', 'sidepanel', 'sidePanelActivityLogic']),
connect({
- values: [activityForSceneLogic, ['sceneActivityFilters'], projectLogic, ['currentProjectId']],
+ values: [sidePanelContextLogic, ['sceneSidePanelContext'], projectLogic, ['currentProjectId']],
actions: [sidePanelStateLogic, ['openSidePanel']],
}),
actions({
@@ -56,6 +66,7 @@ export const sidePanelActivityLogic = kea([
reducers({
activeTab: [
SidePanelActivityTab.Unread as SidePanelActivityTab,
+ { persist: true },
{
setActiveTab: (_, { tab }) => tab,
},
@@ -267,8 +278,16 @@ export const sidePanelActivityLogic = kea([
}),
subscriptions(({ actions, values }) => ({
- sceneActivityFilters: (activityFilters) => {
- actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null)
+ sceneSidePanelContext: (sceneSidePanelContext: SidePanelSceneContext) => {
+ actions.setFiltersForCurrentPage(
+ sceneSidePanelContext
+ ? {
+ ...values.filters,
+ scope: sceneSidePanelContext.activity_scope,
+ item_id: sceneSidePanelContext.activity_item_id,
+ }
+ : null
+ )
},
filters: () => {
if (values.activeTab === SidePanelActivityTab.All) {
@@ -280,7 +299,7 @@ export const sidePanelActivityLogic = kea([
afterMount(({ actions, values }) => {
actions.loadImportantChanges()
- const activityFilters = values.sceneActivityFilters
+ const activityFilters = values.sceneSidePanelContext
actions.setFiltersForCurrentPage(activityFilters ? { ...values.filters, ...activityFilters } : null)
}),
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts
index 5793deba3469f..9d1ba1d536d9b 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/discussion/sidePanelDiscussionLogic.ts
@@ -6,7 +6,7 @@ import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { CommentsLogicProps } from 'scenes/comments/commentsLogic'
-import { activityForSceneLogic } from '../activity/activityForSceneLogic'
+import { sidePanelContextLogic } from '../sidePanelContextLogic'
import type { sidePanelDiscussionLogicType } from './sidePanelDiscussionLogicType'
export const sidePanelDiscussionLogic = kea([
@@ -16,7 +16,7 @@ export const sidePanelDiscussionLogic = kea([
resetCommentCount: true,
}),
connect({
- values: [featureFlagLogic, ['featureFlags'], activityForSceneLogic, ['sceneActivityFilters']],
+ values: [featureFlagLogic, ['featureFlags'], sidePanelContextLogic, ['sceneSidePanelContext']],
}),
loaders(({ values }) => ({
commentCount: [
@@ -45,12 +45,12 @@ export const sidePanelDiscussionLogic = kea([
selectors({
commentsLogicProps: [
- (s) => [s.sceneActivityFilters],
- (activityFilters): CommentsLogicProps | null => {
- return activityFilters?.scope
+ (s) => [s.sceneSidePanelContext],
+ (sceneSidePanelContext): CommentsLogicProps | null => {
+ return sceneSidePanelContext.activity_scope
? {
- scope: activityFilters.scope,
- item_id: activityFilters.item_id,
+ scope: sceneSidePanelContext.activity_scope,
+ item_id: sceneSidePanelContext.activity_item_id,
}
: null
},
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts
index c9107c4ac695f..8f26e5927842e 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/exports/sidePanelExportsLogic.ts
@@ -1,23 +1,14 @@
import { afterMount, connect, kea, path } from 'kea'
import { exportsLogic } from 'lib/components/ExportButton/exportsLogic'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
-import { activityForSceneLogic } from '../activity/activityForSceneLogic'
import type { sidePanelExportsLogicType } from './sidePanelExportsLogicType'
export const sidePanelExportsLogic = kea([
path(['scenes', 'navigation', 'sidepanel', 'sidePanelExportsLogic']),
connect({
- values: [
- featureFlagLogic,
- ['featureFlags'],
- activityForSceneLogic,
- ['sceneActivityFilters'],
- exportsLogic,
- ['exports', 'freshUndownloadedExports'],
- ],
+ values: [exportsLogic, ['exports', 'freshUndownloadedExports']],
actions: [sidePanelStateLogic, ['openSidePanel'], exportsLogic, ['loadExports', 'removeFresh']],
}),
afterMount(({ actions }) => {
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts
similarity index 59%
rename from frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts
rename to frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts
index 641c0900638ef..1de9b8e00e251 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic.ts
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelContextLogic.ts
@@ -1,22 +1,15 @@
import { connect, kea, path, selectors } from 'kea'
import { router } from 'kea-router'
import { objectsEqual } from 'kea-test-utils'
-import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity'
import { removeProjectIdIfPresent } from 'lib/utils/router-utils'
import { sceneLogic } from 'scenes/sceneLogic'
import { SceneConfig } from 'scenes/sceneTypes'
-import { ActivityScope, UserBasicType } from '~/types'
+import { SidePanelSceneContext } from '../types'
+import { SIDE_PANEL_CONTEXT_KEY } from '../types'
+import type { sidePanelContextLogicType } from './sidePanelContextLogicType'
-import type { activityForSceneLogicType } from './activityForSceneLogicType'
-
-export type ActivityFilters = {
- scope?: ActivityScope
- item_id?: ActivityLogItem['item_id']
- user?: UserBasicType['id']
-}
-
-export const activityFiltersForScene = (sceneConfig: SceneConfig | null): ActivityFilters | null => {
+export const activityFiltersForScene = (sceneConfig: SceneConfig | null): SidePanelSceneContext | null => {
if (sceneConfig?.activityScope) {
// NOTE: - HACKY, we are just parsing the item_id from the url optimistically...
const pathParts = removeProjectIdIfPresent(router.values.currentLocation.pathname).split('/')
@@ -24,38 +17,43 @@ export const activityFiltersForScene = (sceneConfig: SceneConfig | null): Activi
// Loose check for the item_id being a number, a short_id (8 chars) or a uuid
if (item_id && (item_id.length === 8 || item_id.length === 36 || !isNaN(parseInt(item_id)))) {
- return { scope: sceneConfig.activityScope, item_id }
+ return { activity_scope: sceneConfig.activityScope, activity_item_id: item_id }
}
- return { scope: sceneConfig.activityScope }
+ return { activity_scope: sceneConfig.activityScope }
}
return null
}
-export const activityForSceneLogic = kea([
- path(['scenes', 'navigation', 'sidepanel', 'activityForSceneLogic']),
+export const sidePanelContextLogic = kea([
+ path(['scenes', 'navigation', 'sidepanel', 'sidePanelContextLogic']),
connect({
values: [sceneLogic, ['sceneConfig']],
}),
selectors({
- sceneActivityFilters: [
+ sceneSidePanelContext: [
(s) => [
+ s.sceneConfig,
// Similar to "breadcrumbs"
(state, props) => {
const activeSceneLogic = sceneLogic.selectors.activeSceneLogic(state, props)
- const sceneConfig = s.sceneConfig(state, props)
- if (activeSceneLogic && 'activityFilters' in activeSceneLogic.selectors) {
+ if (activeSceneLogic && SIDE_PANEL_CONTEXT_KEY in activeSceneLogic.selectors) {
const activeLoadedScene = sceneLogic.selectors.activeLoadedScene(state, props)
- return activeSceneLogic.selectors.activityFilters(
+ return activeSceneLogic.selectors[SIDE_PANEL_CONTEXT_KEY](
state,
activeLoadedScene?.paramsToProps?.(activeLoadedScene?.sceneParams) || props
)
}
- return activityFiltersForScene(sceneConfig)
+ return null
},
],
- (filters): ActivityFilters | null => filters,
+ (sceneConfig, context): SidePanelSceneContext => {
+ return {
+ ...(context ?? {}),
+ ...(!context?.activity_scope ? activityFiltersForScene(sceneConfig) : {}),
+ }
+ },
{ equalityCheck: objectsEqual },
],
}),
diff --git a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx
index 029b34b6cbf4a..b220fd505c4a8 100644
--- a/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx
+++ b/frontend/src/layout/navigation-3000/sidepanel/sidePanelLogic.tsx
@@ -8,6 +8,7 @@ import { activationLogic } from '~/layout/navigation-3000/sidepanel/panels/activ
import { AvailableFeature, SidePanelTab } from '~/types'
import { sidePanelActivityLogic } from './panels/activity/sidePanelActivityLogic'
+import { sidePanelContextLogic } from './panels/sidePanelContextLogic'
import { sidePanelStatusLogic } from './panels/sidePanelStatusLogic'
import type { sidePanelLogicType } from './sidePanelLogicType'
import { sidePanelStateLogic } from './sidePanelStateLogic'
@@ -39,14 +40,16 @@ export const sidePanelLogic = kea([
['status'],
userLogic,
['hasAvailableFeature'],
+ sidePanelContextLogic,
+ ['sceneSidePanelContext'],
],
actions: [sidePanelStateLogic, ['closeSidePanel', 'openSidePanel']],
}),
selectors({
enabledTabs: [
- (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks, s.featureFlags],
- (isCloudOrDev, isReady, hasCompletedAllTasks, featureflags) => {
+ (s) => [s.isCloudOrDev, s.isReady, s.hasCompletedAllTasks, s.featureFlags, s.sceneSidePanelContext],
+ (isCloudOrDev, isReady, hasCompletedAllTasks, featureflags, sceneSidePanelContext) => {
const tabs: SidePanelTab[] = []
tabs.push(SidePanelTab.Notebooks)
@@ -61,6 +64,13 @@ export const sidePanelLogic = kea([
if (isReady && !hasCompletedAllTasks) {
tabs.push(SidePanelTab.Activation)
}
+ if (
+ featureflags[FEATURE_FLAGS.ROLE_BASED_ACCESS_CONTROL] &&
+ sceneSidePanelContext.access_control_resource &&
+ sceneSidePanelContext.access_control_resource_id
+ ) {
+ tabs.push(SidePanelTab.AccessControl)
+ }
tabs.push(SidePanelTab.Exports)
tabs.push(SidePanelTab.FeaturePreviews)
tabs.push(SidePanelTab.Settings)
diff --git a/frontend/src/layout/navigation-3000/sidepanel/types.ts b/frontend/src/layout/navigation-3000/sidepanel/types.ts
new file mode 100644
index 0000000000000..28da07acb1c89
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/types.ts
@@ -0,0 +1,12 @@
+import { ActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity'
+
+import { ActivityScope, APIScopeObject } from '~/types'
+
+/** Allows scenes to set a context which enables richer features of the side panel */
+export type SidePanelSceneContext = {
+ access_control_resource?: APIScopeObject
+ access_control_resource_id?: string
+ activity_scope?: ActivityScope
+ activity_item_id?: ActivityLogItem['item_id']
+}
+export const SIDE_PANEL_CONTEXT_KEY = 'sidePanelContext'
diff --git a/frontend/src/layout/navigation-3000/types.ts b/frontend/src/layout/navigation-3000/types.ts
index 3f79f6dbda42f..a941e7dfaad74 100644
--- a/frontend/src/layout/navigation-3000/types.ts
+++ b/frontend/src/layout/navigation-3000/types.ts
@@ -151,4 +151,5 @@ export interface TentativeListItem {
export interface ButtonListItem extends BasicListItem {
key: '__button__'
onClick: () => void
+ icon?: JSX.Element
}
diff --git a/frontend/src/lib/api.mock.ts b/frontend/src/lib/api.mock.ts
index e6dac16290e92..709beee303f6b 100644
--- a/frontend/src/lib/api.mock.ts
+++ b/frontend/src/lib/api.mock.ts
@@ -3,6 +3,7 @@ import { dayjs } from 'lib/dayjs'
import {
CohortType,
+ DataColorThemeModel,
FilterLogicalOperator,
GroupType,
OrganizationInviteType,
@@ -276,3 +277,28 @@ export const MOCK_DEFAULT_PLUGIN_CONFIG: PluginConfigWithPluginInfo = {
created_at: '2020-12-01T14:00:00.000Z',
plugin_info: MOCK_DEFAULT_PLUGIN,
}
+
+export const MOCK_DATA_COLOR_THEMES: DataColorThemeModel[] = [
+ {
+ id: 1,
+ name: 'Default Theme',
+ colors: [
+ '#1d4aff',
+ '#621da6',
+ '#42827e',
+ '#ce0e74',
+ '#f14f58',
+ '#7c440e',
+ '#529a0a',
+ '#0476fb',
+ '#fe729e',
+ '#35416b',
+ '#41cbc4',
+ '#b64b02',
+ '#e4a604',
+ '#a56eff',
+ '#30d5c8',
+ ],
+ is_global: true,
+ },
+]
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 37d394a7fa483..14671b7a808e1 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -37,6 +37,7 @@ import {
DashboardTemplateListParams,
DashboardTemplateType,
DashboardType,
+ DataColorThemeModel,
DataWarehouseSavedQuery,
DataWarehouseTable,
DataWarehouseViewLink,
@@ -60,6 +61,7 @@ import {
GroupListParams,
HogFunctionIconResponse,
HogFunctionStatus,
+ HogFunctionSubTemplateIdType,
HogFunctionTemplateType,
HogFunctionType,
HogFunctionTypeType,
@@ -845,9 +847,9 @@ class ApiRequest {
return apiRequest
}
- // Chat
- public chat(teamId?: TeamType['id']): ApiRequest {
- return this.environmentsDetail(teamId).addPathComponent('query').addPathComponent('chat')
+ // Conversations
+ public conversations(teamId?: TeamType['id']): ApiRequest {
+ return this.environmentsDetail(teamId).addPathComponent('conversations')
}
// Notebooks
@@ -943,6 +945,15 @@ class ApiRequest {
public async delete(): Promise {
return await api.delete(this.assembleFullUrl())
}
+
+ // Data color themes
+ public dataColorThemes(teamId?: TeamType['id']): ApiRequest {
+ return this.environmentsDetail(teamId).addPathComponent('data_color_themes')
+ }
+
+ public dataColorTheme(id: DataColorThemeModel['id'], teamId?: TeamType['id']): ApiRequest {
+ return this.environmentsDetail(teamId).addPathComponent('data_color_themes').addPathComponent(id)
+ }
}
const normalizeUrl = (url: string): string => {
@@ -1824,13 +1835,15 @@ const api = {
): Promise {
return await new ApiRequest().hogFunction(id).withAction('metrics/totals').withQueryString(params).get()
},
- async listTemplates(
- type?: HogFunctionTypeType | HogFunctionTypeType[]
- ): Promise> {
- return new ApiRequest()
- .hogFunctionTemplates()
- .withQueryString(Array.isArray(type) ? { types: type.join(',') } : { type: type ?? 'destination' })
- .get()
+ async listTemplates(params: {
+ types: HogFunctionTypeType[]
+ sub_template_id?: HogFunctionSubTemplateIdType
+ }): Promise> {
+ const finalParams = {
+ ...params,
+ types: params.types.join(','),
+ }
+ return new ApiRequest().hogFunctionTemplates().withQueryString(finalParams).get()
},
async getTemplate(id: HogFunctionTemplateType['id']): Promise {
return await new ApiRequest().hogFunctionTemplate(id).get()
@@ -2517,6 +2530,18 @@ const api = {
},
},
+ dataColorThemes: {
+ async list(): Promise {
+ return await new ApiRequest().dataColorThemes().get()
+ },
+ async create(data: Partial): Promise {
+ return await new ApiRequest().dataColorThemes().create({ data })
+ },
+ async update(id: DataColorThemeModel['id'], data: Partial): Promise {
+ return await new ApiRequest().dataColorTheme(id).update({ data })
+ },
+ },
+
queryURL: (): string => {
return new ApiRequest().query().assembleFullUrl(true)
},
@@ -2547,12 +2572,10 @@ const api = {
})
},
- chatURL: (): string => {
- return new ApiRequest().chat().assembleFullUrl()
- },
-
- async chat(data: any): Promise {
- return await api.createResponse(this.chatURL(), data)
+ conversations: {
+ async create(data: { content: string; conversation?: string | null }): Promise {
+ return api.createResponse(new ApiRequest().conversations().assembleFullUrl(), data)
+ },
},
/** Fetch data from specified URL. The result already is JSON-parsed. */
diff --git a/frontend/src/lib/colors.ts b/frontend/src/lib/colors.ts
index 9646b6dbc953d..6965ba1342cda 100644
--- a/frontend/src/lib/colors.ts
+++ b/frontend/src/lib/colors.ts
@@ -4,39 +4,49 @@ import { LifecycleToggle } from '~/types'
import { LemonTagType } from './lemon-ui/LemonTag'
-/** --brand-blue in HSL for saturation mixing */
-export const BRAND_BLUE_HSL: [number, number, number] = [228, 100, 56]
-export const PURPLE: [number, number, number] = [260, 88, 71]
+/*
+ * Data colors.
+ */
-/* Insight series colors. */
+/** CSS variable names for the default posthog theme data colors. */
const dataColorVars = [
- 'color-1',
- 'color-2',
- 'color-3',
- 'color-4',
- 'color-5',
- 'color-6',
- 'color-7',
- 'color-8',
- 'color-9',
- 'color-10',
- 'color-11',
- 'color-12',
- 'color-13',
- 'color-14',
- 'color-15',
-]
+ 'data-color-1',
+ 'data-color-2',
+ 'data-color-3',
+ 'data-color-4',
+ 'data-color-5',
+ 'data-color-6',
+ 'data-color-7',
+ 'data-color-8',
+ 'data-color-9',
+ 'data-color-10',
+ 'data-color-11',
+ 'data-color-12',
+ 'data-color-13',
+ 'data-color-14',
+ 'data-color-15',
+] as const
-export const tagColors: LemonTagType[] = [
- 'primary',
- 'highlight',
- 'warning',
- 'danger',
- 'success',
- 'completion',
- 'caution',
- 'option',
-]
+export type DataColorToken =
+ | 'preset-1'
+ | 'preset-2'
+ | 'preset-3'
+ | 'preset-4'
+ | 'preset-5'
+ | 'preset-6'
+ | 'preset-7'
+ | 'preset-8'
+ | 'preset-9'
+ | 'preset-10'
+ | 'preset-11'
+ | 'preset-12'
+ | 'preset-13'
+ | 'preset-14'
+ | 'preset-15'
+
+export type DataColorTheme = Partial> & {
+ [key: `preset-${number}`]: string
+}
export function getColorVar(variable: string): string {
const colorValue = getComputedStyle(document.body).getPropertyValue('--' + variable)
@@ -48,6 +58,10 @@ export function getColorVar(variable: string): string {
return colorValue.trim()
}
+export function getDataThemeColor(theme: DataColorTheme, color: DataColorToken): string {
+ return theme[color] as string
+}
+
/** Returns the color for the given series index.
*
* The returned colors are in hex format for compatibility with Chart.js. They repeat
@@ -57,12 +71,12 @@ export function getColorVar(variable: string): string {
*/
export function getSeriesColor(index: number = 0): string {
const adjustedIndex = index % dataColorVars.length
- return getColorVar(`data-${dataColorVars[adjustedIndex]}`)
+ return getColorVar(dataColorVars[adjustedIndex])
}
/** Returns all color options for series */
export function getSeriesColorPalette(): string[] {
- return dataColorVars.map((colorVar) => getColorVar(`data-${colorVar}`))
+ return dataColorVars.map((colorVar) => getColorVar(colorVar))
}
/** Return the background color for the given series index. */
@@ -104,18 +118,17 @@ export function getGraphColors(isDarkModeOn: boolean): Record & {
id?: AlertType['id']
created_by?: AlertType['created_by'] | null
insight?: QueryBasedInsightModel['id']
}
+export function canCheckOngoingInterval(alert?: AlertType | AlertFormType): boolean {
+ return (
+ (alert?.condition.type === AlertConditionType.ABSOLUTE_VALUE ||
+ alert?.condition.type === AlertConditionType.RELATIVE_INCREASE) &&
+ alert?.threshold.configuration.bounds?.upper != null &&
+ !isNaN(alert?.threshold.configuration.bounds.upper)
+ )
+}
+
export interface AlertFormLogicProps {
alert: AlertType | null
insightId: QueryBasedInsightModel['id']
@@ -48,6 +66,7 @@ export const alertFormLogic = kea([
config: {
type: 'TrendsAlertConfig',
series_index: 0,
+ check_ongoing_interval: false,
},
threshold: { configuration: { type: InsightThresholdType.ABSOLUTE, bounds: {} } },
condition: {
@@ -56,6 +75,7 @@ export const alertFormLogic = kea([
subscribed_users: [],
checks: [],
calculation_interval: AlertCalculationInterval.DAILY,
+ skip_weekend: false,
insight: props.insightId,
} as AlertFormType),
errors: ({ name }) => ({
@@ -66,6 +86,16 @@ export const alertFormLogic = kea([
...alert,
subscribed_users: alert.subscribed_users?.map(({ id }) => id),
insight: props.insightId,
+ // can only skip weekends for hourly/daily alerts
+ skip_weekend:
+ (alert.calculation_interval === AlertCalculationInterval.DAILY ||
+ alert.calculation_interval === AlertCalculationInterval.HOURLY) &&
+ alert.skip_weekend,
+ // can only check ongoing interval for absolute value/increase alerts with upper threshold
+ config: {
+ ...alert.config,
+ check_ongoing_interval: canCheckOngoingInterval(alert) && alert.config.check_ongoing_interval,
+ },
}
// absolute value alert can only have absolute threshold
diff --git a/frontend/src/lib/components/Alerts/types.ts b/frontend/src/lib/components/Alerts/types.ts
index 4641d7fe0728f..8e0787ea1ece0 100644
--- a/frontend/src/lib/components/Alerts/types.ts
+++ b/frontend/src/lib/components/Alerts/types.ts
@@ -16,6 +16,7 @@ export interface AlertTypeBase {
enabled: boolean
insight: QueryBasedInsightModel
config: AlertConfig
+ skip_weekend?: boolean
}
export interface AlertTypeWrite extends Omit {
diff --git a/frontend/src/lib/components/Alerts/views/EditAlertModal.tsx b/frontend/src/lib/components/Alerts/views/EditAlertModal.tsx
index ae12fccdab37f..66120e26fe3da 100644
--- a/frontend/src/lib/components/Alerts/views/EditAlertModal.tsx
+++ b/frontend/src/lib/components/Alerts/views/EditAlertModal.tsx
@@ -1,3 +1,4 @@
+import { IconInfo } from '@posthog/icons'
import {
LemonBanner,
LemonCheckbox,
@@ -5,6 +6,7 @@ import {
LemonSegmentedButton,
LemonSelect,
SpinnerOverlay,
+ Tooltip,
} from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Form, Group } from 'kea-forms'
@@ -18,12 +20,13 @@ import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonModal } from 'lib/lemon-ui/LemonModal'
import { alphabet, formatDate } from 'lib/utils'
+import { useCallback } from 'react'
import { trendsDataLogic } from 'scenes/trends/trendsDataLogic'
import { AlertCalculationInterval, AlertConditionType, AlertState, InsightThresholdType } from '~/queries/schema'
import { InsightShortId, QueryBasedInsightModel } from '~/types'
-import { alertFormLogic } from '../alertFormLogic'
+import { alertFormLogic, canCheckOngoingInterval } from '../alertFormLogic'
import { alertLogic } from '../alertLogic'
import { SnoozeButton } from '../SnoozeButton'
import { AlertType } from '../types'
@@ -85,9 +88,17 @@ export function EditAlertModal({
onClose,
onEditSuccess,
}: EditAlertModalProps): JSX.Element {
- const { alert, alertLoading } = useValues(alertLogic({ alertId }))
+ const _alertLogic = alertLogic({ alertId })
+ const { alert, alertLoading } = useValues(_alertLogic)
+ const { loadAlert } = useActions(_alertLogic)
- const formLogicProps = { alert, insightId, onEditSuccess }
+ // need to reload edited alert as well
+ const _onEditSuccess = useCallback(() => {
+ loadAlert()
+ onEditSuccess()
+ }, [loadAlert, onEditSuccess])
+
+ const formLogicProps = { alert, insightId, onEditSuccess: _onEditSuccess }
const formLogic = alertFormLogic(formLogicProps)
const { alertForm, isAlertFormSubmitting, alertFormChanged } = useValues(formLogic)
const { deleteAlert, snoozeAlert, clearSnooze } = useActions(formLogic)
@@ -97,6 +108,8 @@ export function EditAlertModal({
const { alertSeries, isNonTimeSeriesDisplay, isBreakdownValid, formula } = useValues(trendsLogic)
const creatingNewAlert = alertForm.id === undefined
+ // can only check ongoing interval for absolute value/increase alerts with upper threshold
+ const can_check_ongoing_interval = canCheckOngoingInterval(alertForm)
return (
@@ -160,14 +173,18 @@ export function EditAlertModal({
({
- label: isBreakdownValid
- ? 'any breakdown value'
- : formula
- ? `Formula (${formula})`
- : `${alphabet[index]} - ${event}`,
- value: isBreakdownValid || formula ? 0 : index,
- }))}
+ options={alertSeries?.map(
+ ({ custom_name, name, event }, index) => ({
+ label: isBreakdownValid
+ ? 'any breakdown value'
+ : formula
+ ? `Formula (${formula})`
+ : `${alphabet[index]} - ${
+ custom_name ?? name ?? event
+ }`,
+ value: isBreakdownValid || formula ? 0 : index,
+ })
+ )}
disabledReason={
(isBreakdownValid &&
`For trends with breakdown, the alert will fire if any of the breakdown
@@ -320,7 +337,55 @@ export function EditAlertModal({
+
+
+
Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{alert && }
diff --git a/frontend/src/lib/components/Alerts/views/ManageAlertsModal.tsx b/frontend/src/lib/components/Alerts/views/ManageAlertsModal.tsx
index e46b7bb83a0fe..16df9ee7dc988 100644
--- a/frontend/src/lib/components/Alerts/views/ManageAlertsModal.tsx
+++ b/frontend/src/lib/components/Alerts/views/ManageAlertsModal.tsx
@@ -45,10 +45,10 @@ export function AlertListItem({ alert, onClick }: AlertListItemProps): JSX.Eleme
{alert.enabled ? (
- {bounds?.lower !== undefined &&
+ {bounds?.lower != null &&
`Low ${isPercentage ? bounds.lower * 100 : bounds.lower}${isPercentage ? '%' : ''}`}
- {bounds?.lower !== undefined && bounds?.upper ? ' Ā· ' : ''}
- {bounds?.upper !== undefined &&
+ {bounds?.lower != null && bounds?.upper != null ? ' Ā· ' : ''}
+ {bounds?.upper != null &&
`High ${isPercentage ? bounds.upper * 100 : bounds.upper}${isPercentage ? '%' : ''}`}
) : (
diff --git a/frontend/src/lib/components/CommandBar/CommandBar.tsx b/frontend/src/lib/components/CommandBar/CommandBar.tsx
index fe4b9c2e4555e..5d25486df6862 100644
--- a/frontend/src/lib/components/CommandBar/CommandBar.tsx
+++ b/frontend/src/lib/components/CommandBar/CommandBar.tsx
@@ -26,7 +26,7 @@ const CommandBarOverlay = forwardRef(fun
data-attr="command-bar"
className={`w-full ${
barStatus === BarStatus.SHOW_SEARCH && 'h-full'
- } bg-bg-3000 rounded overflow-hidden border border-border-bold`}
+ } w-full bg-bg-3000 rounded overflow-hidden border border-border-bold`}
ref={ref}
>
{children}
diff --git a/frontend/src/lib/components/CommandBar/SearchBar.tsx b/frontend/src/lib/components/CommandBar/SearchBar.tsx
index 3eba8e50e2aad..d9b27c7e806db 100644
--- a/frontend/src/lib/components/CommandBar/SearchBar.tsx
+++ b/frontend/src/lib/components/CommandBar/SearchBar.tsx
@@ -13,9 +13,10 @@ export const SearchBar = (): JSX.Element => {
const inputRef = useRef(null)
return (
-
+
-
+ {/* 49px = height of search input, 40rem = height of search results */}
+
diff --git a/frontend/src/lib/components/CommandBar/SearchResult.tsx b/frontend/src/lib/components/CommandBar/SearchResult.tsx
index 354470759518e..6bb04fce0693c 100644
--- a/frontend/src/lib/components/CommandBar/SearchResult.tsx
+++ b/frontend/src/lib/components/CommandBar/SearchResult.tsx
@@ -1,6 +1,8 @@
import { LemonSkeleton } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
+import { TAILWIND_BREAKPOINTS } from 'lib/constants'
+import { useWindowSize } from 'lib/hooks/useWindowSize'
import { capitalizeFirstLetter } from 'lib/utils'
import { useLayoutEffect, useRef } from 'react'
import { useSummarizeInsight } from 'scenes/insights/summarizeInsight'
@@ -22,10 +24,12 @@ type SearchResultProps = {
export const SearchResult = ({ result, resultIndex, focused }: SearchResultProps): JSX.Element => {
const { aggregationLabel } = useValues(searchBarLogic)
- const { openResult } = useActions(searchBarLogic)
+ const { setActiveResultIndex, openResult } = useActions(searchBarLogic)
const ref = useRef
(null)
+ const { width } = useWindowSize()
+
useLayoutEffect(() => {
if (focused) {
// :HACKY: This uses the non-standard scrollIntoViewIfNeeded api
@@ -40,27 +44,33 @@ export const SearchResult = ({ result, resultIndex, focused }: SearchResultProps
}, [focused])
return (
- {
- openResult(resultIndex)
- }}
- ref={ref}
- >
-
-
- {result.type !== 'group'
- ? tabToName[result.type]
- : `${capitalizeFirstLetter(aggregationLabel(result.extra_fields.group_type_index).plural)}`}
-
-
-
-
+ <>
+
{
+ if (width && width <= TAILWIND_BREAKPOINTS.md) {
+ openResult(resultIndex)
+ } else {
+ setActiveResultIndex(resultIndex)
+ }
+ }}
+ ref={ref}
+ >
+
+
+ {result.type !== 'group'
+ ? tabToName[result.type]
+ : `${capitalizeFirstLetter(aggregationLabel(result.extra_fields.group_type_index).plural)}`}
+
+
+
+
+
-
+ >
)
}
diff --git a/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx b/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx
index 498150ebada3a..7fe2a6313bfdd 100644
--- a/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx
+++ b/frontend/src/lib/components/CommandBar/SearchResultPreview.tsx
@@ -1,11 +1,15 @@
-import { useValues } from 'kea'
+import { useActions, useValues } from 'kea'
import { ResultDescription, ResultName } from 'lib/components/CommandBar/SearchResult'
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+
+import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut'
import { tabToName } from './constants'
import { searchBarLogic, urlForResult } from './searchBarLogic'
export const SearchResultPreview = (): JSX.Element | null => {
const { activeResultIndex, combinedSearchResults } = useValues(searchBarLogic)
+ const { openResult } = useActions(searchBarLogic)
if (!combinedSearchResults || combinedSearchResults.length === 0) {
return null
@@ -14,17 +18,33 @@ export const SearchResultPreview = (): JSX.Element | null => {
const result = combinedSearchResults[activeResultIndex]
return (
-
-
{tabToName[result.type]}
-
-
-
-
- {location.host}
- {urlForResult(result)}
-
-
-
+
+
+
+
{tabToName[result.type as keyof typeof tabToName]}
+
+
+
+
+ {location.host}
+ {urlForResult(result)}
+
+
+
+
+
+
+ {
+ openResult(activeResultIndex)
+ }}
+ aria-label="Open search result"
+ >
+ Open
+
+
)
diff --git a/frontend/src/lib/components/CommandBar/SearchResults.tsx b/frontend/src/lib/components/CommandBar/SearchResults.tsx
index 2dde6f78cbead..3e6abbc35a27d 100644
--- a/frontend/src/lib/components/CommandBar/SearchResults.tsx
+++ b/frontend/src/lib/components/CommandBar/SearchResults.tsx
@@ -1,6 +1,4 @@
-import clsx from 'clsx'
import { useValues } from 'kea'
-import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
import { DetectiveHog } from '../hedgehogs'
import { searchBarLogic } from './searchBarLogic'
@@ -10,27 +8,17 @@ import { SearchResultPreview } from './SearchResultPreview'
export const SearchResults = (): JSX.Element => {
const { combinedSearchResults, combinedSearchLoading, activeResultIndex } = useValues(searchBarLogic)
- const { ref, size } = useResizeBreakpoints({
- 0: 'small',
- 550: 'normal',
- })
-
return (
-
+ <>
{!combinedSearchLoading && combinedSearchResults?.length === 0 ? (
-
+
No results
This doesn't happen often, but we're stumped!
) : (
-
-
+
+
{combinedSearchLoading && (
<>
@@ -48,13 +36,11 @@ export const SearchResults = (): JSX.Element => {
/>
))}
- {size !== 'small' ? (
-
-
-
- ) : null}
+
+
+
)}
-
+ >
)
}
diff --git a/frontend/src/lib/components/CommandBar/SearchTabs.tsx b/frontend/src/lib/components/CommandBar/SearchTabs.tsx
index 37ff41ff30a53..aa3ddb67e8496 100644
--- a/frontend/src/lib/components/CommandBar/SearchTabs.tsx
+++ b/frontend/src/lib/components/CommandBar/SearchTabs.tsx
@@ -12,11 +12,13 @@ type SearchTabsProps = {
export const SearchTabs = ({ inputRef }: SearchTabsProps): JSX.Element | null => {
const { tabsGrouped } = useValues(searchBarLogic)
return (
-
+
{Object.entries(tabsGrouped).map(([group, tabs]) => (
{group !== 'all' && (
-
{groupToName[group]}
+
+ {groupToName[group as keyof typeof groupToName]}
+
)}
{tabs.map((tab) => (
diff --git a/frontend/src/lib/components/CommandBar/index.scss b/frontend/src/lib/components/CommandBar/index.scss
index 02aa24cb7a11d..3150d46ed5ada 100644
--- a/frontend/src/lib/components/CommandBar/index.scss
+++ b/frontend/src/lib/components/CommandBar/index.scss
@@ -16,11 +16,6 @@
}
}
-.SearchResults {
- // offset container height by input
- height: calc(100% - 2.875rem);
-}
-
.CommandBar__overlay {
position: fixed;
top: 0;
diff --git a/frontend/src/lib/components/CommandBar/searchBarLogic.ts b/frontend/src/lib/components/CommandBar/searchBarLogic.ts
index b3576ac482d10..1b96c64c34b81 100644
--- a/frontend/src/lib/components/CommandBar/searchBarLogic.ts
+++ b/frontend/src/lib/components/CommandBar/searchBarLogic.ts
@@ -61,6 +61,7 @@ export const searchBarLogic = kea
([
onArrowUp: (activeIndex: number, maxIndex: number) => ({ activeIndex, maxIndex }),
onArrowDown: (activeIndex: number, maxIndex: number) => ({ activeIndex, maxIndex }),
openResult: (index: number) => ({ index }),
+ setActiveResultIndex: (index: number) => ({ index }),
}),
loaders(({ values, actions }) => ({
rawSearchResponse: [
@@ -208,6 +209,7 @@ export const searchBarLogic = kea([
openResult: () => 0,
onArrowUp: (_, { activeIndex, maxIndex }) => (activeIndex > 0 ? activeIndex - 1 : maxIndex),
onArrowDown: (_, { activeIndex, maxIndex }) => (activeIndex < maxIndex ? activeIndex + 1 : 0),
+ setActiveResultIndex: (_, { index }) => index,
},
],
activeTab: [
diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx
index 70b61d4841019..b77c601a43154 100644
--- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx
+++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx
@@ -775,7 +775,7 @@ export class HedgehogActor {
ref?.(r)
}
}}
- className="HedgehogBuddy cursor-pointer m-0"
+ className="m-0 cursor-pointer HedgehogBuddy"
data-content={preloadContent}
onTouchStart={this.static ? undefined : () => onTouchOrMouseStart()}
onMouseDown={this.static ? undefined : () => onTouchOrMouseStart()}
@@ -835,10 +835,15 @@ export class HedgehogActor {
{this.accessories().map((accessory, index) => (
+
-
+
Good bye!
@@ -1032,7 +1039,7 @@ export function MyHedgehogBuddy({
hedgehogConfig={hedgehogConfig}
tooltip={
hedgehogConfig.party_mode_enabled ? (
-
+
) : undefined
@@ -1076,7 +1083,7 @@ export function MemberHedgehogBuddy({ member }: { member: OrganizationMemberType
-
+
+
}
diff --git a/frontend/src/lib/components/HogQLEditor/HogQLEditor.tsx b/frontend/src/lib/components/HogQLEditor/HogQLEditor.tsx
index 9d0cf1f42b893..95b809878a98d 100644
--- a/frontend/src/lib/components/HogQLEditor/HogQLEditor.tsx
+++ b/frontend/src/lib/components/HogQLEditor/HogQLEditor.tsx
@@ -58,7 +58,7 @@ export function HogQLEditor({
{placeholder ??
(metadataSource && isActorsQuery(metadataSource)
? "Enter HogQL expression, such as:\n- properties.$geoip_country_name\n- toInt(properties.$browser_version) * 10\n- concat(properties.name, ' <', properties.email, '>')\n- is_identified ? 'user' : 'anon'"
- : "Enter HogQL Expression, such as:\n- properties.$current_url\n- person.properties.$geoip_country_name\n- toInt(properties.`Long Field Name`) * 10\n- concat(event, ' ', distinct_id)\n- if(1 < 2, 'small', 'large')")}
+ : "Enter HogQL Expression, such as:\n- properties.$current_url\n- person.properties.$geoip_country_name\n- pdi.person.properties.email\n- toInt(properties.`Long Field Name`) * 10\n- concat(event, ' ', distinct_id)\n- if(1 < 2, 'small', 'large')")}
{indexedResults &&
- indexedResults.map((item, index) => (
-
- ))}
+ indexedResults.map((item, index) => )}
) : null
diff --git a/frontend/src/lib/components/InsightLegend/InsightLegendRow.tsx b/frontend/src/lib/components/InsightLegend/InsightLegendRow.tsx
index 692125aef9267..09ba6a58507e5 100644
--- a/frontend/src/lib/components/InsightLegend/InsightLegendRow.tsx
+++ b/frontend/src/lib/components/InsightLegend/InsightLegendRow.tsx
@@ -1,5 +1,5 @@
import { useActions, useValues } from 'kea'
-import { getSeriesBackgroundColor, getTrendLikeSeriesColor } from 'lib/colors'
+import { getSeriesBackgroundColor } from 'lib/colors'
import { InsightLabel } from 'lib/components/InsightLabel'
import { LemonCheckbox } from 'lib/lemon-ui/LemonCheckbox'
import { useEffect, useRef } from 'react'
@@ -19,15 +19,14 @@ import { shouldHighlightThisRow } from './utils'
type InsightLegendRowProps = {
rowIndex: number
item: IndexedTrendResult
- totalItems: number
}
-export function InsightLegendRow({ rowIndex, item, totalItems }: InsightLegendRowProps): JSX.Element {
+export function InsightLegendRow({ rowIndex, item }: InsightLegendRowProps): JSX.Element {
const { cohorts } = useValues(cohortsModel)
const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
const { insightProps, highlightedSeries } = useValues(insightLogic)
- const { display, trendsFilter, breakdownFilter, isSingleSeries, hiddenLegendIndexes } = useValues(
+ const { display, trendsFilter, breakdownFilter, isSingleSeries, hiddenLegendIndexes, getTrendsColor } = useValues(
trendsDataLogic(insightProps)
)
const { toggleHiddenLegendIndex } = useActions(trendsDataLogic(insightProps))
@@ -54,21 +53,23 @@ export function InsightLegendRow({ rowIndex, item, totalItems }: InsightLegendRo
)
const isPrevious = !!item.compare && item.compare_label === 'previous'
- const adjustedIndex = isPrevious ? item.seriesIndex - totalItems / 2 : item.seriesIndex
+
+ const themeColor = getTrendsColor(item)
+ const mainColor = isPrevious ? `${themeColor}80` : themeColor
return (
toggleHiddenLegendIndex(rowIndex)}
fullWidth
label={
([
path(['lib', 'components', 'metalytics', 'metalyticsLogic']),
connect({
- values: [activityForSceneLogic, ['sceneActivityFilters'], membersLogic, ['members']],
+ values: [sidePanelContextLogic, ['sceneSidePanelContext'], membersLogic, ['members']],
}),
loaders(({ values }) => ({
@@ -62,11 +63,16 @@ export const metalyticsLogic = kea([
selectors({
instanceId: [
- (s) => [s.sceneActivityFilters],
- (sceneActivityFilters) =>
- sceneActivityFilters?.item_id ? `${sceneActivityFilters.scope}:${sceneActivityFilters.item_id}` : null,
+ (s) => [s.sceneSidePanelContext],
+ (sidePanelContext: SidePanelSceneContext) =>
+ sidePanelContext?.activity_item_id
+ ? `${sidePanelContext.activity_scope}:${sidePanelContext.activity_item_id}`
+ : null,
+ ],
+ scope: [
+ (s) => [s.sceneSidePanelContext],
+ (sidePanelContext: SidePanelSceneContext) => sidePanelContext?.activity_scope,
],
- scope: [(s) => [s.sceneActivityFilters], (sceneActivityFilters) => sceneActivityFilters?.scope],
recentUserMembers: [
(s) => [s.recentUsers, s.members],
diff --git a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx
index 07060bcd8698d..14e4f2e77c361 100644
--- a/frontend/src/lib/components/PayGateMini/PayGateMini.tsx
+++ b/frontend/src/lib/components/PayGateMini/PayGateMini.tsx
@@ -1,4 +1,4 @@
-import { IconInfo, IconOpenSidebar } from '@posthog/icons'
+import { IconInfo, IconOpenSidebar, IconUnlock } from '@posthog/icons'
import { LemonButton, Link, Tooltip } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
@@ -7,6 +7,7 @@ import { useEffect } from 'react'
import { billingLogic } from 'scenes/billing/billingLogic'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { getProductIcon } from 'scenes/products/Products'
+import { userLogic } from 'scenes/userLogic'
import { AvailableFeature, BillingFeatureType, BillingProductV2AddonType, BillingProductV2Type } from '~/types'
@@ -41,10 +42,14 @@ export function PayGateMini({
isGrandfathered,
docsLink,
}: PayGateMiniProps): JSX.Element | null {
- const { productWithFeature, featureInfo, gateVariant } = useValues(payGateMiniLogic({ feature, currentUsage }))
+ const { productWithFeature, featureInfo, gateVariant, bypassPaywall } = useValues(
+ payGateMiniLogic({ feature, currentUsage })
+ )
+ const { setBypassPaywall } = useActions(payGateMiniLogic({ feature, currentUsage }))
const { preflight, isCloudOrDev } = useValues(preflightLogic)
const { billingLoading } = useValues(billingLogic)
const { hideUpgradeModal } = useActions(upgradeModalLogic)
+ const { user } = useValues(userLogic)
useEffect(() => {
if (gateVariant) {
@@ -73,7 +78,7 @@ export function PayGateMini({
return null // Don't show anything if paid features are explicitly disabled
}
- if (gateVariant && productWithFeature && featureInfo && !overrideShouldShowGate) {
+ if (gateVariant && productWithFeature && featureInfo && !overrideShouldShowGate && !bypassPaywall) {
return (
)}
+
+ {user?.is_impersonated && (
+ }
+ tooltip="Bypass this paywall - (UI only)"
+ onClick={() => setBypassPaywall(true)}
+ >
+ Bypass paywall
+
+ )}
)
@@ -142,7 +158,7 @@ function PayGateContent({
'PayGateMini rounded flex flex-col items-center p-4 text-center'
)}
>
-
+
{getProductIcon(productWithFeature.name, featureInfo.icon_key)}
{featureInfo.name}
@@ -184,7 +200,7 @@ const renderUsageLimitMessage = (
.
-
+
Your current plan limit: {' '}
{featureAvailableOnOrg.limit} {featureAvailableOnOrg.unit}
@@ -198,7 +214,7 @@ const renderUsageLimitMessage = (
{featureInfoOnNextPlan?.limit} projects .
)}
-
+
Need unlimited projects? Check out the{' '}
Teams addon
@@ -244,9 +260,9 @@ const renderGateVariantMessage = (
const GrandfatheredMessage = (): JSX.Element => {
return (
-
-
-
+
+
+
Your plan does not include this feature, but previously set settings may remain. Please upgrade your
plan to regain access.
diff --git a/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx b/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx
index ba15f163fbbf6..6c86949164815 100644
--- a/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx
+++ b/frontend/src/lib/components/PayGateMini/payGateMiniLogic.tsx
@@ -1,4 +1,4 @@
-import { actions, connect, kea, key, path, props, selectors } from 'kea'
+import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea'
import { billingLogic } from 'scenes/billing/billingLogic'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { userLogic } from 'scenes/userLogic'
@@ -29,8 +29,17 @@ export const payGateMiniLogic = kea
([
],
actions: [],
})),
+ reducers({
+ bypassPaywall: [
+ false,
+ {
+ setBypassPaywall: (_, { bypassPaywall }) => bypassPaywall,
+ },
+ ],
+ }),
actions({
setGateVariant: (gateVariant: GateVariantType) => ({ gateVariant }),
+ setBypassPaywall: (bypassPaywall: boolean) => ({ bypassPaywall }),
}),
selectors(({ values, props }) => ({
productWithFeature: [
diff --git a/frontend/src/lib/components/RestrictedArea.tsx b/frontend/src/lib/components/RestrictedArea.tsx
index ade847740c42a..852d1606bb0d0 100644
--- a/frontend/src/lib/components/RestrictedArea.tsx
+++ b/frontend/src/lib/components/RestrictedArea.tsx
@@ -27,7 +27,10 @@ export interface RestrictedAreaProps extends UseRestrictedAreaProps {
Component: (props: RestrictedComponentProps) => JSX.Element
}
-export function useRestrictedArea({ scope, minimumAccessLevel }: UseRestrictedAreaProps): null | string {
+export function useRestrictedArea({
+ scope = RestrictionScope.Organization,
+ minimumAccessLevel,
+}: UseRestrictedAreaProps): null | string {
const { currentOrganization } = useValues(organizationLogic)
const { currentTeam } = useValues(teamLogic)
diff --git a/frontend/src/lib/components/SeriesGlyph.tsx b/frontend/src/lib/components/SeriesGlyph.tsx
index 1ebc8d30b3b1d..81ac6d3aec6df 100644
--- a/frontend/src/lib/components/SeriesGlyph.tsx
+++ b/frontend/src/lib/components/SeriesGlyph.tsx
@@ -1,12 +1,13 @@
import { useValues } from 'kea'
import { getSeriesColor } from 'lib/colors'
import { alphabet, hexToRGBA, lightenDarkenColor, RGBToRGBA } from 'lib/utils'
+import { useEffect, useState } from 'react'
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
interface SeriesGlyphProps {
className?: string
- children: React.ReactNode
+ children?: React.ReactNode
style?: React.CSSProperties
variant?: 'funnel-step-glyph' // Built-in styling defaults
}
@@ -20,6 +21,37 @@ export function SeriesGlyph({ className, style, children, variant }: SeriesGlyph
)
}
+type ColorGlyphProps = {
+ color?: string | null
+} & SeriesGlyphProps
+
+export function ColorGlyph({ color, ...rest }: ColorGlyphProps): JSX.Element {
+ const { isDarkModeOn } = useValues(themeLogic)
+
+ const [lastValidColor, setLastValidColor] = useState('#000000')
+
+ useEffect(() => {
+ // allow only 6-digit hex colors
+ // other color formats are not supported everywhere e.g. insight visualizations
+ if (color != null && /^#[0-9A-Fa-f]{6}$/.test(color)) {
+ setLastValidColor(color)
+ }
+ }, [color])
+
+ return (
+
+ )
+}
+
interface SeriesLetterProps {
className?: string
hasBreakdown: boolean
diff --git a/frontend/src/lib/components/Sharing/SharingModal.tsx b/frontend/src/lib/components/Sharing/SharingModal.tsx
index 7348b1bc56610..7ef76f6fc54d6 100644
--- a/frontend/src/lib/components/Sharing/SharingModal.tsx
+++ b/frontend/src/lib/components/Sharing/SharingModal.tsx
@@ -92,6 +92,7 @@ export function SharingModalContent({
Something went wrong...
) : (
<>
+ Sharing
{
{ versionCount: 1, expectation: null },
{
versionCount: 11,
+ expectation: null,
+ },
+ {
+ versionCount: 51,
expectation: {
latestUsedVersion: '1.0.0',
- latestAvailableVersion: '1.0.10',
- numVersionsBehind: 10,
- level: 'info',
+ latestAvailableVersion: '1.0.50',
+ numVersionsBehind: 50,
+ level: 'error',
},
},
{
- versionCount: 15,
+ minorUsedVersion: 40,
+ versionCount: 1,
expectation: {
latestUsedVersion: '1.0.0',
- latestAvailableVersion: '1.0.14',
- numVersionsBehind: 14,
- level: 'info',
+ latestAvailableVersion: '1.40.0',
+ numVersionsBehind: 40,
+ level: 'warning',
},
},
{
- versionCount: 25,
+ majorUsedVersion: 2,
+ versionCount: 1,
expectation: {
latestUsedVersion: '1.0.0',
- latestAvailableVersion: '1.0.24',
- numVersionsBehind: 24,
- level: 'error',
+ latestAvailableVersion: '2.0.0',
+ numVersionsBehind: 1,
+ level: 'info',
},
},
])('return a version warning if diff is great enough', async (options) => {
// TODO: How do we clear the persisted value?
const versionsList = Array.from({ length: options.versionCount }, (_, i) => ({
- version: `1.0.${i}`,
+ version: `${options.majorUsedVersion || 1}.${options.minorUsedVersion || 0}.${i}`,
})).reverse()
useMockedVersions(
@@ -143,13 +149,14 @@ describe('versionCheckerLogic', () => {
},
{
usedVersions: [
- { version: '1.80.0', timestamp: '2023-01-01T12:00:00Z' },
- { version: '1.83.1-beta', timestamp: '2023-01-01T10:00:00Z' },
- { version: '1.84.0-delta', timestamp: '2023-01-01T08:00:00Z' },
+ { version: '1.40.0', timestamp: '2023-01-01T12:00:00Z' },
+ { version: '1.41.1-beta', timestamp: '2023-01-01T10:00:00Z' },
+ { version: '1.42.0', timestamp: '2023-01-01T08:00:00Z' },
+ { version: '1.42.0-delta', timestamp: '2023-01-01T08:00:00Z' },
],
expectation: {
- latestUsedVersion: '1.84.0-delta',
- numVersionsBehind: 1,
+ latestUsedVersion: '1.42.0',
+ numVersionsBehind: 42,
latestAvailableVersion: '1.84.0',
level: 'warning',
},
diff --git a/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts b/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts
index 7ffecbbf89c82..4c6067adf4afc 100644
--- a/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts
+++ b/frontend/src/lib/components/VersionChecker/versionCheckerLogic.ts
@@ -174,6 +174,7 @@ export const versionCheckerLogic = kea([
if (!warning && sdkVersions && latestAvailableVersion) {
const diff = diffVersions(latestAvailableVersion, latestUsedVersion)
+
if (diff && diff.diff > 0) {
// there's a difference between the latest used version and the latest available version
@@ -188,18 +189,14 @@ export const versionCheckerLogic = kea([
}
let level: 'warning' | 'info' | 'error' | undefined
- if (diff.kind === 'major' || numVersionsBehind >= 20) {
+ if (diff.kind === 'major') {
+ level = 'info' // it is desirable to be on the latest major version, but not critical
+ } else if (diff.kind === 'minor') {
+ level = numVersionsBehind >= 40 ? 'warning' : undefined
+ }
+
+ if (level === undefined && numVersionsBehind >= 50) {
level = 'error'
- } else if (diff.kind === 'minor' && diff.diff >= 15) {
- level = 'warning'
- } else if ((diff.kind === 'minor' && diff.diff >= 10) || numVersionsBehind >= 10) {
- level = 'info'
- } else if (latestUsedVersion.extra) {
- // if we have an extra (alpha/beta/rc/etc.) version, we should always show a warning if they aren't on the latest
- level = 'warning'
- } else {
- // don't warn for a small number of patch versions behind
- level = undefined
}
// we check if there is a "latest user version string" to avoid returning odd data in unexpected cases
diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index 4f5dab68b9942..d7422e52447e5 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -138,7 +138,6 @@ export const WEBHOOK_SERVICES: Record = {
export const FEATURE_FLAGS = {
// Experiments / beta features
FUNNELS_CUE_OPT_OUT: 'funnels-cue-opt-out-7301', // owner: @neilkakkar
- KAFKA_INSPECTOR: 'kafka-inspector', // owner: @yakkomajuri
HISTORICAL_EXPORTS_V2: 'historical-exports-v2', // owner @macobo
INGESTION_WARNINGS_ENABLED: 'ingestion-warnings-enabled', // owner: @tiina303
SESSION_RESET_ON_LOAD: 'session-reset-on-load', // owner: @benjackwhite
@@ -181,7 +180,6 @@ export const FEATURE_FLAGS = {
SQL_EDITOR: 'sql-editor', // owner: @EDsCODE #team-data-warehouse
SESSION_REPLAY_DOCTOR: 'session-replay-doctor', // owner: #team-replay
SAVED_NOT_PINNED: 'saved-not-pinned', // owner: #team-replay
- NEW_EXPERIMENTS_UI: 'new-experiments-ui', // owner: @jurajmajerik #team-feature-success
AUDIT_LOGS_ACCESS: 'audit-logs-access', // owner: #team-growth
SUBSCRIBE_FROM_PAYGATE: 'subscribe-from-paygate', // owner: #team-growth
HEATMAPS_UI: 'heatmaps-ui', // owner: @benjackwhite
@@ -198,19 +196,14 @@ export const FEATURE_FLAGS = {
SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE: 'settings-bounce-rate-page-view-mode', // owner: @robbie-c
ONBOARDING_DASHBOARD_TEMPLATES: 'onboarding-dashboard-templates', // owner: @raquelmsmith
MULTIPLE_BREAKDOWNS: 'multiple-breakdowns', // owner: @skoob13 #team-product-analytics
- WEB_ANALYTICS_LIVE_USER_COUNT: 'web-analytics-live-user-count', // owner: @robbie-c
SETTINGS_SESSION_TABLE_VERSION: 'settings-session-table-version', // owner: @robbie-c
INSIGHT_FUNNELS_USE_UDF: 'insight-funnels-use-udf', // owner: @aspicer #team-product-analytics
INSIGHT_FUNNELS_USE_UDF_TRENDS: 'insight-funnels-use-udf-trends', // owner: @aspicer #team-product-analytics
FIRST_TIME_FOR_USER_MATH: 'first-time-for-user-math', // owner: @skoob13 #team-product-analytics
MULTITAB_EDITOR: 'multitab-editor', // owner: @EDsCODE #team-data-warehouse
- WEB_ANALYTICS_REPLAY: 'web-analytics-replay', // owner: @robbie-c
BATCH_EXPORTS_POSTHOG_HTTP: 'posthog-http-batch-exports',
EXPERIMENT_MAKE_DECISION: 'experiment-make-decision', // owner: @jurajmajerik #team-feature-success
DATA_MODELING: 'data-modeling', // owner: @EDsCODE #team-data-warehouse
- WEB_ANALYTICS_CONVERSION_GOALS: 'web-analytics-conversion-goals', // owner: @robbie-c
- WEB_ANALYTICS_LAST_CLICK: 'web-analytics-last-click', // owner: @robbie-c
- WEB_ANALYTICS_LCP_SCORE: 'web-analytics-lcp-score', // owner: @robbie-c
HEDGEHOG_SKIN_SPIDERHOG: 'hedgehog-skin-spiderhog', // owner: @benjackwhite
INSIGHT_VARIABLES: 'insight_variables', // owner: @Gilbert09 #team-data-warehouse
WEB_EXPERIMENTS: 'web-experiments', // owner: @team-feature-success
@@ -225,23 +218,25 @@ export const FEATURE_FLAGS = {
BILLING_TRIAL_FLOW: 'billing-trial-flow', // owner: @zach
EDIT_DWH_SOURCE_CONFIG: 'edit_dwh_source_config', // owner: @Gilbert09 #team-data-warehouse
AI_SURVEY_RESPONSE_SUMMARY: 'ai-survey-response-summary', // owner: @pauldambra
- CUSTOM_CHANNEL_TYPE_RULES: 'custom-channel-type-rules', // owner: @robbie-c #team-web-analytics
SELF_SERVE_CREDIT_OVERRIDE: 'self-serve-credit-override', // owner: @zach
FEATURE_MANAGEMENT_UI: 'feature-management-ui', // owner: @haven #team-feature-flags
CUSTOM_CSS_THEMES: 'custom-css-themes', // owner: @daibhin
METALYTICS: 'metalytics', // owner: @surbhi
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
REMOTE_CONFIG: 'remote-config', // owner: @benjackwhite
SITE_DESTINATIONS: 'site-destinations', // owner: @mariusandra #team-cdp
SITE_APP_FUNCTIONS: 'site-app-functions', // owner: @mariusandra #team-cdp
HOG_TRANSFORMATIONS: 'hog-transformations', // owner: #team-cdp
REPLAY_HOGQL_FILTERS: 'replay-hogql-filters', // owner: @pauldambra #team-replay
REPLAY_LIST_RECORDINGS_AS_QUERY: 'replay-list-recordings-as-query', // owner: @pauldambra #team-replay
+ SUPPORT_MESSAGE_OVERRIDE: 'support-message-override', // owner: @abigail
BILLING_SKIP_FORECASTING: 'billing-skip-forecasting', // owner: @zach
EXPERIMENT_STATS_V2: 'experiment-stats-v2', // owner: @danielbachhuber #team-experiments
WEB_ANALYTICS_PERIOD_COMPARISON: 'web-analytics-period-comparison', // owner: @rafaeelaudibert #team-web-analytics
WEB_ANALYTICS_CONVERSION_GOAL_FILTERS: 'web-analytics-conversion-goal-filters', // owner: @rafaeelaudibert #team-web-analytics
+ CDP_ACTIVITY_LOG_NOTIFICATIONS: 'cdp-activity-log-notifications', // owner: #team-cdp
+ COOKIELESS_SERVER_HASH_MODE_SETTING: 'cookieless-server-hash-mode-setting', // owner: @robbie-c #team-web-analytics
+ INSIGHT_COLORS: 'insight-colors', // owner @thmsobrmlr #team-product-analytics
} as const
export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]
@@ -320,3 +315,11 @@ export const SESSION_REPLAY_MINIMUM_DURATION_OPTIONS: LemonSelectOptions = {
'google-pubsub': IconGoogleCloud,
'google-cloud-storage': IconGoogleCloudStorage,
'google-ads': IconGoogleAds,
+ snapchat: IconSnapchat,
}
export const integrationsLogic = kea([
diff --git a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss
index c7b7a7e40f3e1..6cebd0b3a7c8a 100644
--- a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss
+++ b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss
@@ -73,7 +73,6 @@
user-select: none;
background: none;
border-radius: var(--radius);
- outline: none;
transition: var(--lemon-button-transition);
.font-normal,
diff --git a/frontend/src/lib/lemon-ui/LemonButton/More.tsx b/frontend/src/lib/lemon-ui/LemonButton/More.tsx
index 6eff15a377572..d4e61ade7cbf9 100644
--- a/frontend/src/lib/lemon-ui/LemonButton/More.tsx
+++ b/frontend/src/lib/lemon-ui/LemonButton/More.tsx
@@ -2,12 +2,14 @@ import { IconEllipsis } from '@posthog/icons'
import { PopoverProps } from '../Popover/Popover'
import { LemonButtonWithDropdown } from '.'
-import { LemonButtonProps } from './LemonButton'
+import { LemonButtonDropdown, LemonButtonProps } from './LemonButton'
-export type MoreProps = Partial> & LemonButtonProps
+export type MoreProps = Partial> &
+ LemonButtonProps & { dropdown?: Partial }
export function More({
overlay,
+ dropdown,
'data-attr': dataAttr,
placement = 'bottom-end',
...buttonProps
@@ -17,11 +19,14 @@ export function More({
aria-label="more"
data-attr={dataAttr ?? 'more-button'}
icon={ }
- dropdown={{
- placement: placement,
- actionable: true,
- overlay,
- }}
+ dropdown={
+ {
+ placement: placement,
+ actionable: true,
+ ...dropdown,
+ overlay,
+ } as LemonButtonDropdown
+ }
size="small"
{...buttonProps}
disabled={!overlay}
diff --git a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss
index 8aac0fa8754b1..d9d77abcb5119 100644
--- a/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss
+++ b/frontend/src/lib/lemon-ui/LemonCheckbox/LemonCheckbox.scss
@@ -7,6 +7,12 @@
font-weight: 500;
line-height: 1.5rem;
+ &:has(:focus-visible) {
+ .LemonCheckbox__box {
+ outline: -webkit-focus-ring-color auto 1px;
+ }
+ }
+
.LemonCheckbox__input {
width: 0 !important;
height: 0 !important;
diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
index 4f9256e1569c9..9cfa959fc8e9b 100644
--- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
+++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx
@@ -41,7 +41,7 @@ export interface LemonTableProps> {
/** Class to append to each row. */
rowClassName?: string | ((record: T, rowIndex: number) => string | null)
/** Color to mark each row with. */
- rowRibbonColor?: string | ((record: T, rowIndex: number) => string | null)
+ rowRibbonColor?: string | ((record: T, rowIndex: number) => string | null | undefined)
/** Status of each row. Defaults no status. */
rowStatus?: 'highlighted' | ((record: T, rowIndex: number) => 'highlighted' | null)
/** Function that for each row determines what props should its `tr` element have based on the row's record. */
diff --git a/frontend/src/lib/lemon-ui/Link/Link.scss b/frontend/src/lib/lemon-ui/Link/Link.scss
index ebb154f170e1a..a48f6ee182f01 100644
--- a/frontend/src/lib/lemon-ui/Link/Link.scss
+++ b/frontend/src/lib/lemon-ui/Link/Link.scss
@@ -6,7 +6,6 @@
cursor: pointer;
background: none;
border: none;
- outline: none;
transition: none;
&:not(:disabled) {
diff --git a/frontend/src/lib/lemon-ui/colors.stories.tsx b/frontend/src/lib/lemon-ui/colors.stories.tsx
index f2e87c73528af..1a578ea7c0029 100644
--- a/frontend/src/lib/lemon-ui/colors.stories.tsx
+++ b/frontend/src/lib/lemon-ui/colors.stories.tsx
@@ -182,6 +182,24 @@ const threeThousand = [
'primary-alt',
]
+const dataColors = [
+ 'data-color-1',
+ 'data-color-2',
+ 'data-color-3',
+ 'data-color-4',
+ 'data-color-5',
+ 'data-color-6',
+ 'data-color-7',
+ 'data-color-8',
+ 'data-color-9',
+ 'data-color-10',
+ 'data-color-11',
+ 'data-color-12',
+ 'data-color-13',
+ 'data-color-14',
+ 'data-color-15',
+]
+
export function ColorPalette(): JSX.Element {
const [hover, setHover] = useState()
return (
@@ -278,3 +296,53 @@ export function AllThreeThousandColorOptions(): JSX.Element {
/>
)
}
+
+export function DataColors(): JSX.Element {
+ return (
+ ({ name: color, color }))}
+ columns={[
+ {
+ title: 'Class name',
+ key: 'name',
+ dataIndex: 'name',
+ render: function RenderName(name) {
+ return name
+ },
+ },
+ {
+ title: 'Light mode',
+ key: 'light',
+ dataIndex: 'color',
+ render: function RenderColor(color) {
+ return (
+
+ )
+ },
+ },
+ {
+ title: 'Dark mode',
+ key: 'dark',
+ dataIndex: 'color',
+ render: function RenderColor(color) {
+ return (
+
+ )
+ },
+ },
+ ]}
+ />
+ )
+}
diff --git a/frontend/src/lib/monaco/CodeEditor.tsx b/frontend/src/lib/monaco/CodeEditor.tsx
index f824b982d7f63..10dabd94c1bb4 100644
--- a/frontend/src/lib/monaco/CodeEditor.tsx
+++ b/frontend/src/lib/monaco/CodeEditor.tsx
@@ -16,7 +16,7 @@ import * as monaco from 'monaco-editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
-import { AnyDataNode, HogLanguage } from '~/queries/schema'
+import { AnyDataNode, HogLanguage, HogQLMetadataResponse } from '~/queries/schema'
if (loader) {
loader.config({ monaco })
@@ -32,7 +32,7 @@ export interface CodeEditorProps extends Omit
sourceQuery?: AnyDataNode
globals?: Record
schema?: Record | null
-
+ onMetadata?: (metadata: HogQLMetadataResponse) => void
onError?: (error: string | null, isValidView: boolean) => void
}
let codeEditorIndex = 0
@@ -121,6 +121,7 @@ export function CodeEditor({
sourceQuery,
schema,
onError,
+ onMetadata,
...editorProps
}: CodeEditorProps): JSX.Element {
const { isDarkModeOn } = useValues(themeLogic)
@@ -140,6 +141,7 @@ export function CodeEditor({
monaco: monaco,
editor: editor,
onError,
+ onMetadata,
})
useMountedLogic(builtCodeEditorLogic)
diff --git a/frontend/src/lib/monaco/codeEditorLogic.tsx b/frontend/src/lib/monaco/codeEditorLogic.tsx
index 63290fa0012b7..b02e4d38d780a 100644
--- a/frontend/src/lib/monaco/codeEditorLogic.tsx
+++ b/frontend/src/lib/monaco/codeEditorLogic.tsx
@@ -29,7 +29,7 @@ import {
import type { codeEditorLogicType } from './codeEditorLogicType'
export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries`
-export const activemodelStateKey = (key: string | number): string => `${key}/activeModelUri`
+export const activeModelStateKey = (key: string | number): string => `${key}/activeModelUri`
const METADATA_LANGUAGES = [HogLanguage.hog, HogLanguage.hogQL, HogLanguage.hogQLExpr, HogLanguage.hogTemplate]
@@ -50,6 +50,7 @@ export interface CodeEditorLogicProps {
globals?: Record
multitab?: boolean
onError?: (error: string | null, isValidView: boolean) => void
+ onMetadata?: (metadata: HogQLMetadataResponse) => void
}
export const codeEditorLogic = kea([
@@ -100,6 +101,7 @@ export const codeEditorLogic = kea([
variables,
})
breakpoint()
+ props.onMetadata?.(response)
return [query, response]
},
},
@@ -204,7 +206,7 @@ export const codeEditorLogic = kea([
if (values.featureFlags[FEATURE_FLAGS.MULTITAB_EDITOR] || values.featureFlags[FEATURE_FLAGS.SQL_EDITOR]) {
const path = modelName.path.split('/').pop()
- path && props.multitab && actions.setLocalState(activemodelStateKey(props.key), path)
+ path && props.multitab && actions.setLocalState(activeModelStateKey(props.key), path)
}
},
deleteModel: ({ modelName }) => {
diff --git a/frontend/src/lib/taxonomy.tsx b/frontend/src/lib/taxonomy.tsx
index 3d5f3f308349e..b982ef2c6eb81 100644
--- a/frontend/src/lib/taxonomy.tsx
+++ b/frontend/src/lib/taxonomy.tsx
@@ -133,6 +133,10 @@ export const CORE_FILTER_DEFINITIONS_BY_GROUP = {
label: 'Feature Interaction',
description: 'When a user interacts with a feature.',
},
+ $feature_enrollment_update: {
+ label: 'Feature Enrollment',
+ description: 'When a user enrolls with a feature.',
+ },
$capture_metrics: {
label: 'Capture Metrics',
description: 'Metrics captured with values pertaining to your systems at a specific point in time',
diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx
index 15b90eeb5473a..d8a66b558ac15 100644
--- a/frontend/src/lib/utils.tsx
+++ b/frontend/src/lib/utils.tsx
@@ -1452,11 +1452,20 @@ export function resolveWebhookService(webhookUrl: string): string {
return 'your webhook service'
}
-function hexToRGB(hex: string): { r: number; g: number; b: number } {
+export function hexToRGB(hex: string): { r: number; g: number; b: number } {
const originalString = hex.trim()
const hasPoundSign = originalString[0] === '#'
- const originalColor = hasPoundSign ? originalString.slice(1) : originalString
+ let originalColor = hasPoundSign ? originalString.slice(1) : originalString
+ // convert 3-digit hex colors to 6-digit
+ if (originalColor.length === 3) {
+ originalColor = originalColor
+ .split('')
+ .map((c) => c + c)
+ .join('')
+ }
+
+ // make sure we have a 6-digit color
if (originalColor.length !== 6) {
console.warn(`Incorrectly formatted color string: ${hex}.`)
return { r: 0, g: 0, b: 0 }
@@ -1513,6 +1522,26 @@ export function lightenDarkenColor(hex: string, pct: number): string {
return `rgb(${[r, g, b].join(',')})`
}
+/* Colors in hsl for gradation. */
+export const BRAND_BLUE_HSL: [number, number, number] = [228, 100, 56]
+export const PURPLE: [number, number, number] = [260, 88, 71]
+
+/**
+ * Gradate color saturation based on its intended strength.
+ * This is for visualizations where a data point's color depends on its value.
+ * @param hsl The HSL color to gradate.
+ * @param strength The strength of the data point.
+ * @param floor The minimum saturation. This preserves proportionality of strength, so doesn't just cut it off.
+ */
+export function gradateColor(
+ hsl: [number, number, number],
+ strength: number,
+ floor: number = 0
+): `hsla(${number}, ${number}%, ${number}%, ${string})` {
+ const saturation = floor + (1 - floor) * strength
+ return `hsla(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%, ${saturation.toPrecision(3)})`
+}
+
export function toString(input?: any): string {
return input?.toString() || ''
}
diff --git a/frontend/src/lib/utils/concurrencyController.ts b/frontend/src/lib/utils/concurrencyController.ts
index 941af92f33b74..7326165b623a5 100644
--- a/frontend/src/lib/utils/concurrencyController.ts
+++ b/frontend/src/lib/utils/concurrencyController.ts
@@ -1,5 +1,8 @@
import FastPriorityQueue from 'fastpriorityqueue'
import { promiseResolveReject } from 'lib/utils'
+
+// Note that this file also exists in the plugin-server, please keep them in sync as the tests only exist for this version
+
class ConcurrencyControllerItem {
_debugTag?: string
_runFn: () => Promise
@@ -8,7 +11,7 @@ class ConcurrencyControllerItem {
constructor(
concurrencyController: ConcurrencyController,
userFn: () => Promise,
- abortController: AbortController,
+ abortController: AbortController | undefined,
priority: number = Infinity,
debugTag: string | undefined
) {
@@ -17,7 +20,7 @@ class ConcurrencyControllerItem {
const { promise, resolve, reject } = promiseResolveReject()
this._promise = promise
this._runFn = async () => {
- if (abortController.signal.aborted) {
+ if (abortController?.signal.aborted) {
reject(new FakeAbortError(abortController.signal.reason || 'AbortError'))
return
}
@@ -32,7 +35,7 @@ class ConcurrencyControllerItem {
reject(error)
}
}
- abortController.signal.addEventListener('abort', () => {
+ abortController?.signal.addEventListener('abort', () => {
reject(new FakeAbortError(abortController.signal.reason || 'AbortError'))
})
promise
@@ -76,7 +79,7 @@ export class ConcurrencyController {
}: {
fn: () => Promise
priority?: number
- abortController: AbortController
+ abortController?: AbortController
debugTag?: string
}): Promise => {
const item = new ConcurrencyControllerItem(this, fn, abortController, priority, debugTag)
diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts
index bc0014c36ad88..39487ede8f5e0 100644
--- a/frontend/src/lib/utils/eventUsageLogic.ts
+++ b/frontend/src/lib/utils/eventUsageLogic.ts
@@ -441,8 +441,6 @@ export const eventUsageLogic = kea([
reportRecordingsListPropertiesFetched: (loadTime: number) => ({ loadTime }),
reportRecordingsListFilterAdded: (filterType: SessionRecordingFilterType) => ({ filterType }),
reportRecordingPlayerSeekbarEventHovered: true,
- reportRecordingPlayerSpeedChanged: (newSpeed: number) => ({ newSpeed }),
- reportRecordingPlayerSkipInactivityToggled: (skipInactivity: boolean) => ({ skipInactivity }),
reportRecordingInspectorItemExpanded: (tab: InspectorListItemType, index: number) => ({ tab, index }),
reportRecordingInspectorMiniFilterViewed: (minifilterKey: MiniFilterKey, enabled: boolean) => ({
minifilterKey,
@@ -948,12 +946,6 @@ export const eventUsageLogic = kea([
reportRecordingPlayerSeekbarEventHovered: () => {
posthog.capture('recording player seekbar event hovered')
},
- reportRecordingPlayerSpeedChanged: ({ newSpeed }) => {
- posthog.capture('recording player speed changed', { new_speed: newSpeed })
- },
- reportRecordingPlayerSkipInactivityToggled: ({ skipInactivity }) => {
- posthog.capture('recording player skip inactivity toggled', { skip_inactivity: skipInactivity })
- },
reportRecordingInspectorItemExpanded: ({ tab, index }) => {
posthog.capture('recording inspector item expanded', { tab: 'replay-4000', type: tab, index })
},
diff --git a/frontend/src/mocks/fixtures/_hogFunctionTemplates.json b/frontend/src/mocks/fixtures/_hogFunctionTemplates.json
index 547c01d09ddc0..5a46141258384 100644
--- a/frontend/src/mocks/fixtures/_hogFunctionTemplates.json
+++ b/frontend/src/mocks/fixtures/_hogFunctionTemplates.json
@@ -6,7 +6,7 @@
{
"sub_templates": [
{
- "id": "early_access_feature_enrollment",
+ "id": "early-access-feature-enrollment",
"name": "Post to Slack on feature enrollment",
"description": "Posts a message to Slack when a user enrolls or un-enrolls in an early access feature",
"filters": { "events": [{ "id": "$feature_enrollment_update", "type": "events" }] },
@@ -35,7 +35,7 @@
}
},
{
- "id": "survey_response",
+ "id": "survey-response",
"name": "Post to Slack on survey response",
"description": "Posts a message to Slack when a user responds to a survey",
"filters": {
@@ -171,7 +171,7 @@
{
"sub_templates": [
{
- "id": "early_access_feature_enrollment",
+ "id": "early-access-feature-enrollment",
"name": "HTTP Webhook on feature enrollment",
"description": null,
"filters": { "events": [{ "id": "$feature_enrollment_update", "type": "events" }] },
@@ -179,7 +179,7 @@
"inputs": null
},
{
- "id": "survey_response",
+ "id": "survey-response",
"name": "HTTP Webhook on survey response",
"description": null,
"filters": {
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 9d14c1b3c0acf..b9963ac3fa5ad 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -1,4 +1,5 @@
import {
+ MOCK_DATA_COLOR_THEMES,
MOCK_DEFAULT_COHORT,
MOCK_DEFAULT_ORGANIZATION,
MOCK_DEFAULT_ORGANIZATION_INVITE,
@@ -119,6 +120,7 @@ export const defaultMocks: Mocks = {
},
},
],
+ '/api/users/@me/two_factor_status/': () => [200, { is_enabled: true, backup_codes: [], method: 'TOTP' }],
'/api/environments/@current/': MOCK_DEFAULT_TEAM,
'/api/projects/@current/': MOCK_DEFAULT_TEAM,
'/api/projects/:team_id/comments/count': { count: 0 },
@@ -152,6 +154,7 @@ export const defaultMocks: Mocks = {
'/api/projects/:team_id/hog_function_templates': _hogFunctionTemplates,
'/api/projects/:team_id/hog_function_templates/:id': hogFunctionTemplateRetrieveMock,
'/api/projects/:team_id/hog_functions': EMPTY_PAGINATED_RESPONSE,
+ '/api/environments/:team_id/data_color_themes': MOCK_DATA_COLOR_THEMES,
},
post: {
'https://us.i.posthog.com/e/': (req, res, ctx): MockSignature => posthogCORSResponse(req, res, ctx),
diff --git a/frontend/src/queries/nodes/DataVisualization/Components/ColorPickerButton.tsx b/frontend/src/queries/nodes/DataVisualization/Components/ColorPickerButton.tsx
index a9fdac3aedf91..107f9d426d83e 100644
--- a/frontend/src/queries/nodes/DataVisualization/Components/ColorPickerButton.tsx
+++ b/frontend/src/queries/nodes/DataVisualization/Components/ColorPickerButton.tsx
@@ -1,7 +1,7 @@
import { LemonButton, Popover } from '@posthog/lemon-ui'
import { useValues } from 'kea'
-import { SeriesGlyph } from 'lib/components/SeriesGlyph'
-import { hexToRGBA, lightenDarkenColor, RGBToHex, RGBToRGBA } from 'lib/utils'
+import { ColorGlyph } from 'lib/components/SeriesGlyph'
+import { lightenDarkenColor, RGBToHex } from 'lib/utils'
import { useState } from 'react'
import { ColorResult, TwitterPicker } from 'react-color'
@@ -57,17 +57,7 @@ export const ColorPickerButton = ({
sideIcon={<>>}
className="ConditionalFormattingTab__ColorPicker"
>
-
- <>>
-
+
)
diff --git a/frontend/src/queries/nodes/DataVisualization/Components/ConditionalFormatting/ConditionalFormattingTab.tsx b/frontend/src/queries/nodes/DataVisualization/Components/ConditionalFormatting/ConditionalFormattingTab.tsx
index 30cd92628f817..5f94978e5afb1 100644
--- a/frontend/src/queries/nodes/DataVisualization/Components/ConditionalFormatting/ConditionalFormattingTab.tsx
+++ b/frontend/src/queries/nodes/DataVisualization/Components/ConditionalFormatting/ConditionalFormattingTab.tsx
@@ -3,10 +3,8 @@ import './ConditionalFormattingTab.scss'
import { IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonButton, LemonCollapse, LemonInput, LemonSelect, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
-import { SeriesGlyph } from 'lib/components/SeriesGlyph'
-import { hexToRGBA, lightenDarkenColor, RGBToRGBA } from 'lib/utils'
+import { ColorGlyph } from 'lib/components/SeriesGlyph'
-import { themeLogic } from '~/layout/navigation-3000/themeLogic'
import { ColorPickerButton } from '~/queries/nodes/DataVisualization/Components/ColorPickerButton'
import { ConditionalFormattingRule } from '~/queries/schema'
@@ -33,7 +31,6 @@ const getRuleHeader = (rule: ConditionalFormattingRule): string => {
}
export const ConditionalFormattingTab = (): JSX.Element => {
- const { isDarkModeOn } = useValues(themeLogic)
const { conditionalFormattingRules, conditionalFormattingRulesPanelActiveKeys } = useValues(dataVisualizationLogic)
const { addConditionalFormattingRule, setConditionalFormattingRulesPanelActiveKeys } =
useActions(dataVisualizationLogic)
@@ -53,17 +50,7 @@ export const ConditionalFormattingTab = (): JSX.Element => {
key: rule.id,
header: (
<>
-
- <>>
-
+
{getRuleHeader(rule)}
>
),
diff --git a/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx b/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx
index d500bdf9e2f00..4c1db2454dd1f 100644
--- a/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx
+++ b/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx
@@ -13,11 +13,8 @@ import {
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { getSeriesColor, getSeriesColorPalette } from 'lib/colors'
-import { SeriesGlyph } from 'lib/components/SeriesGlyph'
+import { ColorGlyph } from 'lib/components/SeriesGlyph'
import { LemonField } from 'lib/lemon-ui/LemonField'
-import { hexToRGBA, lightenDarkenColor, RGBToRGBA } from 'lib/utils'
-
-import { themeLogic } from '~/layout/navigation-3000/themeLogic'
import { AxisSeries, dataVisualizationLogic } from '../dataVisualizationLogic'
import { ColorPickerButton } from './ColorPickerButton'
@@ -126,7 +123,6 @@ const YSeries = ({ series, index }: { series: AxisSeries; index: number
const { isSettingsOpen, canOpenSettings, activeSettingsTab } = useValues(seriesLogic)
const { setSettingsOpen, submitFormatting, submitDisplay, setSettingsTab } = useActions(seriesLogic)
- const { isDarkModeOn } = useValues(themeLogic)
const seriesColor = series.settings?.display?.color ?? getSeriesColor(index)
const showSeriesColor = !showTableSettings && !selectedSeriesBreakdownColumn
@@ -135,20 +131,7 @@ const YSeries = ({ series, index }: { series: AxisSeries; index: number
value: name,
label: (
- {showSeriesColor && (
-
- <>>
-
- )}
+ {showSeriesColor &&
}
{series.settings?.display?.label && series.column.name === name ? series.settings.display.label : name}
{type.name}
@@ -399,24 +382,12 @@ export const SeriesBreakdownSelector = (): JSX.Element => {
}
const BreakdownSeries = ({ series, index }: { series: AxisBreakdownSeries; index: number }): JSX.Element => {
- const { isDarkModeOn } = useValues(themeLogic)
const seriesColor = series.settings?.display?.color ?? getSeriesColor(index)
return (
-
- <>>
-
+
{series.name ? series.name : '[No value]'}
{/* For now let's keep things simple and not allow too much configuration */}
diff --git a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
index 4480fe9977755..e2b0f57d11623 100644
--- a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
+++ b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts
@@ -178,6 +178,9 @@ export const convertTableValue = (
}
const toFriendlyClickhouseTypeName = (type: string): ColumnScalar => {
+ if (type.indexOf('Tuple') !== -1) {
+ return 'TUPLE'
+ }
if (type.indexOf('Int') !== -1) {
return 'INTEGER'
}
@@ -203,8 +206,8 @@ const toFriendlyClickhouseTypeName = (type: string): ColumnScalar => {
return type as ColumnScalar
}
-const isNumericalType = (type: string): boolean => {
- if (type.indexOf('Int') !== -1 || type.indexOf('Float') !== -1 || type.indexOf('Decimal') !== -1) {
+const isNumericalType = (type: ColumnScalar): boolean => {
+ if (type === 'INTEGER' || type === 'FLOAT' || type === 'DECIMAL') {
return true
}
@@ -547,11 +550,13 @@ export const dataVisualizationLogic = kea
([
return columns.map((column, index) => {
const type = types[index]?.[1]
+ const friendlyClickhouseTypeName = toFriendlyClickhouseTypeName(type)
+
return {
name: column,
type: {
- name: toFriendlyClickhouseTypeName(type),
- isNumerical: isNumericalType(type),
+ name: friendlyClickhouseTypeName,
+ isNumerical: isNumericalType(friendlyClickhouseTypeName),
},
label: `${column} - ${type}`,
dataIndex: index,
diff --git a/frontend/src/queries/nodes/DataVisualization/types.ts b/frontend/src/queries/nodes/DataVisualization/types.ts
index ad9f186f67001..b39a78b5658d2 100644
--- a/frontend/src/queries/nodes/DataVisualization/types.ts
+++ b/frontend/src/queries/nodes/DataVisualization/types.ts
@@ -1,4 +1,4 @@
-export type ColumnScalar = 'INTEGER' | 'FLOAT' | 'DATETIME' | 'DATE' | 'BOOLEAN' | 'DECIMAL' | 'STRING'
+export type ColumnScalar = 'INTEGER' | 'FLOAT' | 'DATETIME' | 'DATE' | 'BOOLEAN' | 'DECIMAL' | 'STRING' | 'TUPLE'
export interface FormattingTemplate {
id: string
diff --git a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
index 49ebdc16ae396..4c6dd4d8a70e6 100644
--- a/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
+++ b/frontend/src/queries/nodes/HogQLQuery/HogQLQueryEditor.tsx
@@ -10,7 +10,7 @@ import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { CodeEditor } from 'lib/monaco/CodeEditor'
import {
- activemodelStateKey,
+ activeModelStateKey,
codeEditorLogic,
CodeEditorLogicProps,
editorModelsStateKey,
@@ -190,7 +190,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
setMonacoAndEditor([monaco, editor])
const allModelQueries = localStorage.getItem(editorModelsStateKey(codeEditorKey))
- const activeModelUri = localStorage.getItem(activemodelStateKey(codeEditorKey))
+ const activeModelUri = localStorage.getItem(activeModelStateKey(codeEditorKey))
if (allModelQueries && multitab) {
// clear existing models
diff --git a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx
index ab8b30abc4f5e..67e7f9e7d8dc4 100644
--- a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx
+++ b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx
@@ -1,4 +1,5 @@
-import { LemonButton, LemonInput } from '@posthog/lemon-ui'
+import { IconInfo } from '@posthog/icons'
+import { LemonButton, LemonInput, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ChartFilter } from 'lib/components/ChartFilter'
import { CompareFilter } from 'lib/components/CompareFilter/CompareFilter'
@@ -13,6 +14,7 @@ import { ReactNode } from 'react'
import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
import { axisLabel } from 'scenes/insights/aggregationAxisFormat'
import { PercentStackViewFilter } from 'scenes/insights/EditorFilters/PercentStackViewFilter'
+import { ResultCustomizationByPicker } from 'scenes/insights/EditorFilters/ResultCustomizationByPicker'
import { ScalePicker } from 'scenes/insights/EditorFilters/ScalePicker'
import { ShowAlertThresholdLinesFilter } from 'scenes/insights/EditorFilters/ShowAlertThresholdLinesFilter'
import { ShowLegendFilter } from 'scenes/insights/EditorFilters/ShowLegendFilter'
@@ -30,6 +32,7 @@ import { PathStepPicker } from 'scenes/insights/views/Paths/PathStepPicker'
import { trendsDataLogic } from 'scenes/trends/trendsDataLogic'
import { useDebouncedCallback } from 'use-debounce'
+import { resultCustomizationsModalLogic } from '~/queries/nodes/InsightViz/resultCustomizationsModalLogic'
import { isValidBreakdown } from '~/queries/utils'
import { ChartDisplayType } from '~/types'
@@ -52,6 +55,7 @@ export function InsightDisplayConfig(): JSX.Element {
supportsValueOnSeries,
showPercentStackView,
supportsPercentStackView,
+ supportsResultCustomizationBy,
yAxisScaleType,
isNonTimeSeriesDisplay,
compareFilter,
@@ -60,6 +64,7 @@ export function InsightDisplayConfig(): JSX.Element {
const { isTrendsFunnel, isStepsFunnel, isTimeToConvertFunnel, isEmptyFunnel } = useValues(
funnelDataLogic(insightProps)
)
+ const { hasInsightColors } = useValues(resultCustomizationsModalLogic(insightProps))
const { updateCompareFilter } = useActions(insightVizDataLogic(insightProps))
@@ -74,7 +79,7 @@ export function InsightDisplayConfig(): JSX.Element {
const { showValuesOnSeries, mightContainFractionalNumbers } = useValues(trendsDataLogic(insightProps))
const advancedOptions: LemonMenuItems = [
- ...(supportsValueOnSeries || supportsPercentStackView || hasLegend
+ ...(supportsValueOnSeries || supportsPercentStackView || hasLegend || supportsResultCustomizationBy
? [
{
title: 'Display',
@@ -87,6 +92,23 @@ export function InsightDisplayConfig(): JSX.Element {
},
]
: []),
+ ...(supportsResultCustomizationBy && hasInsightColors
+ ? [
+ {
+ title: (
+ <>
+
+ Color customization by{' '}
+
+
+
+
+ >
+ ),
+ items: [{ label: () => }],
+ },
+ ]
+ : []),
...(!showPercentStackView && isTrends
? [
{
diff --git a/frontend/src/queries/nodes/InsightViz/InsightVizDisplay.tsx b/frontend/src/queries/nodes/InsightViz/InsightVizDisplay.tsx
index 69a30e9e727f8..6f6531a3669ac 100644
--- a/frontend/src/queries/nodes/InsightViz/InsightVizDisplay.tsx
+++ b/frontend/src/queries/nodes/InsightViz/InsightVizDisplay.tsx
@@ -32,6 +32,7 @@ import { ExporterFormat, FunnelVizType, InsightType, ItemMode } from '~/types'
import { InsightDisplayConfig } from './InsightDisplayConfig'
import { InsightResultMetadata } from './InsightResultMetadata'
+import { ResultCustomizationsModal } from './ResultCustomizationsModal'
export function InsightVizDisplay({
disableHeader,
@@ -55,13 +56,13 @@ export function InsightVizDisplay({
context?: QueryContext
embedded: boolean
inSharedMode?: boolean
-}): JSX.Element {
+}): JSX.Element | null {
const { insightProps, canEditInsight } = useValues(insightLogic)
const { activeView } = useValues(insightNavLogic(insightProps))
const { hasFunnelResults } = useValues(funnelDataLogic(insightProps))
- const { isFunnelWithEnoughSteps, validationError } = useValues(insightVizDataLogic(insightProps))
+ const { isFunnelWithEnoughSteps, validationError, theme } = useValues(insightVizDataLogic(insightProps))
const {
isFunnels,
isPaths,
@@ -218,6 +219,10 @@ export function InsightVizDisplay({
const showComputationMetadata = !disableLastComputation || !!samplingFactor
+ if (!theme) {
+ return null
+ }
+
return (
<>
{/* These are filters that are reused between insight features. They each have generic logic that updates the url */}
@@ -265,12 +270,13 @@ export function InsightVizDisplay({
>
) : (
- renderActiveView()
+ <>{renderActiveView()}>
)}
>
)}
+
{renderTable()}
{!disableCorrelationTable && activeView === InsightType.FUNNELS &&
}
>
diff --git a/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.scss b/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.scss
new file mode 100644
index 0000000000000..5169495b7ffb4
--- /dev/null
+++ b/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.scss
@@ -0,0 +1,5 @@
+.ResultCustomizationsModal__ColorGlyphButton .LemonButton__chrome {
+ gap: 4px;
+ padding-right: 6px !important;
+ padding-left: 6px !important;
+}
diff --git a/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.tsx b/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.tsx
new file mode 100644
index 0000000000000..9d1bed9f6faaf
--- /dev/null
+++ b/frontend/src/queries/nodes/InsightViz/ResultCustomizationsModal.tsx
@@ -0,0 +1,361 @@
+import './ResultCustomizationsModal.scss'
+
+import { LemonButton, LemonButtonProps, LemonModal } from '@posthog/lemon-ui'
+import assert from 'assert'
+import { useActions, useValues } from 'kea'
+import { DataColorToken } from 'lib/colors'
+import { EntityFilterInfo } from 'lib/components/EntityFilterInfo'
+import { ColorGlyph } from 'lib/components/SeriesGlyph'
+import { hexToRGB } from 'lib/utils'
+import { dataThemeLogic } from 'scenes/dataThemeLogic'
+import { insightLogic } from 'scenes/insights/insightLogic'
+import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
+import { formatBreakdownLabel } from 'scenes/insights/utils'
+import { IndexedTrendResult } from 'scenes/trends/types'
+
+import { cohortsModel } from '~/models/cohortsModel'
+import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
+import { ResultCustomizationBy } from '~/queries/schema'
+import { FlattenedFunnelStepByBreakdown } from '~/types'
+
+import { resultCustomizationsModalLogic } from './resultCustomizationsModalLogic'
+
+export function ResultCustomizationsModal(): JSX.Element | null {
+ const { insightProps } = useValues(insightLogic)
+
+ const { modalVisible, dataset, colorToken, resultCustomizationBy } = useValues(
+ resultCustomizationsModalLogic(insightProps)
+ )
+ const { closeModal, setColorToken, save } = useActions(resultCustomizationsModalLogic(insightProps))
+
+ const { isTrends, isFunnels, querySource } = useValues(insightVizDataLogic)
+
+ const { getTheme } = useValues(dataThemeLogic)
+ const theme = getTheme(querySource?.dataColorTheme)
+
+ if (dataset == null || theme == null) {
+ return null
+ }
+
+ return (
+
+
+ Cancel
+
+
+ Save customizations
+
+ >
+ }
+ onClose={closeModal}
+ >
+
+ Query results can be customized to provide a more{' '}
+ meaningful appearance for you and your team members . The customizations are also shown
+ on dashboards.
+
+ {isTrends && (
+
+ )}
+ {isFunnels && }
+
+ Color
+
+ {Object.keys(theme).map((key) => (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+
+ setColorToken(key as DataColorToken)
+ }}
+ />
+ ))}
+
+
+ )
+}
+
+type TrendsInfoProps = {
+ dataset: IndexedTrendResult
+ resultCustomizationBy: ResultCustomizationBy
+}
+
+function TrendsInfo({ dataset, resultCustomizationBy }: TrendsInfoProps): JSX.Element {
+ const { cohorts } = useValues(cohortsModel)
+ const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel)
+ const { breakdownFilter } = useValues(insightVizDataLogic)
+
+ return (
+ <>
+ {dataset.breakdown_value ? (
+
+ You are customizing the appearance of series{' '}
+
+
+ {' '}
+ for the breakdown{' '}
+
+ {formatBreakdownLabel(
+ dataset.breakdown_value,
+ breakdownFilter,
+ cohorts,
+ formatPropertyValueForDisplay
+ )}
+
+ .
+
+ ) : (
+
+ You are customizing the appearance of series{' '}
+
+
+
+ .
+
+ )}
+
+
+ Results are assigned by{' '}
+ {resultCustomizationBy === ResultCustomizationBy.Position ? (
+ <>
+ their rank in the dataset
+ >
+ ) : (
+ <>
+ their name in the dataset
+ >
+ )}
+ . You can change this in insight settings.
+
+ >
+ )
+}
+
+type FunnelsInfoProps = {
+ dataset: FlattenedFunnelStepByBreakdown
+}
+
+function FunnelsInfo({ dataset }: FunnelsInfoProps): JSX.Element {
+ return (
+ <>
+ You are customizing the appearance of the{' '}
+ {dataset.breakdown_value?.[0] === 'Baseline' ? (
+
Baseline
+ ) : (
+ <>
+
{dataset.breakdown_value?.[0]} breakdown
+ >
+ )}
+ .
+ >
+ )
+}
+
+type ColorGlyphButtonProps = {
+ colorToken: DataColorToken
+ selected: boolean
+ onClick: LemonButtonProps['onClick']
+}
+
+function ColorGlyphButton({ colorToken, selected, onClick }: ColorGlyphButtonProps): JSX.Element {
+ const { getTheme } = useValues(dataThemeLogic)
+
+ const { querySource } = useValues(insightVizDataLogic)
+
+ const theme = getTheme(querySource?.dataColorTheme)
+ const color = theme?.[colorToken] as string
+
+ return (
+
+
+
+ )
+}
+
+type ReferenceColor = { name: string; group: string }
+
+/** HTML5 colors */
+const referenceColors: Record
= {
+ '#FFC0CB': { name: 'Pink', group: 'Pink' },
+ '#FFB6C1': { name: 'LightPink', group: 'Pink' },
+ '#FF69B4': { name: 'HotPink', group: 'Pink' },
+ '#FF1493': { name: 'DeepPink', group: 'Pink' },
+ '#DB7093': { name: 'PaleVioletRed', group: 'Pink' },
+ '#C71585': { name: 'MediumVioletRed', group: 'Pink' },
+ '#E6E6FA': { name: 'Lavender', group: 'Purple' },
+ '#D8BFD8': { name: 'Thistle', group: 'Purple' },
+ '#DDA0DD': { name: 'Plum', group: 'Purple' },
+ '#DA70D6': { name: 'Orchid', group: 'Purple' },
+ '#EE82EE': { name: 'Violet', group: 'Purple' },
+ '#FF00FF': { name: 'Magenta', group: 'Purple' },
+ '#BA55D3': { name: 'MediumOrchid', group: 'Purple' },
+ '#9932CC': { name: 'DarkOrchid', group: 'Purple' },
+ '#9400D3': { name: 'DarkViolet', group: 'Purple' },
+ '#8A2BE2': { name: 'BlueViolet', group: 'Purple' },
+ '#8B008B': { name: 'DarkMagenta', group: 'Purple' },
+ '#800080': { name: 'Purple', group: 'Purple' },
+ '#9370DB': { name: 'MediumPurple', group: 'Purple' },
+ '#7B68EE': { name: 'MediumSlateBlue', group: 'Purple' },
+ '#6A5ACD': { name: 'SlateBlue', group: 'Purple' },
+ '#483D8B': { name: 'DarkSlateBlue', group: 'Purple' },
+ '#663399': { name: 'RebeccaPurple', group: 'Purple' },
+ '#4B0082': { name: 'Indigo', group: 'Purple' },
+ '#FFA07A': { name: 'LightSalmon', group: 'Red' },
+ '#FA8072': { name: 'Salmon', group: 'Red' },
+ '#E9967A': { name: 'DarkSalmon', group: 'Red' },
+ '#F08080': { name: 'LightCoral', group: 'Red' },
+ '#CD5C5C': { name: 'IndianRed', group: 'Red' },
+ '#DC143C': { name: 'Crimson', group: 'Red' },
+ '#FF0000': { name: 'Red', group: 'Red' },
+ '#B22222': { name: 'FireBrick', group: 'Red' },
+ '#8B0000': { name: 'DarkRed', group: 'Red' },
+ '#FFA500': { name: 'Orange', group: 'Orange' },
+ '#FF8C00': { name: 'DarkOrange', group: 'Orange' },
+ '#FF7F50': { name: 'Coral', group: 'Orange' },
+ '#FF6347': { name: 'Tomato', group: 'Orange' },
+ '#FF4500': { name: 'OrangeRed', group: 'Orange' },
+ '#FFD700': { name: 'Gold', group: 'Yellow' },
+ '#FFFF00': { name: 'Yellow', group: 'Yellow' },
+ '#FFFFE0': { name: 'LightYellow', group: 'Yellow' },
+ '#FFFACD': { name: 'LemonChiffon', group: 'Yellow' },
+ '#FAFAD2': { name: 'LightGoldenRodYellow', group: 'Yellow' },
+ '#FFEFD5': { name: 'PapayaWhip', group: 'Yellow' },
+ '#FFE4B5': { name: 'Moccasin', group: 'Yellow' },
+ '#FFDAB9': { name: 'PeachPuff', group: 'Yellow' },
+ '#EEE8AA': { name: 'PaleGoldenRod', group: 'Yellow' },
+ '#F0E68C': { name: 'Khaki', group: 'Yellow' },
+ '#BDB76B': { name: 'DarkKhaki', group: 'Yellow' },
+ '#ADFF2F': { name: 'GreenYellow', group: 'Green' },
+ '#7FFF00': { name: 'Chartreuse', group: 'Green' },
+ '#7CFC00': { name: 'LawnGreen', group: 'Green' },
+ '#00FF00': { name: 'Lime', group: 'Green' },
+ '#32CD32': { name: 'LimeGreen', group: 'Green' },
+ '#98FB98': { name: 'PaleGreen', group: 'Green' },
+ '#90EE90': { name: 'LightGreen', group: 'Green' },
+ '#00FA9A': { name: 'MediumSpringGreen', group: 'Green' },
+ '#00FF7F': { name: 'SpringGreen', group: 'Green' },
+ '#3CB371': { name: 'MediumSeaGreen', group: 'Green' },
+ '#2E8B57': { name: 'SeaGreen', group: 'Green' },
+ '#228B22': { name: 'ForestGreen', group: 'Green' },
+ '#008000': { name: 'Green', group: 'Green' },
+ '#006400': { name: 'DarkGreen', group: 'Green' },
+ '#9ACD32': { name: 'YellowGreen', group: 'Green' },
+ '#6B8E23': { name: 'OliveDrab', group: 'Green' },
+ '#556B2F': { name: 'DarkOliveGreen', group: 'Green' },
+ '#66CDAA': { name: 'MediumAquaMarine', group: 'Green' },
+ '#8FBC8F': { name: 'DarkSeaGreen', group: 'Green' },
+ '#20B2AA': { name: 'LightSeaGreen', group: 'Green' },
+ '#008B8B': { name: 'DarkCyan', group: 'Green' },
+ '#008080': { name: 'Teal', group: 'Green' },
+ '#00FFFF': { name: 'Cyan', group: 'Cyan' },
+ '#E0FFFF': { name: 'LightCyan', group: 'Cyan' },
+ '#AFEEEE': { name: 'PaleTurquoise', group: 'Cyan' },
+ '#7FFFD4': { name: 'Aquamarine', group: 'Cyan' },
+ '#40E0D0': { name: 'Turquoise', group: 'Cyan' },
+ '#48D1CC': { name: 'MediumTurquoise', group: 'Cyan' },
+ '#00CED1': { name: 'DarkTurquoise', group: 'Cyan' },
+ '#5F9EA0': { name: 'CadetBlue', group: 'Blue' },
+ '#4682B4': { name: 'SteelBlue', group: 'Blue' },
+ '#B0C4DE': { name: 'LightSteelBlue', group: 'Blue' },
+ '#ADD8E6': { name: 'LightBlue', group: 'Blue' },
+ '#B0E0E6': { name: 'PowderBlue', group: 'Blue' },
+ '#87CEFA': { name: 'LightSkyBlue', group: 'Blue' },
+ '#87CEEB': { name: 'SkyBlue', group: 'Blue' },
+ '#6495ED': { name: 'CornflowerBlue', group: 'Blue' },
+ '#00BFFF': { name: 'DeepSkyBlue', group: 'Blue' },
+ '#1E90FF': { name: 'DodgerBlue', group: 'Blue' },
+ '#4169E1': { name: 'RoyalBlue', group: 'Blue' },
+ '#0000FF': { name: 'Blue', group: 'Blue' },
+ '#0000CD': { name: 'MediumBlue', group: 'Blue' },
+ '#00008B': { name: 'DarkBlue', group: 'Blue' },
+ '#000080': { name: 'Navy', group: 'Blue' },
+ '#191970': { name: 'MidnightBlue', group: 'Blue' },
+ '#FFF8DC': { name: 'Cornsilk', group: 'Brown' },
+ '#FFEBCD': { name: 'BlanchedAlmond', group: 'Brown' },
+ '#FFE4C4': { name: 'Bisque', group: 'Brown' },
+ '#FFDEAD': { name: 'NavajoWhite', group: 'Brown' },
+ '#F5DEB3': { name: 'Wheat', group: 'Brown' },
+ '#DEB887': { name: 'BurlyWood', group: 'Brown' },
+ '#D2B48C': { name: 'Tan', group: 'Brown' },
+ '#BC8F8F': { name: 'RosyBrown', group: 'Brown' },
+ '#F4A460': { name: 'SandyBrown', group: 'Brown' },
+ '#DAA520': { name: 'GoldenRod', group: 'Brown' },
+ '#B8860B': { name: 'DarkGoldenRod', group: 'Brown' },
+ '#CD853F': { name: 'Peru', group: 'Brown' },
+ '#D2691E': { name: 'Chocolate', group: 'Brown' },
+ '#808000': { name: 'Olive', group: 'Brown' },
+ '#8B4513': { name: 'SaddleBrown', group: 'Brown' },
+ '#A0522D': { name: 'Sienna', group: 'Brown' },
+ '#A52A2A': { name: 'Brown', group: 'Brown' },
+ '#800000': { name: 'Maroon', group: 'Brown' },
+ '#FFFFFF': { name: 'White', group: 'White' },
+ '#FFFAFA': { name: 'Snow', group: 'White' },
+ '#F0FFF0': { name: 'HoneyDew', group: 'White' },
+ '#F5FFFA': { name: 'MintCream', group: 'White' },
+ '#F0FFFF': { name: 'Azure', group: 'White' },
+ '#F0F8FF': { name: 'AliceBlue', group: 'White' },
+ '#F8F8FF': { name: 'GhostWhite', group: 'White' },
+ '#F5F5F5': { name: 'WhiteSmoke', group: 'White' },
+ '#FFF5EE': { name: 'SeaShell', group: 'White' },
+ '#F5F5DC': { name: 'Beige', group: 'White' },
+ '#FDF5E6': { name: 'OldLace', group: 'White' },
+ '#FFFAF0': { name: 'FloralWhite', group: 'White' },
+ '#FFFFF0': { name: 'Ivory', group: 'White' },
+ '#FAEBD7': { name: 'AntiqueWhite', group: 'White' },
+ '#FAF0E6': { name: 'Linen', group: 'White' },
+ '#FFF0F5': { name: 'LavenderBlush', group: 'White' },
+ '#FFE4E1': { name: 'MistyRose', group: 'White' },
+ '#DCDCDC': { name: 'Gainsboro', group: 'Gray' },
+ '#D3D3D3': { name: 'LightGray', group: 'Gray' },
+ '#C0C0C0': { name: 'Silver', group: 'Gray' },
+ '#A9A9A9': { name: 'DarkGray', group: 'Gray' },
+ '#696969': { name: 'DimGray', group: 'Gray' },
+ '#808080': { name: 'Gray', group: 'Gray' },
+ '#778899': { name: 'LightSlateGray', group: 'Gray' },
+ '#708090': { name: 'SlateGray', group: 'Gray' },
+ '#2F4F4F': { name: 'DarkSlateGray', group: 'Gray' },
+ '#000000': { name: 'Black', group: 'Gray' },
+}
+
+function nearestColor(color: string): ReferenceColor {
+ const { r: r1, g: g1, b: b1 } = hexToRGB(color)
+
+ let minDistance = null
+ let minColor = null
+
+ for (const referenceColor in referenceColors) {
+ const { r: r2, g: g2, b: b2 } = hexToRGB(referenceColor)
+ const distance = Math.sqrt((r2 - r1) ** 2 + (g2 - g1) ** 2 + (b2 - b1) ** 2)
+
+ if (minDistance === null || distance < minDistance) {
+ minDistance = distance
+ minColor = referenceColor
+ }
+ }
+
+ assert(minColor)
+
+ return referenceColors[minColor]
+}
+
+function colorDescription(color: string): string {
+ const { name, group } = nearestColor(color)
+ const colorName = name.split(/(?=[A-Z])/).join(' ')
+
+ return colorName.includes(group) ? colorName : `${colorName} (${group})`
+}
diff --git a/frontend/src/queries/nodes/InsightViz/resultCustomizationsModalLogic.ts b/frontend/src/queries/nodes/InsightViz/resultCustomizationsModalLogic.ts
new file mode 100644
index 0000000000000..c297bfada948f
--- /dev/null
+++ b/frontend/src/queries/nodes/InsightViz/resultCustomizationsModalLogic.ts
@@ -0,0 +1,150 @@
+import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { DataColorToken } from 'lib/colors'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
+import { RESULT_CUSTOMIZATION_DEFAULT } from 'scenes/insights/EditorFilters/ResultCustomizationByPicker'
+import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
+import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
+import { getFunnelDatasetKey, getTrendResultCustomizationKey } from 'scenes/insights/utils'
+import { trendsDataLogic } from 'scenes/trends/trendsDataLogic'
+import { IndexedTrendResult } from 'scenes/trends/types'
+
+import { ResultCustomizationBy, TrendsFilter } from '~/queries/schema'
+import { FlattenedFunnelStepByBreakdown, InsightLogicProps } from '~/types'
+
+import type { resultCustomizationsModalLogicType } from './resultCustomizationsModalLogicType'
+
+export const resultCustomizationsModalLogic = kea([
+ props({} as InsightLogicProps),
+ key(keyForInsightLogicProps('new')),
+ path((key) => ['scenes', 'insights', 'views', 'InsightsTable', 'resultCustomizationsModalLogic', key]),
+
+ connect((props: InsightLogicProps) => ({
+ values: [
+ insightVizDataLogic,
+ ['isTrends', 'isFunnels', 'insightFilter'],
+ trendsDataLogic(props),
+ [
+ 'resultCustomizationBy as resultCustomizationByRaw',
+ 'resultCustomizations as trendsResultCustomizations',
+ 'getTrendsColorToken',
+ ],
+ funnelDataLogic(props),
+ ['resultCustomizations as funnelsResultCustomizations', 'getFunnelsColorToken'],
+ featureFlagLogic,
+ ['featureFlags'],
+ ],
+ actions: [insightVizDataLogic, ['updateInsightFilter']],
+ })),
+
+ actions({
+ openModal: (dataset: IndexedTrendResult | FlattenedFunnelStepByBreakdown) => ({ dataset }),
+ closeModal: true,
+
+ setColorToken: (token: DataColorToken) => ({ token }),
+
+ save: true,
+ }),
+
+ reducers({
+ dataset: [
+ null as IndexedTrendResult | FlattenedFunnelStepByBreakdown | null,
+ {
+ openModal: (_, { dataset }) => dataset,
+ closeModal: () => null,
+ },
+ ],
+ localColorToken: [
+ null as DataColorToken | null,
+ {
+ setColorToken: (_, { token }) => token,
+ closeModal: () => null,
+ },
+ ],
+ }),
+
+ selectors({
+ hasInsightColors: [
+ (s) => [s.featureFlags],
+ (featureFlags): boolean => !!featureFlags[FEATURE_FLAGS.INSIGHT_COLORS],
+ ],
+ modalVisible: [(s) => [s.dataset], (dataset): boolean => dataset !== null],
+ colorToken: [
+ (s) => [s.localColorToken, s.colorTokenFromQuery],
+ (localColorToken, colorTokenFromQuery): DataColorToken | null => localColorToken || colorTokenFromQuery,
+ ],
+ colorTokenFromQuery: [
+ (s) => [s.isTrends, s.isFunnels, s.getTrendsColorToken, s.getFunnelsColorToken, s.dataset],
+ (isTrends, isFunnels, getTrendsColorToken, getFunnelsColorToken, dataset): DataColorToken | null => {
+ if (!dataset) {
+ return null
+ }
+
+ if (isTrends) {
+ return getTrendsColorToken(dataset as IndexedTrendResult)
+ } else if (isFunnels) {
+ return getFunnelsColorToken(dataset as FlattenedFunnelStepByBreakdown)
+ }
+
+ return null
+ },
+ ],
+ resultCustomizationBy: [
+ (s) => [s.resultCustomizationByRaw],
+ (resultCustomizationByRaw) => resultCustomizationByRaw || RESULT_CUSTOMIZATION_DEFAULT,
+ ],
+ resultCustomizations: [
+ (s) => [s.isTrends, s.isFunnels, s.trendsResultCustomizations, s.funnelsResultCustomizations],
+ (isTrends, isFunnels, trendsResultCustomizations, funnelsResultCustomizations) => {
+ if (isTrends) {
+ return trendsResultCustomizations
+ } else if (isFunnels) {
+ return funnelsResultCustomizations
+ }
+
+ return null
+ },
+ ],
+ }),
+
+ listeners(({ actions, values }) => ({
+ save: () => {
+ if (values.localColorToken == null || values.dataset == null) {
+ actions.closeModal()
+ return
+ }
+
+ if (values.isTrends) {
+ const resultCustomizationKey = getTrendResultCustomizationKey(
+ values.resultCustomizationBy,
+ values.dataset as IndexedTrendResult
+ )
+ actions.updateInsightFilter({
+ resultCustomizations: {
+ ...values.trendsResultCustomizations,
+ [resultCustomizationKey]: {
+ assignmentBy: values.resultCustomizationBy,
+ color: values.localColorToken,
+ },
+ },
+ } as Partial)
+ }
+
+ if (values.isFunnels) {
+ const resultCustomizationKey = getFunnelDatasetKey(values.dataset as FlattenedFunnelStepByBreakdown)
+ actions.updateInsightFilter({
+ resultCustomizations: {
+ ...values.funnelsResultCustomizations,
+ [resultCustomizationKey]: {
+ assignmentBy: ResultCustomizationBy.Value,
+ color: values.localColorToken,
+ },
+ },
+ })
+ }
+
+ actions.closeModal()
+ },
+ })),
+])
diff --git a/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
index fb030cb8976c2..f284a44625c58 100644
--- a/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
+++ b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx
@@ -20,8 +20,7 @@ interface LabelType {
const labels: Record = {
person: {
label: 'persons',
- description:
- 'Search by email or Distinct ID. Email will match partially, for example: "@gmail.com". Distinct ID needs to match exactly.',
+ description: 'Search by name, email, Person ID or Distinct ID.',
},
group: {
label: 'groups',
diff --git a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx
index 925087cd8f9dc..83af7956c55da 100644
--- a/frontend/src/queries/nodes/WebOverview/WebOverview.tsx
+++ b/frontend/src/queries/nodes/WebOverview/WebOverview.tsx
@@ -2,11 +2,9 @@ import { IconTrending } from '@posthog/icons'
import { LemonSkeleton } from '@posthog/lemon-ui'
import { useValues } from 'kea'
import { getColorVar } from 'lib/colors'
-import { FEATURE_FLAGS } from 'lib/constants'
import { IconTrendingDown, IconTrendingFlat } from 'lib/lemon-ui/icons'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { humanFriendlyDuration, humanFriendlyLargeNumber, isNotNil, range } from 'lib/utils'
import { useState } from 'react'
@@ -42,14 +40,13 @@ export function WebOverview(props: {
onData,
dataNodeCollectionId: dataNodeCollectionId ?? key,
})
- const { featureFlags } = useValues(featureFlagLogic)
const { response, responseLoading } = useValues(logic)
const webOverviewQueryResponse = response as WebOverviewQueryResponse | undefined
const samplingRate = webOverviewQueryResponse?.samplingRate
- const numSkeletons = props.query.conversionGoal ? 4 : featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LCP_SCORE] ? 6 : 5
+ const numSkeletons = props.query.conversionGoal ? 4 : 6
return (
<>
diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json
index c2d5aadc147bb..adf0ef5f77413 100644
--- a/frontend/src/queries/schema.json
+++ b/frontend/src/queries/schema.json
@@ -546,22 +546,6 @@
},
"type": "object"
},
- "AssistantCompareFilter": {
- "additionalProperties": false,
- "properties": {
- "compare": {
- "default": false,
- "description": "Whether to compare the current date range to a previous date range.",
- "type": "boolean"
- },
- "compare_to": {
- "default": "-7d",
- "description": "The date range to compare to. The value is a relative date. Examples of relative dates are: `-1y` for 1 year ago, `-14m` for 14 months ago, `-100w` for 100 weeks ago, `-14d` for 14 days ago, `-30h` for 30 hours ago.",
- "type": "string"
- }
- },
- "type": "object"
- },
"AssistantDateTimePropertyFilter": {
"additionalProperties": false,
"properties": {
@@ -585,7 +569,7 @@
"type": "string"
},
"AssistantEventType": {
- "enum": ["status", "message"],
+ "enum": ["status", "message", "conversation"],
"type": "string"
},
"AssistantFunnelsBreakdownFilter": {
@@ -742,7 +726,7 @@
"description": "Breakdown the chart by a property"
},
"dateRange": {
- "$ref": "#/definitions/AssistantInsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -1043,27 +1027,11 @@
}
]
},
- "AssistantInsightDateRange": {
- "additionalProperties": false,
- "properties": {
- "date_from": {
- "default": "-7d",
- "description": "Start date. The value can be:\n- a relative date. Examples of relative dates are: `-1y` for 1 year ago, `-14m` for 14 months ago, `-1w` for 1 week ago, `-14d` for 14 days ago, `-30h` for 30 hours ago.\n- an absolute ISO 8601 date string. a constant `yStart` for the current year start. a constant `mStart` for the current month start. a constant `dStart` for the current day start. Prefer using relative dates.",
- "type": ["string", "null"]
- },
- "date_to": {
- "default": null,
- "description": "Right boundary of the date range. Use `null` for the current date. You can not use relative dates here.",
- "type": ["string", "null"]
- }
- },
- "type": "object"
- },
"AssistantInsightsQueryBase": {
"additionalProperties": false,
"properties": {
"dateRange": {
- "$ref": "#/definitions/AssistantInsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -1092,9 +1060,8 @@
"content": {
"type": "string"
},
- "done": {
- "description": "We only need this \"done\" value to tell when the particular message is finished during its streaming. It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added.",
- "type": "boolean"
+ "id": {
+ "type": "string"
},
"type": {
"const": "ai",
@@ -1365,7 +1332,7 @@
"description": "Compare to date range"
},
"dateRange": {
- "$ref": "#/definitions/AssistantInsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -1469,6 +1436,15 @@
],
"type": "string"
},
+ "BaseAssistantMessage": {
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
"BaseMathType": {
"enum": [
"total",
@@ -2156,6 +2132,9 @@
"significant": {
"type": "boolean"
},
+ "stats_version": {
+ "type": "integer"
+ },
"timezone": {
"type": "string"
},
@@ -3803,9 +3782,12 @@
"additionalProperties": false,
"properties": {
"compare": {
+ "default": false,
+ "description": "Whether to compare the current date range to a previous date range.",
"type": "boolean"
},
"compare_to": {
+ "description": "The date range to compare to. The value is a relative date. Examples of relative dates are: `-1y` for 1 year ago, `-14m` for 14 months ago, `-100w` for 100 weeks ago, `-14d` for 14 days ago, `-30h` for 30 hours ago.",
"type": "string"
}
},
@@ -3947,6 +3929,26 @@
},
"type": "object"
},
+ "DataColorToken": {
+ "enum": [
+ "preset-1",
+ "preset-2",
+ "preset-3",
+ "preset-4",
+ "preset-5",
+ "preset-6",
+ "preset-7",
+ "preset-8",
+ "preset-9",
+ "preset-10",
+ "preset-11",
+ "preset-12",
+ "preset-13",
+ "preset-14",
+ "preset-15"
+ ],
+ "type": "string"
+ },
"DataTableNode": {
"additionalProperties": false,
"properties": {
@@ -4532,6 +4534,9 @@
"significant": {
"type": "boolean"
},
+ "stats_version": {
+ "type": "integer"
+ },
"variants": {
"items": {
"$ref": "#/definitions/ExperimentVariantFunnelsBaseStats"
@@ -5651,6 +5656,12 @@
"$ref": "#/definitions/HogQLQueryModifiers",
"description": "Modifiers used when performing the query"
},
+ "properties": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"response": {
"$ref": "#/definitions/EventTaxonomyQueryResponse"
}
@@ -6006,6 +6017,9 @@
},
"response": {
"$ref": "#/definitions/ExperimentFunnelsQueryResponse"
+ },
+ "stats_version": {
+ "type": "integer"
}
},
"required": ["funnels_query", "kind"],
@@ -6056,6 +6070,9 @@
"significant": {
"type": "boolean"
},
+ "stats_version": {
+ "type": "integer"
+ },
"variants": {
"items": {
"$ref": "#/definitions/ExperimentVariantFunnelsBaseStats"
@@ -6220,16 +6237,15 @@
"content": {
"type": "string"
},
- "done": {
- "const": true,
- "type": "boolean"
+ "id": {
+ "type": "string"
},
"type": {
"const": "ai/failure",
"type": "string"
}
},
- "required": ["type", "done"],
+ "required": ["type"],
"type": "object"
},
"FeaturePropertyFilter": {
@@ -6786,6 +6802,13 @@
"$ref": "#/definitions/FunnelLayout",
"default": "vertical"
},
+ "resultCustomizations": {
+ "additionalProperties": {
+ "$ref": "#/definitions/ResultCustomizationByValue"
+ },
+ "description": "Customizations for the appearance of result datasets.",
+ "type": "object"
+ },
"useUdf": {
"type": "boolean"
}
@@ -6872,8 +6895,12 @@
"$ref": "#/definitions/BreakdownFilter",
"description": "Breakdown of the events and actions"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -7265,6 +7292,12 @@
"query": {
"type": "string"
},
+ "table_names": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"warnings": {
"items": {
"$ref": "#/definitions/HogQLNotice"
@@ -7357,6 +7390,9 @@
"additionalProperties": false,
"description": "HogQL Query Options are automatically set per team. However, they can be overridden in the query.",
"properties": {
+ "bounceRateDurationSeconds": {
+ "type": "number"
+ },
"bounceRatePageViewMode": {
"enum": ["count_pageviews", "uniq_urls", "uniq_page_screen_autocaptures"],
"type": "string"
@@ -7553,17 +7589,15 @@
"content": {
"type": "string"
},
- "done": {
- "const": true,
- "description": "Human messages are only appended when done.",
- "type": "boolean"
+ "id": {
+ "type": "string"
},
"type": {
"const": "human",
"type": "string"
}
},
- "required": ["type", "content", "done"],
+ "required": ["type", "content"],
"type": "object"
},
"InsightActorsQuery": {
@@ -7786,24 +7820,6 @@
},
"type": "object"
},
- "InsightDateRange": {
- "additionalProperties": false,
- "properties": {
- "date_from": {
- "default": "-7d",
- "type": ["string", "null"]
- },
- "date_to": {
- "type": ["string", "null"]
- },
- "explicitDate": {
- "default": false,
- "description": "Whether the date_from and date_to should be used verbatim. Disables rounding to the start and end of period.",
- "type": ["boolean", "null"]
- }
- },
- "type": "object"
- },
"InsightFilter": {
"anyOf": [
{
@@ -7965,8 +7981,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8022,8 +8042,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8079,8 +8103,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8136,8 +8164,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8193,8 +8225,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8303,8 +8339,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -8712,8 +8752,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -9426,6 +9470,12 @@
"query": {
"type": "string"
},
+ "table_names": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
"warnings": {
"items": {
"$ref": "#/definitions/HogQLNotice"
@@ -9811,6 +9861,9 @@
"significant": {
"type": "boolean"
},
+ "stats_version": {
+ "type": "integer"
+ },
"variants": {
"items": {
"$ref": "#/definitions/ExperimentVariantFunnelsBaseStats"
@@ -10442,6 +10495,9 @@
"significant": {
"type": "boolean"
},
+ "stats_version": {
+ "type": "integer"
+ },
"variants": {
"items": {
"$ref": "#/definitions/ExperimentVariantFunnelsBaseStats"
@@ -11163,9 +11219,8 @@
"content": {
"type": "string"
},
- "done": {
- "const": true,
- "type": "boolean"
+ "id": {
+ "type": "string"
},
"substeps": {
"items": {
@@ -11178,7 +11233,7 @@
"type": "string"
}
},
- "required": ["type", "content", "done"],
+ "required": ["type", "content"],
"type": "object"
},
"RecordingOrder": {
@@ -11366,6 +11421,58 @@
}
]
},
+ "ResultCustomization": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/ResultCustomizationByValue"
+ },
+ {
+ "$ref": "#/definitions/ResultCustomizationByPosition"
+ }
+ ]
+ },
+ "ResultCustomizationBase": {
+ "additionalProperties": false,
+ "properties": {
+ "color": {
+ "$ref": "#/definitions/DataColorToken"
+ }
+ },
+ "required": ["color"],
+ "type": "object"
+ },
+ "ResultCustomizationBy": {
+ "enum": ["value", "position"],
+ "type": "string"
+ },
+ "ResultCustomizationByPosition": {
+ "additionalProperties": false,
+ "properties": {
+ "assignmentBy": {
+ "const": "position",
+ "type": "string"
+ },
+ "color": {
+ "$ref": "#/definitions/DataColorToken"
+ }
+ },
+ "required": ["assignmentBy", "color"],
+ "type": "object"
+ },
+ "ResultCustomizationByValue": {
+ "additionalProperties": false,
+ "properties": {
+ "assignmentBy": {
+ "const": "value",
+ "type": "string"
+ },
+ "color": {
+ "$ref": "#/definitions/DataColorToken"
+ }
+ },
+ "required": ["assignmentBy", "color"],
+ "type": "object"
+ },
"RetentionEntity": {
"additionalProperties": false,
"properties": {
@@ -11480,8 +11587,12 @@
],
"description": "Groups aggregation"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -11625,17 +11736,15 @@
"content": {
"type": "string"
},
- "done": {
- "const": true,
- "description": "Router messages are not streamed, so they can only be done.",
- "type": "boolean"
+ "id": {
+ "type": "string"
},
"type": {
"const": "ai/router",
"type": "string"
}
},
- "required": ["type", "content", "done"],
+ "required": ["type", "content"],
"type": "object"
},
"SamplingRate": {
@@ -12152,8 +12261,12 @@
"$ref": "#/definitions/CompareFilter",
"description": "Compare to date range"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -12528,6 +12641,9 @@
"TrendsAlertConfig": {
"additionalProperties": false,
"properties": {
+ "check_ongoing_interval": {
+ "type": "boolean"
+ },
"series_index": {
"type": "integer"
},
@@ -12571,6 +12687,31 @@
},
"type": "array"
},
+ "resultCustomizationBy": {
+ "$ref": "#/definitions/ResultCustomizationBy",
+ "default": "value",
+ "description": "Wether result datasets are associated by their values or by their order."
+ },
+ "resultCustomizations": {
+ "anyOf": [
+ {
+ "additionalProperties": {
+ "$ref": "#/definitions/ResultCustomizationByValue"
+ },
+ "type": "object"
+ },
+ {
+ "additionalProperties": {
+ "$ref": "#/definitions/ResultCustomizationByPosition"
+ },
+ "propertyNames": {
+ "type": "string"
+ },
+ "type": "object"
+ }
+ ],
+ "description": "Customizations for the appearance of result datasets."
+ },
"showAlertThresholdLines": {
"default": false,
"type": "boolean"
@@ -12703,8 +12844,12 @@
],
"description": "Whether we should be comparing against a specific conversion goal"
},
+ "dataColorTheme": {
+ "description": "Colors used in the insight's visualization",
+ "type": ["number", "null"]
+ },
"dateRange": {
- "$ref": "#/definitions/InsightDateRange",
+ "$ref": "#/definitions/DateRange",
"description": "Date range for the query"
},
"filterTestAccounts": {
@@ -12815,8 +12960,11 @@
}
]
},
- "done": {
- "type": "boolean"
+ "id": {
+ "type": "string"
+ },
+ "initiator": {
+ "type": "string"
},
"plan": {
"type": "string"
@@ -12895,6 +13043,9 @@
"WebExternalClicksTableQuery": {
"additionalProperties": false,
"properties": {
+ "compareFilter": {
+ "$ref": "#/definitions/CompareFilter"
+ },
"conversionGoal": {
"anyOf": [
{
@@ -13008,6 +13159,9 @@
"WebGoalsQuery": {
"additionalProperties": false,
"properties": {
+ "compareFilter": {
+ "$ref": "#/definitions/CompareFilter"
+ },
"conversionGoal": {
"anyOf": [
{
@@ -13148,14 +13302,7 @@
"additionalProperties": false,
"properties": {
"compareFilter": {
- "anyOf": [
- {
- "$ref": "#/definitions/CompareFilter"
- },
- {
- "type": "null"
- }
- ]
+ "$ref": "#/definitions/CompareFilter"
},
"conversionGoal": {
"anyOf": [
@@ -13271,6 +13418,7 @@
"InitialUTMSourceMediumCampaign",
"Browser",
"OS",
+ "Viewport",
"DeviceType",
"Country",
"Region",
@@ -13287,14 +13435,7 @@
"$ref": "#/definitions/WebStatsBreakdown"
},
"compareFilter": {
- "anyOf": [
- {
- "$ref": "#/definitions/CompareFilter"
- },
- {
- "type": "null"
- }
- ]
+ "$ref": "#/definitions/CompareFilter"
},
"conversionGoal": {
"anyOf": [
@@ -13425,6 +13566,9 @@
}
},
"type": "object"
+ },
+ "numerical_key": {
+ "type": "string"
}
}
}
diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts
index 9f8f101449564..26b995a1c6d1d 100644
--- a/frontend/src/queries/schema.ts
+++ b/frontend/src/queries/schema.ts
@@ -1,3 +1,4 @@
+import { DataColorToken } from 'lib/colors'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import {
@@ -43,6 +44,10 @@ export { ChartDisplayCategory }
/** @asType integer */
type integer = number
+// Type alias for a numerical key. Needs to be reflected as string in json-schema, as JSON only supports string keys.
+/** @asType string */
+export type numerical_key = number
+
/**
* PostHog Query Schema definition.
*
@@ -236,6 +241,7 @@ export interface HogQLQueryModifiers {
s3TableUseInvalidColumns?: boolean
personsJoinMode?: 'inner' | 'left'
bounceRatePageViewMode?: 'count_pageviews' | 'uniq_urls' | 'uniq_page_screen_autocaptures'
+ bounceRateDurationSeconds?: number
sessionTableVersion?: 'auto' | 'v1' | 'v2'
propertyGroupsMode?: 'enabled' | 'disabled' | 'optimized'
useMaterializedViews?: boolean
@@ -367,6 +373,7 @@ export interface HogQLMetadataResponse {
warnings: HogQLNotice[]
notices: HogQLNotice[]
query_status?: never
+ table_names?: string[]
}
export type AutocompleteCompletionItemKind =
@@ -819,7 +826,7 @@ interface InsightVizNodeViewProps {
/** Base class for insight query nodes. Should not be used directly. */
export interface InsightsQueryBase> extends Node {
/** Date range for the query */
- dateRange?: InsightDateRange
+ dateRange?: DateRange
/**
* Exclude internal and test users by applying the respective filters
*
@@ -838,6 +845,8 @@ export interface InsightsQueryBase> ex
aggregation_group_type_index?: integer | null
/** Sampling rate */
samplingFactor?: number | null
+ /** Colors used in the insight's visualization */
+ dataColorTheme?: number | null
/** Modifiers used when performing the query */
modifiers?: HogQLQueryModifiers
}
@@ -845,6 +854,11 @@ export interface InsightsQueryBase> ex
/** `TrendsFilterType` minus everything inherited from `FilterType` and `shown_as` */
export type TrendsFilterLegacy = Omit
+export enum ResultCustomizationBy {
+ Value = 'value',
+ Position = 'position',
+}
+
export type TrendsFilter = {
/** @default 1 */
smoothingIntervals?: integer
@@ -868,6 +882,15 @@ export type TrendsFilter = {
showPercentStackView?: TrendsFilterLegacy['show_percent_stack_view']
yAxisScaleType?: TrendsFilterLegacy['y_axis_scale_type']
hiddenLegendIndexes?: integer[]
+ /**
+ * Wether result datasets are associated by their values or by their order.
+ * @default value
+ **/
+ resultCustomizationBy?: ResultCustomizationBy
+ /** Customizations for the appearance of result datasets. */
+ resultCustomizations?:
+ | Record
+ | Record
}
export const TRENDS_FILTER_PROPERTIES = new Set([
@@ -894,6 +917,20 @@ export interface TrendsQueryResponse extends AnalyticsQueryResponseBase
+export type ResultCustomizationBase = {
+ color: DataColorToken
+}
+
+export interface ResultCustomizationByPosition extends ResultCustomizationBase {
+ assignmentBy: ResultCustomizationBy.Position
+}
+
+export interface ResultCustomizationByValue extends ResultCustomizationBase {
+ assignmentBy: ResultCustomizationBy.Value
+}
+
+export type ResultCustomization = ResultCustomizationByValue | ResultCustomizationByPosition
+
export interface TrendsQuery extends InsightsQueryBase {
kind: NodeKind.TrendsQuery
/**
@@ -1004,31 +1041,11 @@ export type AssistantGroupPropertyFilter = AssistantBasePropertyFilter & {
export type AssistantPropertyFilter = AssistantGenericPropertyFilter | AssistantGroupPropertyFilter
-export interface AssistantInsightDateRange {
- /**
- * Start date. The value can be:
- * - a relative date. Examples of relative dates are: `-1y` for 1 year ago, `-14m` for 14 months ago, `-1w` for 1 week ago, `-14d` for 14 days ago, `-30h` for 30 hours ago.
- * - an absolute ISO 8601 date string.
- * a constant `yStart` for the current year start.
- * a constant `mStart` for the current month start.
- * a constant `dStart` for the current day start.
- * Prefer using relative dates.
- * @default -7d
- */
- date_from?: string | null
-
- /**
- * Right boundary of the date range. Use `null` for the current date. You can not use relative dates here.
- * @default null
- */
- date_to?: string | null
-}
-
export interface AssistantInsightsQueryBase {
/**
* Date range for the query
*/
- dateRange?: AssistantInsightDateRange
+ dateRange?: DateRange
/**
* Exclude internal and test users by applying the respective filters
@@ -1171,7 +1188,7 @@ export interface AssistantTrendsFilter {
yAxisScaleType?: TrendsFilterLegacy['y_axis_scale_type']
}
-export interface AssistantCompareFilter {
+export interface CompareFilter {
/**
* Whether to compare the current date range to a previous date range.
* @default false
@@ -1180,7 +1197,6 @@ export interface AssistantCompareFilter {
/**
* The date range to compare to. The value is a relative date. Examples of relative dates are: `-1y` for 1 year ago, `-14m` for 14 months ago, `-100w` for 100 weeks ago, `-14d` for 14 days ago, `-30h` for 30 hours ago.
- * @default -7d
*/
compare_to?: string
}
@@ -1388,6 +1404,8 @@ export type FunnelsFilter = {
/** @default total */
funnelStepReference?: FunnelsFilterLegacy['funnel_step_reference']
useUdf?: boolean
+ /** Customizations for the appearance of result datasets. */
+ resultCustomizations?: Record
}
export interface FunnelsQuery extends InsightsQueryBase {
@@ -1789,6 +1807,7 @@ interface WebAnalyticsQueryBase> extends DataNode<
dateRange?: DateRange
properties: WebAnalyticsPropertyFilters
conversionGoal?: WebAnalyticsConversionGoal | null
+ compareFilter?: CompareFilter
sampling?: {
enabled?: boolean
forceSamplingRate?: SamplingRate
@@ -1800,7 +1819,6 @@ interface WebAnalyticsQueryBase> extends DataNode<
export interface WebOverviewQuery extends WebAnalyticsQueryBase {
kind: NodeKind.WebOverviewQuery
- compareFilter?: CompareFilter | null
includeLCPScore?: boolean
}
@@ -1842,6 +1860,7 @@ export enum WebStatsBreakdown {
InitialUTMSourceMediumCampaign = 'InitialUTMSourceMediumCampaign',
Browser = 'Browser',
OS = 'OS',
+ Viewport = 'Viewport',
DeviceType = 'DeviceType',
Country = 'Country',
Region = 'Region',
@@ -1852,7 +1871,6 @@ export enum WebStatsBreakdown {
export interface WebStatsTableQuery extends WebAnalyticsQueryBase {
kind: NodeKind.WebStatsTableQuery
breakdownBy: WebStatsBreakdown
- compareFilter?: CompareFilter | null
includeScrollDepth?: boolean // automatically sets includeBounceRate to true
includeBounceRate?: boolean
doPathCleaning?: boolean
@@ -2024,6 +2042,7 @@ export interface ExperimentFunnelsQueryResponse {
significance_code: ExperimentSignificanceCode
expected_loss: number
credible_intervals: Record
+ stats_version?: integer
}
export type CachedExperimentFunnelsQueryResponse = CachedQueryResponse
@@ -2033,6 +2052,7 @@ export interface ExperimentFunnelsQuery extends DataNode {
@@ -2321,17 +2341,6 @@ export interface DateRange {
explicitDate?: boolean | null
}
-export interface InsightDateRange {
- /** @default -7d */
- date_from?: string | null
- date_to?: string | null
- /** Whether the date_from and date_to should be used verbatim. Disables
- * rounding to the start and end of period.
- * @default false
- * */
- explicitDate?: boolean | null
-}
-
export type MultipleBreakdownType = Extract
export interface Breakdown {
@@ -2358,11 +2367,6 @@ export interface BreakdownFilter {
breakdown_hide_other_aggregation?: boolean | null // hides the "other" field for trends
}
-export interface CompareFilter {
- compare?: boolean
- compare_to?: string
-}
-
// TODO: Rename to `DashboardFilters` for consistency with `HogQLFilters`
export interface DashboardFilter {
date_from?: string | null
@@ -2414,6 +2418,7 @@ export enum AlertCalculationInterval {
export interface TrendsAlertConfig {
type: 'TrendsAlertConfig'
series_index: integer
+ check_ongoing_interval?: boolean
}
export interface HogCompileResponse {
@@ -2457,6 +2462,7 @@ export type EventTaxonomyResponse = EventTaxonomyItem[]
export interface EventTaxonomyQuery extends DataNode {
kind: NodeKind.EventTaxonomyQuery
event: string
+ properties?: string[]
}
export type EventTaxonomyQueryResponse = AnalyticsQueryResponseBase
@@ -2489,48 +2495,41 @@ export enum AssistantMessageType {
Router = 'ai/router',
}
-export interface HumanMessage {
+export interface BaseAssistantMessage {
+ id?: string
+}
+
+export interface HumanMessage extends BaseAssistantMessage {
type: AssistantMessageType.Human
content: string
- /** Human messages are only appended when done. */
- done: true
}
-export interface AssistantMessage {
+export interface AssistantMessage extends BaseAssistantMessage {
type: AssistantMessageType.Assistant
content: string
- /**
- * We only need this "done" value to tell when the particular message is finished during its streaming.
- * It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added.
- */
- done?: boolean
}
-export interface ReasoningMessage {
+export interface ReasoningMessage extends BaseAssistantMessage {
type: AssistantMessageType.Reasoning
content: string
substeps?: string[]
- done: true
}
-export interface VisualizationMessage {
+export interface VisualizationMessage extends BaseAssistantMessage {
type: AssistantMessageType.Visualization
plan?: string
answer?: AssistantTrendsQuery | AssistantFunnelsQuery
- done?: boolean
+ initiator?: string
}
-export interface FailureMessage {
+export interface FailureMessage extends BaseAssistantMessage {
type: AssistantMessageType.Failure
content?: string
- done: true
}
-export interface RouterMessage {
+export interface RouterMessage extends BaseAssistantMessage {
type: AssistantMessageType.Router
content: string
- /** Router messages are not streamed, so they can only be done. */
- done: true
}
export type RootAssistantMessage =
@@ -2544,6 +2543,7 @@ export type RootAssistantMessage =
export enum AssistantEventType {
Status = 'status',
Message = 'message',
+ Conversation = 'conversation',
}
export enum AssistantGenerationStatusType {
diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts
index dc98b2ec2d3d1..b3a4e305811f0 100644
--- a/frontend/src/queries/utils.ts
+++ b/frontend/src/queries/utils.ts
@@ -33,6 +33,7 @@ import {
PersonsNode,
QuerySchema,
QueryStatusResponse,
+ ResultCustomizationBy,
RetentionQuery,
SavedInsightNode,
SessionAttributionExplorerQuery,
@@ -333,6 +334,13 @@ export const getYAxisScaleType = (query: InsightQueryNode): string | undefined =
return undefined
}
+export const getResultCustomizationBy = (query: InsightQueryNode): ResultCustomizationBy | undefined => {
+ if (isTrendsQuery(query)) {
+ return query.trendsFilter?.resultCustomizationBy
+ }
+ return undefined
+}
+
export const supportsPercentStackView = (q: InsightQueryNode | null | undefined): boolean =>
isTrendsQuery(q) && PERCENT_STACK_VIEW_DISPLAY_TYPE.includes(getDisplay(q) || ChartDisplayType.ActionsLineGraph)
diff --git a/frontend/src/scenes/ResourcePermissionModal.tsx b/frontend/src/scenes/FeatureFlagPermissions.tsx
similarity index 67%
rename from frontend/src/scenes/ResourcePermissionModal.tsx
rename to frontend/src/scenes/FeatureFlagPermissions.tsx
index b7361519f398d..24d4ebbe458d8 100644
--- a/frontend/src/scenes/ResourcePermissionModal.tsx
+++ b/frontend/src/scenes/FeatureFlagPermissions.tsx
@@ -1,12 +1,16 @@
-import { IconGear, IconTrash } from '@posthog/icons'
-import { LemonButton, LemonModal, LemonTable } from '@posthog/lemon-ui'
-import { useValues } from 'kea'
+import { IconGear, IconOpenSidebar, IconTrash } from '@posthog/icons'
+import { LemonBanner, LemonButton, LemonTable } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { TitleWithIcon } from 'lib/components/TitleWithIcon'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { LemonInputSelect, LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
import { LemonTableColumns } from 'lib/lemon-ui/LemonTable'
-import { AccessLevel, Resource, RoleType } from '~/types'
+import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
+import { AccessLevel, AvailableFeature, FeatureFlagType, Resource, RoleType, SidePanelTab } from '~/types'
+import { featureFlagPermissionsLogic } from './feature-flags/featureFlagPermissionsLogic'
import { permissionsLogic } from './settings/organization/Permissions/permissionsLogic'
import { rolesLogic } from './settings/organization/Permissions/Roles/rolesLogic'
import { urls } from './urls'
@@ -23,13 +27,7 @@ interface ResourcePermissionProps {
canEdit: boolean
}
-interface ResourcePermissionModalProps extends ResourcePermissionProps {
- title: string
- visible: boolean
- onClose: () => void
-}
-
-export function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOption[] {
+function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOption[] {
return roles.map((role) => ({
key: role.id,
label: `${role.name}`,
@@ -41,35 +39,52 @@ export function roleLemonSelectOptions(roles: RoleType[]): LemonInputSelectOptio
}))
}
-export function ResourcePermissionModal({
- title,
- visible,
- onClose,
- rolesToAdd,
- addableRoles,
- onChange,
- addableRolesLoading,
- onAdd,
- roles,
- deleteAssociatedRole,
- canEdit,
-}: ResourcePermissionModalProps): JSX.Element {
+export function FeatureFlagPermissions({ featureFlag }: { featureFlag: FeatureFlagType }): JSX.Element {
+ const { addableRoles, unfilteredAddableRolesLoading, rolesToAdd, derivedRoles } = useValues(
+ featureFlagPermissionsLogic({ flagId: featureFlag.id })
+ )
+ const { setRolesToAdd, addAssociatedRoles, deleteAssociatedRole } = useActions(
+ featureFlagPermissionsLogic({ flagId: featureFlag.id })
+ )
+ const { openSidePanel } = useActions(sidePanelStateLogic)
+
+ const newAccessControls = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL')
+ if (newAccessControls) {
+ if (!featureFlag.id) {
+ return Please save the feature flag before changing the access controls.
+ }
+ return (
+
+
+ Permissions have moved! We're rolling out our new access control system. Click below to open it.
+
+ }
+ onClick={() => {
+ openSidePanel(SidePanelTab.AccessControl)
+ }}
+ >
+ Open access control
+
+
+ )
+ }
+
return (
- <>
-
-
-
- >
+
+ setRolesToAdd(roleIds)}
+ rolesToAdd={rolesToAdd}
+ addableRoles={addableRoles}
+ addableRolesLoading={unfilteredAddableRolesLoading}
+ onAdd={() => addAssociatedRoles()}
+ roles={derivedRoles}
+ deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })}
+ canEdit={featureFlag.can_edit}
+ />
+
)
}
@@ -108,7 +123,7 @@ export function ResourcePermission({
icon={
}
- to={`${urls.settings('organization-rbac')}`}
+ to={`${urls.settings('organization-roles')}`}
targetBlank
size="small"
noPadding
diff --git a/frontend/src/scenes/actions/actionLogic.ts b/frontend/src/scenes/actions/actionLogic.ts
index a3101cb8d9daf..f650b616ba944 100644
--- a/frontend/src/scenes/actions/actionLogic.ts
+++ b/frontend/src/scenes/actions/actionLogic.ts
@@ -5,7 +5,7 @@ import { DataManagementTab } from 'scenes/data-management/DataManagementScene'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { ActionType, ActivityScope, Breadcrumb, HogFunctionType } from '~/types'
import { actionEditLogic } from './actionEditLogic'
@@ -106,13 +106,15 @@ export const actionLogic = kea([
(action) => action?.steps?.some((step) => step.properties?.find((p) => p.type === 'cohort')) ?? false,
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
(s) => [s.action],
- (action): ActivityFilters | null => {
+ (action): SidePanelSceneContext | null => {
return action?.id
? {
- scope: ActivityScope.ACTION,
- item_id: String(action.id),
+ activity_scope: ActivityScope.ACTION,
+ activity_item_id: `${action.id}`,
+ // access_control_resource: 'action',
+ // access_control_resource_id: `${action.id}`,
}
: null
},
diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts
index f6a646f64f7c4..cf58492e72677 100644
--- a/frontend/src/scenes/appScenes.ts
+++ b/frontend/src/scenes/appScenes.ts
@@ -28,6 +28,8 @@ export const appScenes: Record any> = {
[Scene.Group]: () => import('./groups/Group'),
[Scene.Action]: () => import('./actions/Action'),
[Scene.Experiments]: () => import('./experiments/Experiments'),
+ [Scene.ExperimentsSavedMetrics]: () => import('./experiments/SavedMetrics/SavedMetrics'),
+ [Scene.ExperimentsSavedMetric]: () => import('./experiments/SavedMetrics/SavedMetric'),
[Scene.Experiment]: () => import('./experiments/Experiment'),
[Scene.FeatureFlags]: () => import('./feature-flags/FeatureFlags'),
[Scene.FeatureManagement]: () => import('./feature-flags/FeatureManagement'),
@@ -41,7 +43,6 @@ export const appScenes: Record any> = {
[Scene.Survey]: () => import('./surveys/Survey'),
[Scene.CustomCss]: () => import('./themes/CustomCssScene'),
[Scene.SurveyTemplates]: () => import('./surveys/SurveyTemplates'),
- [Scene.DataModel]: () => import('./data-model/DataModelScene'),
[Scene.DataWarehouse]: () => import('./data-warehouse/external/DataWarehouseExternalScene'),
[Scene.SQLEditor]: () => import('./data-warehouse/editor/EditorScene'),
[Scene.DataWarehouseTable]: () => import('./data-warehouse/new/NewSourceWizard'),
diff --git a/frontend/src/scenes/authentication/TwoFactorSetupModal.tsx b/frontend/src/scenes/authentication/TwoFactorSetupModal.tsx
index 8da04b39ed0bd..ae63d8649e87d 100644
--- a/frontend/src/scenes/authentication/TwoFactorSetupModal.tsx
+++ b/frontend/src/scenes/authentication/TwoFactorSetupModal.tsx
@@ -1,35 +1,25 @@
import { useActions, useValues } from 'kea'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonModal } from 'lib/lemon-ui/LemonModal'
+import { membersLogic } from 'scenes/organization/membersLogic'
+import { userLogic } from 'scenes/userLogic'
import { twoFactorLogic } from './twoFactorLogic'
import { TwoFactorSetup } from './TwoFactorSetup'
-interface TwoFactorSetupModalProps {
- onSuccess: () => void
- closable?: boolean
- required?: boolean
- forceOpen?: boolean
-}
-
-export function TwoFactorSetupModal({
- onSuccess,
- closable = true,
- required = false,
- forceOpen = false,
-}: TwoFactorSetupModalProps): JSX.Element {
- const { isTwoFactorSetupModalOpen } = useValues(twoFactorLogic)
- const { toggleTwoFactorSetupModal } = useActions(twoFactorLogic)
+export function TwoFactorSetupModal(): JSX.Element {
+ const { isTwoFactorSetupModalOpen, forceOpenTwoFactorSetupModal } = useValues(twoFactorLogic)
+ const { closeTwoFactorSetupModal } = useActions(twoFactorLogic)
return (
toggleTwoFactorSetupModal(false) : undefined}
- closable={closable}
+ isOpen={isTwoFactorSetupModalOpen || forceOpenTwoFactorSetupModal}
+ onClose={!forceOpenTwoFactorSetupModal ? () => closeTwoFactorSetupModal() : undefined}
+ closable={!forceOpenTwoFactorSetupModal}
>
- {required && (
+ {forceOpenTwoFactorSetupModal && (
Your organization requires you to set up 2FA.
@@ -37,10 +27,9 @@ export function TwoFactorSetupModal({
Use an authenticator app like Google Authenticator or 1Password to scan the QR code below.
{
- toggleTwoFactorSetupModal(false)
- if (onSuccess) {
- onSuccess()
- }
+ closeTwoFactorSetupModal()
+ userLogic.actions.loadUser()
+ membersLogic.actions.loadAllMembers()
}}
/>
diff --git a/frontend/src/scenes/authentication/twoFactorLogic.ts b/frontend/src/scenes/authentication/twoFactorLogic.ts
index 43d31a7f4d189..37c331b809868 100644
--- a/frontend/src/scenes/authentication/twoFactorLogic.ts
+++ b/frontend/src/scenes/authentication/twoFactorLogic.ts
@@ -4,7 +4,9 @@ import { forms } from 'kea-forms'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+import { membersLogic } from 'scenes/organization/membersLogic'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
+import { userLogic } from 'scenes/userLogic'
import type { twoFactorLogicType } from './twoFactorLogicType'
@@ -26,7 +28,8 @@ export const twoFactorLogic = kea([
path(['scenes', 'authentication', 'loginLogic']),
props({} as TwoFactorLogicProps),
connect({
- values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags']],
+ values: [preflightLogic, ['preflight'], featureFlagLogic, ['featureFlags'], userLogic, ['user']],
+ actions: [userLogic, ['loadUser'], membersLogic, ['loadAllMembers']],
}),
actions({
setGeneralError: (code: string, detail: string) => ({ code, detail }),
@@ -34,16 +37,24 @@ export const twoFactorLogic = kea([
loadStatus: true,
generateBackupCodes: true,
disable2FA: true,
- toggleTwoFactorSetupModal: (open: boolean) => ({ open }),
+ openTwoFactorSetupModal: (forceOpen?: boolean) => ({ forceOpen }),
+ closeTwoFactorSetupModal: true,
toggleDisable2FAModal: (open: boolean) => ({ open }),
toggleBackupCodesModal: (open: boolean) => ({ open }),
- startSetup: true,
}),
reducers({
isTwoFactorSetupModalOpen: [
false,
{
- toggleTwoFactorSetupModal: (_, { open }) => open,
+ openTwoFactorSetupModal: () => true,
+ closeTwoFactorSetupModal: () => false,
+ },
+ ],
+ forceOpenTwoFactorSetupModal: [
+ false,
+ {
+ openTwoFactorSetupModal: (_, { forceOpen }) => !!forceOpen,
+ closeTwoFactorSetupModal: () => false,
},
],
isDisable2FAModalOpen: [
@@ -89,11 +100,9 @@ export const twoFactorLogic = kea([
startSetup: [
{},
{
- toggleTwoFactorSetupModal: async ({ open }, breakpoint) => {
- if (open) {
- breakpoint()
- await api.get('api/users/@me/two_factor_start_setup/')
- }
+ openTwoFactorSetupModal: async (_, breakpoint) => {
+ breakpoint()
+ await api.get('api/users/@me/two_factor_start_setup/')
return { status: 'completed' }
},
},
@@ -144,6 +153,10 @@ export const twoFactorLogic = kea([
await api.create('api/users/@me/two_factor_disable/')
lemonToast.success('2FA disabled successfully')
actions.loadStatus()
+
+ // Refresh user and members
+ actions.loadUser()
+ actions.loadAllMembers()
} catch (e) {
const { code, detail } = e as Record
actions.setGeneralError(code, detail)
@@ -153,19 +166,17 @@ export const twoFactorLogic = kea([
generateBackupCodesSuccess: () => {
lemonToast.success('Backup codes generated successfully')
},
- toggleTwoFactorSetupModal: ({ open }) => {
- if (!open) {
- // Clear the form when closing the modal
- actions.resetToken()
- }
- },
- startSetup: async () => {
- await api.get('api/users/@me/two_factor_start_setup/')
+ closeTwoFactorSetupModal: () => {
+ // Clear the form when closing the modal
+ actions.resetToken()
},
})),
- afterMount(({ actions }) => {
- actions.startSetup()
+ afterMount(({ actions, values }) => {
actions.loadStatus()
+
+ if (values.user && values.user.organization?.enforce_2fa && !values.user.is_2fa_enabled) {
+ actions.openTwoFactorSetupModal(true)
+ }
}),
])
diff --git a/frontend/src/scenes/dashboard/Dashboard.tsx b/frontend/src/scenes/dashboard/Dashboard.tsx
index 5f9e59ee897de..0906d2c948ebb 100644
--- a/frontend/src/scenes/dashboard/Dashboard.tsx
+++ b/frontend/src/scenes/dashboard/Dashboard.tsx
@@ -1,5 +1,5 @@
import { LemonButton } from '@posthog/lemon-ui'
-import { BindLogic, useActions, useValues } from 'kea'
+import { BindLogic, useActions, useMountedLogic, useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
import { useKeyboardHotkeys } from 'lib/hooks/useKeyboardHotkeys'
import { DashboardEventSource } from 'lib/utils/eventUsageLogic'
@@ -8,12 +8,13 @@ import { DashboardEditBar } from 'scenes/dashboard/DashboardEditBar'
import { DashboardItems } from 'scenes/dashboard/DashboardItems'
import { dashboardLogic, DashboardLogicProps } from 'scenes/dashboard/dashboardLogic'
import { DashboardReloadAction, LastRefreshText } from 'scenes/dashboard/DashboardReloadAction'
+import { dataThemeLogic } from 'scenes/dataThemeLogic'
import { InsightErrorState } from 'scenes/insights/EmptyStates'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { VariablesForDashboard } from '~/queries/nodes/DataVisualization/Components/Variables/Variables'
-import { DashboardMode, DashboardPlacement, DashboardType, QueryBasedInsightModel } from '~/types'
+import { DashboardMode, DashboardPlacement, DashboardType, DataColorThemeModel, QueryBasedInsightModel } from '~/types'
import { DashboardHeader } from './DashboardHeader'
import { EmptyDashboardComponent } from './EmptyDashboardComponent'
@@ -22,6 +23,7 @@ interface DashboardProps {
id?: string
dashboard?: DashboardType
placement?: DashboardPlacement
+ themes?: DataColorThemeModel[]
}
export const scene: SceneExport = {
@@ -33,7 +35,9 @@ export const scene: SceneExport = {
}),
}
-export function Dashboard({ id, dashboard, placement }: DashboardProps = {}): JSX.Element {
+export function Dashboard({ id, dashboard, placement, themes }: DashboardProps = {}): JSX.Element {
+ useMountedLogic(dataThemeLogic({ themes }))
+
return (
diff --git a/frontend/src/scenes/dashboard/DashboardCollaborators.tsx b/frontend/src/scenes/dashboard/DashboardCollaborators.tsx
index 048d668bc71fd..75b83719330d4 100644
--- a/frontend/src/scenes/dashboard/DashboardCollaborators.tsx
+++ b/frontend/src/scenes/dashboard/DashboardCollaborators.tsx
@@ -1,8 +1,10 @@
-import { IconLock, IconTrash, IconUnlock } from '@posthog/icons'
+import { IconLock, IconOpenSidebar, IconTrash, IconUnlock } from '@posthog/icons'
import { useActions, useValues } from 'kea'
+import { router } from 'kea-router'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { usersLemonSelectOptions } from 'lib/components/UserSelectItem'
import { DashboardPrivilegeLevel, DashboardRestrictionLevel, privilegeLevelToName } from 'lib/constants'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect'
@@ -10,8 +12,10 @@ import { LemonSelect, LemonSelectOptions } from 'lib/lemon-ui/LemonSelect'
import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
+import { urls } from 'scenes/urls'
-import { AvailableFeature, DashboardType, FusedDashboardCollaboratorType, UserType } from '~/types'
+import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
+import { AvailableFeature, DashboardType, FusedDashboardCollaboratorType, SidePanelTab, UserType } from '~/types'
import { dashboardCollaboratorsLogic } from './dashboardCollaboratorsLogic'
@@ -36,73 +40,96 @@ export function DashboardCollaboration({ dashboardId }: { dashboardId: Dashboard
const { deleteExplicitCollaborator, setExplicitCollaboratorsToBeAdded, addExplicitCollaborators } = useActions(
dashboardCollaboratorsLogic({ dashboardId })
)
+ const { push } = useActions(router)
+ const { openSidePanel } = useActions(sidePanelStateLogic)
+
+ const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL')
+
+ if (!dashboard) {
+ return null
+ }
+
+ if (newAccessControl) {
+ return (
+
+
Access control
+
+ Permissions have moved! We're rolling out our new access control system. Click below to open it.
+
+ }
+ onClick={() => {
+ openSidePanel(SidePanelTab.AccessControl)
+ push(urls.dashboard(dashboard.id))
+ }}
+ >
+ Open access control
+
+
+ )
+ }
return (
- dashboard && (
- <>
-
- {(!canEditDashboard || !canRestrictDashboard) && (
-
- {canEditDashboard
- ? "You aren't allowed to change the restriction level ā only the dashboard owner and project admins can."
- : "You aren't allowed to change sharing settings ā only dashboard collaborators with edit settings can."}
-
- )}
-
- triggerDashboardUpdate({
- restriction_level: newValue,
- })
- }
- options={DASHBOARD_RESTRICTION_OPTIONS}
- loading={dashboardLoading}
- fullWidth
- disabled={!canRestrictDashboard}
- />
- {dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && (
-
-
Collaborators
- {canEditDashboard && (
-
-
-
- setExplicitCollaboratorsToBeAdded(newValues)
- }
- mode="multiple"
- data-attr="subscribed-emails"
- options={usersLemonSelectOptions(addableMembers, 'uuid')}
- />
-
-
addExplicitCollaborators()}
- >
- Add
-
-
- )}
-
Project members with access
-
- {allCollaborators.map((collaborator) => (
-
- ))}
+
+ {(!canEditDashboard || !canRestrictDashboard) && (
+
+ {canEditDashboard
+ ? "You aren't allowed to change the restriction level ā only the dashboard owner and project admins can."
+ : "You aren't allowed to change sharing settings ā only dashboard collaborators with edit settings can."}
+
+ )}
+
+ triggerDashboardUpdate({
+ restriction_level: newValue,
+ })
+ }
+ options={DASHBOARD_RESTRICTION_OPTIONS}
+ loading={dashboardLoading}
+ fullWidth
+ disabled={!canRestrictDashboard}
+ />
+ {dashboard.restriction_level > DashboardRestrictionLevel.EveryoneInProjectCanEdit && (
+
+
Collaborators
+ {canEditDashboard && (
+
+
+ setExplicitCollaboratorsToBeAdded(newValues)}
+ mode="multiple"
+ data-attr="subscribed-emails"
+ options={usersLemonSelectOptions(addableMembers, 'uuid')}
+ />
+
addExplicitCollaborators()}
+ >
+ Add
+
)}
-
- >
- )
+
Project members with access
+
+ {allCollaborators.map((collaborator) => (
+
+ ))}
+
+
+ )}
+
)
}
diff --git a/frontend/src/scenes/dashboard/dashboardLogic.tsx b/frontend/src/scenes/dashboard/dashboardLogic.tsx
index 4addf1f04f4c0..0b6236931cf2b 100644
--- a/frontend/src/scenes/dashboard/dashboardLogic.tsx
+++ b/frontend/src/scenes/dashboard/dashboardLogic.tsx
@@ -30,6 +30,7 @@ import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { dashboardsModel } from '~/models/dashboardsModel'
import { insightsModel } from '~/models/insightsModel'
import { variableDataLogic } from '~/queries/nodes/DataVisualization/Components/Variables/variableDataLogic'
@@ -38,6 +39,7 @@ import { getQueryBasedDashboard, getQueryBasedInsightModel } from '~/queries/nod
import { pollForResults } from '~/queries/query'
import { DashboardFilter, DataVisualizationNode, HogQLVariable, NodeKind, RefreshType } from '~/queries/schema'
import {
+ ActivityScope,
AnyPropertyFilter,
Breadcrumb,
DashboardLayoutSize,
@@ -991,6 +993,21 @@ export const dashboardLogic = kea
([
},
],
],
+
+ [SIDE_PANEL_CONTEXT_KEY]: [
+ (s) => [s.dashboard],
+ (dashboard): SidePanelSceneContext | null => {
+ return dashboard
+ ? {
+ activity_scope: ActivityScope.DASHBOARD,
+ activity_item_id: `${dashboard.id}`,
+ access_control_resource: 'dashboard',
+ access_control_resource_id: `${dashboard.id}`,
+ }
+ : null
+ },
+ ],
+
sortTilesByLayout: [
(s) => [s.layoutForItem],
(layoutForItem) => (tiles: Array) => {
diff --git a/frontend/src/scenes/data-model/DataModelScene.tsx b/frontend/src/scenes/data-model/DataModelScene.tsx
deleted file mode 100644
index b2e953212ccc4..0000000000000
--- a/frontend/src/scenes/data-model/DataModelScene.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { useValues } from 'kea'
-import { ViewLinkModal } from 'scenes/data-warehouse/ViewLinkModal'
-import { SceneExport } from 'scenes/sceneTypes'
-
-import { dataModelSceneLogic } from './dataModelSceneLogic'
-import NodeCanvasWithTable from './NodeCanvasWithTable'
-
-export const scene: SceneExport = {
- component: DataModelScene,
- logic: dataModelSceneLogic,
-}
-
-export function DataModelScene(): JSX.Element {
- const { simplifiedPersonFields, joinedFieldsAsNodes, allNodes } = useValues(dataModelSceneLogic)
-
- return (
- <>
-
-
- >
- )
-}
diff --git a/frontend/src/scenes/data-model/NodeCanvas.tsx b/frontend/src/scenes/data-model/NodeCanvas.tsx
new file mode 100644
index 0000000000000..1c8d0c72a23e2
--- /dev/null
+++ b/frontend/src/scenes/data-model/NodeCanvas.tsx
@@ -0,0 +1,311 @@
+import { clsx } from 'clsx'
+import { useEffect, useRef, useState } from 'react'
+
+import { Edge, Node, NodePosition, NodePositionWithBounds, NodeWithDepth } from './types'
+
+const VERTICAL_SPACING = 300
+const HORIZONTAL_SPACING = 400
+
+// Core graph layout calculation functions
+const assignDepths = (nodes: Node[]): NodeWithDepth[] => {
+ const nodeMap: { [id: string]: NodeWithDepth } = {}
+
+ nodes.forEach((node) => {
+ nodeMap[node.nodeId] = { ...node, depth: -1 }
+ })
+
+ const assignDepthRecursive = (nodeId: string, currentDepth: number): void => {
+ const node = nodeMap[nodeId]
+ if (!node) {
+ return
+ }
+ node.depth = currentDepth
+
+ node.leaf.forEach((leafId) => {
+ if (nodeMap[leafId]) {
+ assignDepthRecursive(leafId, currentDepth + 1)
+ }
+ })
+ }
+
+ nodes.forEach((node) => {
+ if (nodeMap[node.nodeId].depth === -1) {
+ assignDepthRecursive(node.nodeId, 0)
+ }
+ })
+
+ return Object.values(nodeMap)
+}
+
+const calculateNodePositions = (nodesWithDepth: NodeWithDepth[]): NodePosition[] => {
+ const padding = 50
+ nodesWithDepth.sort((a, b) => a.depth - b.depth)
+
+ const nodePositions: NodePosition[] = []
+ const visited: string[] = []
+
+ const dfs = (nodeId: string, row: number = 0): number => {
+ if (visited.includes(nodeId)) {
+ return row
+ }
+ visited.push(nodeId)
+
+ const node = nodesWithDepth.find((n) => n.nodeId === nodeId)
+ if (!node) {
+ return row
+ }
+
+ const nodePosition = {
+ ...node,
+ position: {
+ x: padding + node.depth * HORIZONTAL_SPACING,
+ y: padding + row * VERTICAL_SPACING,
+ },
+ }
+
+ nodePositions.push(nodePosition)
+
+ let maxRow = row
+ node.leaf
+ .filter((leafId) => !leafId.includes('_joined'))
+ .forEach((leafId, index) => {
+ dfs(leafId, row + index)
+ maxRow = Math.max(maxRow, row + index)
+ })
+
+ return maxRow
+ }
+
+ let maxRow = 0
+ nodesWithDepth.forEach((node) => {
+ if (node.depth === 0) {
+ maxRow = dfs(node.nodeId, maxRow) + 1
+ }
+ })
+
+ return nodePositions
+}
+
+const calculateBound = (node: NodePosition, ref: HTMLDivElement | null): NodePositionWithBounds => {
+ if (!ref) {
+ return {
+ ...node,
+ left: null,
+ right: null,
+ }
+ }
+
+ const { x, y } = node.position
+ const { width, height } = ref.getBoundingClientRect()
+ return {
+ ...node,
+ left: { x, y: y + height / 2 },
+ right: { x: x + width, y: y + height / 2 },
+ }
+}
+
+const calculateEdgesFromTo = (from: NodePositionWithBounds, to: NodePositionWithBounds): Edge[] => {
+ if (!from.right || !to.left) {
+ return []
+ }
+
+ const edges = []
+ edges.push({
+ from: from.right,
+ to: to.left,
+ })
+
+ return edges
+}
+
+const calculateEdges = (nodeRefs: (HTMLDivElement | null)[], nodes: NodePosition[]): Edge[] => {
+ const nodes_map = nodes.reduce((acc: Record, node) => {
+ acc[node.nodeId] = node
+ return acc
+ }, {})
+
+ const dfs = (nodeId: string, visited: Set = new Set()): Edge[] => {
+ if (visited.has(nodeId)) {
+ return []
+ }
+ visited.add(nodeId)
+
+ const node = nodes_map[nodeId]
+ if (!node) {
+ return []
+ }
+
+ const nodeRef = nodeRefs.find((ref) => ref?.id === nodeId)
+ if (!nodeRef) {
+ return []
+ }
+
+ const edges: Edge[] = []
+ const fromWithBounds = calculateBound(node, nodeRef)
+
+ for (const leafId of node.leaf) {
+ const toNode = nodes_map[leafId]
+ const toRef = nodeRefs.find((ref) => ref?.id === leafId)
+ if (toNode && toRef) {
+ const toWithBounds = calculateBound(toNode, toRef)
+ edges.push(...calculateEdgesFromTo(fromWithBounds, toWithBounds))
+ }
+
+ edges.push(...dfs(leafId, visited))
+ }
+
+ return edges
+ }
+
+ const edges: Edge[] = []
+ const visited = new Set()
+
+ for (const node of nodes) {
+ if (!visited.has(node.nodeId)) {
+ edges.push(...dfs(node.nodeId, visited))
+ }
+ }
+
+ return edges
+}
+
+interface NodeCanvasProps {
+ nodes: T[]
+ renderNode: (node: T & NodePosition, ref: (el: HTMLDivElement | null) => void) => JSX.Element
+}
+
+export function NodeCanvas({ nodes, renderNode }: NodeCanvasProps): JSX.Element {
+ const canvasRef = useRef(null)
+ const [isDragging, setIsDragging] = useState(false)
+ const [offset, setOffset] = useState({ x: 0, y: 0 })
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
+ const nodeRefs = useRef<(HTMLDivElement | null)[]>(Array(nodes.length).fill(null))
+ const [nodePositions, setNodePositions] = useState([])
+ const [edges, setEdges] = useState([])
+
+ useEffect(() => {
+ const nodesWithDepth = assignDepths(nodes)
+ const positions = calculateNodePositions(nodesWithDepth)
+ setNodePositions(positions)
+ }, [nodes, offset])
+
+ useEffect(() => {
+ const allNodes = [...nodePositions]
+ const calculatedEdges = calculateEdges([...nodeRefs.current], allNodes)
+ setEdges(calculatedEdges)
+ }, [nodePositions])
+
+ const drawGrid = (ctx: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number): void => {
+ ctx.fillStyle = '#000000'
+ ctx.imageSmoothingEnabled = true
+ const dotSize = 0.5
+ const spacing = 10
+
+ for (let x = offset.x % spacing; x < canvasWidth; x += spacing) {
+ for (let y = offset.y % spacing; y < canvasHeight; y += spacing) {
+ ctx.fillRect(x, y, dotSize, dotSize)
+ }
+ }
+ }
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) {
+ return
+ }
+
+ const ctx = canvas.getContext('2d')
+ if (!ctx) {
+ return
+ }
+
+ const { width, height } = canvas.getBoundingClientRect()
+ canvas.width = width
+ canvas.height = height
+ drawGrid(ctx, width, height)
+
+ const handleResize = (): void => {
+ if (canvas) {
+ const { width, height } = canvas.getBoundingClientRect()
+ canvas.width = width
+ canvas.height = height
+ const ctx = canvas.getContext('2d')
+ if (ctx) {
+ drawGrid(ctx, width, height)
+ }
+ }
+ }
+
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [offset, nodePositions])
+
+ const handleMouseDown = (e: React.MouseEvent): void => {
+ setIsDragging(true)
+ setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y })
+ }
+
+ const handleMouseMove = (e: React.MouseEvent): void => {
+ if (!isDragging) {
+ return
+ }
+ const newOffset = {
+ x: e.clientX - dragStart.x,
+ y: e.clientY - dragStart.y,
+ }
+ setOffset(newOffset)
+ }
+
+ const handleMouseUp = (): void => {
+ setIsDragging(false)
+ }
+
+ return (
+
+
+
+ {edges.map((edge, index) => {
+ const controlPoint1X = edge.from.x + offset.x + (edge.to.x - edge.from.x) / 3
+ const controlPoint1Y = edge.from.y + offset.y
+ const controlPoint2X = edge.to.x + offset.x - (edge.to.x - edge.from.x) / 3
+ const controlPoint2Y = edge.to.y + offset.y
+ return (
+
+ )
+ })}
+
+ {nodePositions.map((nodePosition, idx) => (
+
+ {renderNode(nodePosition as T & NodePosition, (el) => {
+ nodeRefs.current[idx] = el
+ nodeRefs.current[idx]?.setAttribute('id', nodePosition.nodeId)
+ })}
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/scenes/data-model/NodeCanvasWithTable.tsx b/frontend/src/scenes/data-model/NodeCanvasWithTable.tsx
deleted file mode 100644
index 77850ec3af75c..0000000000000
--- a/frontend/src/scenes/data-model/NodeCanvasWithTable.tsx
+++ /dev/null
@@ -1,464 +0,0 @@
-import { LemonButton, LemonTag } from '@posthog/lemon-ui'
-import clsx from 'clsx'
-import { useActions, useValues } from 'kea'
-import { humanFriendlyDetailedTime } from 'lib/utils'
-import { useEffect, useRef, useState } from 'react'
-import { dataWarehouseViewsLogic } from 'scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic'
-import { StatusTagSetting } from 'scenes/data-warehouse/settings/DataWarehouseManagedSourcesTable'
-
-import GenericNode from './Node'
-import { FixedField, JoinedField, TableFields } from './TableFields'
-import { Edge, Node, NodePosition, NodePositionWithBounds, NodeWithDepth, Position } from './types'
-
-const VERTICAL_SPACING = 150
-const HORIZONTAL_SPACING = 250
-
-// TODO: Refactor this to be done in the backend
-const assignDepths = (nodes: Node[]): NodeWithDepth[] => {
- const nodeMap: { [id: string]: NodeWithDepth } = {}
-
- // Initialize all nodes with depth -1
- nodes.forEach((node) => {
- nodeMap[node.nodeId] = { ...node, depth: -1 }
- })
-
- const assignDepthRecursive = (nodeId: string, currentDepth: number): void => {
- const node = nodeMap[nodeId]
- if (!node) {
- return
- } // Skip if node doesn't exist or already processed
-
- node.depth = currentDepth
-
- // Process leaf nodes
- node.leaf.forEach((leafId) => {
- if (nodeMap[leafId]) {
- assignDepthRecursive(leafId, currentDepth + 1)
- }
- })
- }
-
- // Start assigning depths from each unprocessed node
- nodes.forEach((node) => {
- if (nodeMap[node.nodeId].depth === -1) {
- assignDepthRecursive(node.nodeId, 0)
- }
- })
-
- return Object.values(nodeMap)
-}
-
-const calculateNodePositions = (nodesWithDepth: NodeWithDepth[]): NodePosition[] => {
- const padding = 50
- // Order nodes by depth
- nodesWithDepth.sort((a, b) => a.depth - b.depth)
-
- const nodePositions: NodePosition[] = []
- const visited: string[] = []
-
- const dfs = (nodeId: string, row: number = 0): number => {
- if (visited.includes(nodeId)) {
- return row
- }
- visited.push(nodeId)
-
- const node = nodesWithDepth.find((n) => n.nodeId === nodeId)
- if (!node) {
- return row
- }
-
- const nodePosition = {
- ...node,
- position: {
- x: padding + node.depth * HORIZONTAL_SPACING,
- y: padding + row * VERTICAL_SPACING,
- },
- }
-
- nodePositions.push(nodePosition)
-
- let maxRow = row
- node.leaf
- .filter((leafId) => !leafId.includes('_joined'))
- .forEach((leafId, index) => {
- dfs(leafId, row + index)
- maxRow = Math.max(maxRow, row + index)
- })
-
- return maxRow
- }
-
- let maxRow = 0
-
- nodesWithDepth.forEach((node) => {
- if (node.depth === 0) {
- maxRow = dfs(node.nodeId, maxRow) + 1
- }
- })
-
- return nodePositions
-}
-
-const calculateTablePosition = (nodePositions: NodePosition[]): Position => {
- // Find the node with the maximum x position
- const farthestNode = nodePositions.reduce((max, node) => (node.position.x > max.position.x ? node : max), {
- position: { x: 0, y: 0 },
- })
-
- // Calculate the table position to be slightly to the right of the farthest node
- const tablePosition: Position = {
- x: farthestNode.position.x + 300, // Add some padding
- y: 100, // Fixed y position for the table
- }
-
- return tablePosition
-}
-
-const calculateEdges = (nodeRefs: (HTMLDivElement | null)[], nodes: NodePosition[]): Edge[] => {
- const nodes_map = nodes.reduce((acc: Record, node) => {
- acc[node.nodeId] = node
- return acc
- }, {})
-
- const dfs = (nodeId: string, visited: Set = new Set(), depth: number = 0): Edge[] => {
- if (visited.has(nodeId)) {
- return []
- }
- visited.add(nodeId)
-
- const node = nodes_map[nodeId]
- if (!node) {
- return []
- }
-
- const nodeRef = nodeRefs.find((ref) => ref?.id === nodeId)
- if (!nodeRef) {
- return []
- }
-
- const edges: Edge[] = []
- const fromWithBounds = calculateBound(node, nodeRef)
-
- for (let i = 0; i < node.leaf.length; i++) {
- const leafId = node.leaf[i]
- const toNode = nodes_map[leafId]
- const toRef = nodeRefs.find((ref) => ref?.id === leafId)
-
- if (toNode && toRef) {
- const toWithBounds = calculateBound(toNode, toRef)
- const newEdges = calculateEdgesFromTo(fromWithBounds, toWithBounds)
- edges.push(...newEdges)
- }
-
- depth = i > 0 ? depth + 1 : depth
- edges.push(...dfs(leafId, visited, depth))
- }
-
- return edges
- }
-
- const edges: Edge[] = []
-
- const visited = new Set()
- for (const node of nodes) {
- if (!visited.has(node.nodeId)) {
- edges.push(...dfs(node.nodeId, visited))
- }
- }
-
- return edges
-}
-
-const calculateBound = (node: NodePosition, ref: HTMLDivElement | null): NodePositionWithBounds => {
- if (!ref) {
- return {
- ...node,
- left: null,
- right: null,
- }
- }
-
- const { x, y } = node.position
- const { width, height } = ref.getBoundingClientRect()
- return {
- ...node,
- left: { x, y: y + height / 2 },
- right: { x: x + width, y: y + height / 2 },
- }
-}
-
-const calculateEdgesFromTo = (from: NodePositionWithBounds, to: NodePositionWithBounds): Edge[] => {
- if (!from.right || !to.left) {
- return []
- }
-
- const edges = []
- edges.push({
- from: from.right,
- to: to.left,
- })
-
- return edges
-}
-
-interface ScrollableDraggableCanvasProps {
- nodes: Node[]
- fixedFields: FixedField[]
- joinedFields: JoinedField[]
- tableName: string
-}
-
-const NodeCanvasWithTable = ({
- nodes,
- fixedFields,
- joinedFields,
- tableName,
-}: ScrollableDraggableCanvasProps): JSX.Element => {
- // would like to keep nodecanvas logicless
- const { dataWarehouseSavedQueryMapById } = useValues(dataWarehouseViewsLogic)
- const { runDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic)
-
- const canvasRef = useRef(null)
- const [isDragging, setIsDragging] = useState(false)
- const [offset, setOffset] = useState({ x: 0, y: 0 })
- const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
- const rowsRefs = useRef<(HTMLDivElement | null)[]>(Array(joinedFields.length).fill(null))
- const nodeRefs = useRef<(HTMLDivElement | null)[]>(Array(nodes.length).fill(null))
- const tableNodeRef = useRef(null)
- const [nodePositions, setNodePositions] = useState([])
- const [tablePosition, setTablePosition] = useState({ x: 0, y: 0 })
- const [edges, setEdges] = useState([])
-
- useEffect(() => {
- const nodesWithDepth = assignDepths(nodes)
- const nodePositions = calculateNodePositions(nodesWithDepth)
- setNodePositions(nodePositions)
- const tablePosition = calculateTablePosition(nodePositions)
- setTablePosition(tablePosition)
- }, [nodes, fixedFields, joinedFields])
-
- useEffect(() => {
- const allNodes = [...nodePositions]
- // calculated table row positions
- rowsRefs.current.forEach((ref) => {
- const rect = ref?.getBoundingClientRect()
- const nodeRect = tableNodeRef.current?.getBoundingClientRect()
-
- if (!rect) {
- return
- }
-
- if (nodeRect && ref) {
- allNodes.push({
- nodeId: ref.id,
- name: 'Table',
- position: { x: tablePosition.x, y: tablePosition.y + (rect.y - nodeRect.y) },
- leaf: [],
- depth: -1,
- })
- }
- })
-
- const calculatedEdges = calculateEdges([...nodeRefs.current, ...rowsRefs.current], allNodes)
- setEdges(calculatedEdges)
- }, [nodePositions, tablePosition])
-
- const drawGrid = (ctx: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number): void => {
- ctx.fillStyle = '#000000'
- ctx.imageSmoothingEnabled = true
- const dotSize = 0.5
- const spacing = 10
-
- for (let x = offset.x % spacing; x < canvasWidth; x += spacing) {
- for (let y = offset.y % spacing; y < canvasHeight; y += spacing) {
- ctx.fillRect(x, y, dotSize, dotSize)
- }
- }
- }
-
- useEffect(() => {
- const canvas = canvasRef.current
-
- if (canvas) {
- const ctx = canvas.getContext('2d')
- if (!ctx) {
- return
- }
- const { width, height } = canvas.getBoundingClientRect()
-
- canvas.width = width
- canvas.height = height
-
- drawGrid(ctx, width, height)
- }
-
- const handleResize = (): void => {
- if (canvas) {
- const { width, height } = canvas.getBoundingClientRect()
- canvas.width = width
- canvas.height = height
- const ctx = canvas.getContext('2d')
- if (ctx) {
- drawGrid(ctx, width, height)
- }
- }
- }
-
- window.addEventListener('resize', handleResize)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- }
- }, [offset, nodePositions])
-
- const handleMouseDown = (e: React.MouseEvent): void => {
- setIsDragging(true)
- setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y })
- }
-
- const handleMouseMove = (e: React.MouseEvent): void => {
- if (!isDragging) {
- return
- }
- const newOffset = {
- x: e.clientX - dragStart.x,
- y: e.clientY - dragStart.y,
- }
- setOffset(newOffset)
- }
-
- const handleMouseUp = (): void => {
- setIsDragging(false)
- }
-
- return (
-
-
-
- {edges.map((edge, index) => {
- const controlPoint1X = edge.from.x + offset.x + (edge.to.x - edge.from.x) / 3
- const controlPoint1Y = edge.from.y + offset.y
- const controlPoint2X = edge.to.x + offset.x - (edge.to.x - edge.from.x) / 3
- const controlPoint2Y = edge.to.y + offset.y
- return (
-
- )
- })}
-
- {nodePositions.map(({ name, savedQueryId, position, nodeId }, idx) => {
- return (
-
-
{
- nodeRefs.current[idx] = el
- nodeRefs.current[idx]?.setAttribute('id', nodeId)
- }}
- >
-
-
-
{name}
- {savedQueryId && (
-
runDataWarehouseSavedQuery(savedQueryId)}
- >
- Run
-
- )}
-
- {savedQueryId && dataWarehouseSavedQueryMapById[savedQueryId]?.status && (
-
-
- {dataWarehouseSavedQueryMapById[savedQueryId]?.status}
-
-
- )}
- {savedQueryId && dataWarehouseSavedQueryMapById[savedQueryId]?.last_run_at && (
-
- {`Last calculated ${humanFriendlyDetailedTime(
- dataWarehouseSavedQueryMapById[savedQueryId]?.last_run_at
- )}`}
-
- )}
-
-
-
- )
- })}
-
-
-
- )
-}
-
-export default NodeCanvasWithTable
-
-interface TableFieldNodeProps {
- fixedFields: FixedField[]
- joinedFields: JoinedField[]
- rowsRefs: React.MutableRefObject<(HTMLDivElement | null)[]>
- nodeRef: React.MutableRefObject
- tableName: string
-}
-
-function TableFieldNode({ nodeRef, rowsRefs, fixedFields, joinedFields, tableName }: TableFieldNodeProps): JSX.Element {
- return (
-
- )
-}
diff --git a/frontend/src/scenes/data-model/dataModelSceneLogic.tsx b/frontend/src/scenes/data-model/dataModelSceneLogic.tsx
index ed83532a40db0..f15f3854e7160 100644
--- a/frontend/src/scenes/data-model/dataModelSceneLogic.tsx
+++ b/frontend/src/scenes/data-model/dataModelSceneLogic.tsx
@@ -30,6 +30,10 @@ export const dataModelSceneLogic = kea([
traverseAncestors: async ({ viewId, level }) => {
const result = await api.dataWarehouseSavedQueries.ancestors(viewId, level)
+ if (!values.nodeMap[viewId]?.savedQueryId) {
+ return
+ }
+
result.ancestors.forEach((ancestor) => {
actions.setNodes({
...values.nodeMap,
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
index f3ac96bb2d949..b4a665606b55f 100644
--- a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
@@ -3,10 +3,12 @@ import 'react-data-grid/lib/styles.css'
import { IconGear } from '@posthog/icons'
import { LemonButton, LemonTabs } from '@posthog/lemon-ui'
import clsx from 'clsx'
-import { useActions, useValues } from 'kea'
+import { BindLogic, useActions, useValues } from 'kea'
import { AnimationType } from 'lib/animations/animations'
import { Animation } from 'lib/components/Animation/Animation'
import { ExportButton } from 'lib/components/ExportButton/ExportButton'
+import { FEATURE_FLAGS } from 'lib/constants'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { useMemo } from 'react'
import DataGrid from 'react-data-grid'
import { InsightErrorState, StatelessInsightLoadingState } from 'scenes/insights/EmptyStates'
@@ -31,19 +33,23 @@ import { ChartDisplayType, ExporterFormat } from '~/types'
import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic'
import { multitabEditorLogic } from './multitabEditorLogic'
import { outputPaneLogic, OutputTab } from './outputPaneLogic'
+import { InfoTab } from './OutputPaneTabs/InfoTab'
+import { LineageTab } from './OutputPaneTabs/lineageTab'
+import { lineageTabLogic } from './OutputPaneTabs/lineageTabLogic'
export function OutputPane(): JSX.Element {
const { activeTab } = useValues(outputPaneLogic)
const { setActiveTab } = useActions(outputPaneLogic)
const { variablesForInsight } = useValues(variablesLogic)
- const { editingView, sourceQuery, exportContext, isValidView, error } = useValues(multitabEditorLogic)
+ const { editingView, sourceQuery, exportContext, isValidView, error, editorKey } = useValues(multitabEditorLogic)
const { saveAsInsight, saveAsView, setSourceQuery, runQuery } = useActions(multitabEditorLogic)
const { isDarkModeOn } = useValues(themeLogic)
const { response, responseLoading, responseError, queryId, pollResponse } = useValues(dataNodeLogic)
const { dataWarehouseSavedQueriesLoading } = useValues(dataWarehouseViewsLogic)
const { updateDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic)
const { visualizationType, queryCancelled } = useValues(dataVisualizationLogic)
+ const { featureFlags } = useValues(featureFlagLogic)
const vizKey = useMemo(() => `SQLEditorScene`, [])
@@ -90,6 +96,18 @@ export function OutputPane(): JSX.Element {
key: OutputTab.Visualization,
label: 'Visualization',
},
+ ...(featureFlags[FEATURE_FLAGS.DATA_MODELING]
+ ? [
+ {
+ key: OutputTab.Info,
+ label: 'Info',
+ },
+ {
+ key: OutputTab.Lineage,
+ label: 'Lineage',
+ },
+ ]
+ : []),
]}
/>
@@ -151,24 +169,27 @@ export function OutputPane(): JSX.Element {
-
-
+
+
+
+
@@ -294,6 +315,7 @@ const Content = ({
saveAsInsight,
queryId,
pollResponse,
+ editorKey,
}: any): JSX.Element | null => {
if (activeTab === OutputTab.Results) {
if (responseError) {
@@ -310,7 +332,9 @@ const Content = ({
return responseLoading ? (
) : !response ? (
-
Query results will appear here
+
+ Query results will appear here
+
) : (
Query be results will be visualized here
+
+ Query results will be visualized here
+
) : (
+
+
+ )
+ }
+
+ if (activeTab === OutputTab.Lineage) {
+ return
+ }
+
return null
}
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/InfoTab.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/InfoTab.tsx
new file mode 100644
index 0000000000000..1c3bbe26558cc
--- /dev/null
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/InfoTab.tsx
@@ -0,0 +1,111 @@
+import { LemonButton, LemonTag, Tooltip } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonTable } from 'lib/lemon-ui/LemonTable'
+import { humanFriendlyDetailedTime } from 'lib/utils'
+
+import { multitabEditorLogic } from '../multitabEditorLogic'
+import { infoTabLogic } from './infoTabLogic'
+
+interface InfoTabProps {
+ codeEditorKey: string
+}
+
+export function InfoTab({ codeEditorKey }: InfoTabProps): JSX.Element {
+ const { sourceTableItems } = useValues(infoTabLogic({ codeEditorKey: codeEditorKey }))
+ const { editingView, isEditingMaterializedView } = useValues(multitabEditorLogic)
+ const { runDataWarehouseSavedQuery } = useActions(multitabEditorLogic)
+
+ return (
+
+
+
+
Materialization
+ BETA
+
+
+ {isEditingMaterializedView ? (
+
+ {editingView?.last_run_at ? (
+ `Last run at ${humanFriendlyDetailedTime(editingView.last_run_at)}`
+ ) : (
+
+ Materialization scheduled
+
+ )}
+
editingView && runDataWarehouseSavedQuery(editingView.id)}
+ className="mt-2"
+ type="secondary"
+ >
+ Run now
+
+
+ ) : (
+
+
+ Materialized views are a way to pre-compute data in your data warehouse. This allows you
+ to run queries faster and more efficiently.
+
+
editingView && runDataWarehouseSavedQuery(editingView.id)}
+ type="primary"
+ disabledReason={editingView ? undefined : 'You must save the view first'}
+ >
+ Materialize
+
+
+ )}
+
+
+
+
Dependencies
+
+ Dependencies are tables that this query uses. See when a source or materialized table was last run.
+
+
+
name,
+ },
+ {
+ key: 'Type',
+ title: 'Type',
+ render: (_, { type }) => type,
+ },
+ {
+ key: 'Status',
+ title: 'Status',
+ render: (_, { type, status }) => {
+ if (type === 'source') {
+ return (
+
+ N/A
+
+ )
+ }
+ return status
+ },
+ },
+ {
+ key: 'Last run at',
+ title: 'Last run at',
+ render: (_, { type, last_run_at }) => {
+ if (type === 'source') {
+ return (
+
+ N/A
+
+ )
+ }
+ return humanFriendlyDetailedTime(last_run_at)
+ },
+ },
+ ]}
+ dataSource={sourceTableItems}
+ />
+
+ )
+}
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/infoTabLogic.ts b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/infoTabLogic.ts
new file mode 100644
index 0000000000000..3b93a1ee0e6f5
--- /dev/null
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/infoTabLogic.ts
@@ -0,0 +1,65 @@
+import { connect, kea, key, path, props, selectors } from 'kea'
+import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
+import { dataWarehouseViewsLogic } from 'scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic'
+
+import { multitabEditorLogic } from '../multitabEditorLogic'
+import type { infoTabLogicType } from './infoTabLogicType'
+
+export interface InfoTableRow {
+ name: string
+ type: 'source' | 'table'
+ view_id?: string
+ status?: string
+ last_run_at?: string
+}
+
+export interface InfoTabLogicProps {
+ codeEditorKey: string
+}
+
+export const infoTabLogic = kea([
+ path(['data-warehouse', 'editor', 'outputPaneTabs', 'infoTabLogic']),
+ props({} as InfoTabLogicProps),
+ key((props) => props.codeEditorKey),
+ connect((props: InfoTabLogicProps) => ({
+ values: [
+ multitabEditorLogic({ key: props.codeEditorKey }),
+ ['metadata'],
+ databaseTableListLogic,
+ ['posthogTablesMap', 'dataWarehouseTablesMap'],
+ dataWarehouseViewsLogic,
+ ['dataWarehouseSavedQueryMap'],
+ ],
+ })),
+ selectors({
+ sourceTableItems: [
+ (s) => [s.metadata, s.dataWarehouseSavedQueryMap],
+ (metadata, dataWarehouseSavedQueryMap) => {
+ if (!metadata) {
+ return []
+ }
+ return (
+ metadata.table_names?.map((table_name) => {
+ const view = dataWarehouseSavedQueryMap[table_name]
+ if (view) {
+ return {
+ name: table_name,
+ type: 'table',
+ view_id: view.id,
+ status: view.status,
+ last_run_at: view.last_run_at || 'never',
+ }
+ }
+
+ return {
+ name: table_name,
+ type: 'source',
+ status: undefined,
+ last_run_at: undefined,
+ }
+ }) || []
+ )
+ },
+ ],
+ }),
+])
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTab.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTab.tsx
new file mode 100644
index 0000000000000..967216dd154f4
--- /dev/null
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTab.tsx
@@ -0,0 +1,67 @@
+import { LemonTag } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+import { humanFriendlyDetailedTime } from 'lib/utils'
+import GenericNode from 'scenes/data-model/Node'
+import { NodeCanvas } from 'scenes/data-model/NodeCanvas'
+import { Node } from 'scenes/data-model/types'
+import { dataWarehouseViewsLogic } from 'scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic'
+import { StatusTagSetting } from 'scenes/data-warehouse/settings/DataWarehouseManagedSourcesTable'
+
+import { lineageTabLogic } from './lineageTabLogic'
+
+export function LineageTab(): JSX.Element {
+ const { allNodes } = useValues(lineageTabLogic)
+ const { dataWarehouseSavedQueryMapById } = useValues(dataWarehouseViewsLogic)
+ const { runDataWarehouseSavedQuery } = useActions(dataWarehouseViewsLogic)
+
+ const renderNode = (node: Node, ref: (el: HTMLDivElement | null) => void): JSX.Element => (
+
+
+
+
{node.name}
+ {node.savedQueryId && (
+
node.savedQueryId && runDataWarehouseSavedQuery(node.savedQueryId)}
+ >
+ Run
+
+ )}
+
+ {node.savedQueryId && dataWarehouseSavedQueryMapById[node.savedQueryId]?.status && (
+
+
+ {dataWarehouseSavedQueryMapById[node.savedQueryId]?.status}
+
+
+ )}
+ {node.savedQueryId && dataWarehouseSavedQueryMapById[node.savedQueryId]?.last_run_at && (
+
+ {`Last calculated ${humanFriendlyDetailedTime(
+ dataWarehouseSavedQueryMapById[node.savedQueryId]?.last_run_at
+ )}`}
+
+ )}
+
+
+ )
+
+ return (
+
+ )
+}
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTabLogic.ts b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTabLogic.ts
new file mode 100644
index 0000000000000..2089d22c4513a
--- /dev/null
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPaneTabs/lineageTabLogic.ts
@@ -0,0 +1,155 @@
+import { actions, connect, events, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { subscriptions } from 'kea-subscriptions'
+import api from 'lib/api'
+import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
+import { Node } from 'scenes/data-model/types'
+import { dataWarehouseViewsLogic } from 'scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic'
+
+import { DataWarehouseSavedQuery } from '~/types'
+
+import { multitabEditorLogic } from '../multitabEditorLogic'
+import type { lineageTabLogicType } from './lineageTabLogicType'
+
+export interface LineageTabLogicProps {
+ codeEditorKey: string
+}
+
+export const lineageTabLogic = kea([
+ path(['data-warehouse', 'editor', 'outputPaneTabs', 'lineageTabLogic']),
+ props({} as LineageTabLogicProps),
+ key((props) => props.codeEditorKey),
+ actions({
+ loadNodes: true,
+ traverseAncestors: (viewId: DataWarehouseSavedQuery['id'], level: number) => ({ viewId, level }),
+ setNodes: (nodes: Record) => ({ nodes }),
+ }),
+ connect((props: LineageTabLogicProps) => ({
+ values: [
+ multitabEditorLogic({ key: props.codeEditorKey }),
+ ['metadata'],
+ databaseTableListLogic,
+ ['posthogTablesMap', 'viewsMapById', 'dataWarehouseTablesMapById'],
+ dataWarehouseViewsLogic,
+ ['dataWarehouseSavedQueryMap'],
+ ],
+ actions: [
+ multitabEditorLogic({ key: props.codeEditorKey }),
+ ['runQuery'],
+ dataWarehouseViewsLogic,
+ ['loadDataWarehouseSavedQueries'],
+ ],
+ })),
+ reducers({
+ nodeMap: [
+ {} as Record,
+ {
+ setNodes: (_, { nodes }) => nodes,
+ },
+ ],
+ }),
+ listeners(({ actions, values }) => ({
+ loadNodes: async () => {
+ const nodes: Record = {}
+
+ const traverseAncestors = async (viewId: DataWarehouseSavedQuery['id'], level: number): Promise => {
+ if (!nodes[viewId]?.savedQueryId) {
+ return
+ }
+
+ const result = await api.dataWarehouseSavedQueries.ancestors(viewId, level)
+ for (const ancestor of result.ancestors) {
+ nodes[ancestor] = {
+ nodeId: ancestor,
+ name:
+ values.viewsMapById[ancestor]?.name ||
+ values.dataWarehouseTablesMapById[ancestor]?.name ||
+ ancestor,
+ savedQueryId: values.viewsMapById[ancestor]?.id,
+ leaf: [...(nodes[ancestor]?.leaf || []), viewId],
+ }
+ await traverseAncestors(ancestor, 1)
+ }
+ }
+
+ values.sources.forEach((source) => {
+ if (!source) {
+ return
+ }
+ nodes[source] = {
+ nodeId: source,
+ name: source,
+ savedQueryId: undefined,
+ leaf: [],
+ }
+ })
+
+ for (const view of values.views) {
+ if (!view) {
+ continue
+ }
+ nodes[view.id] = {
+ nodeId: view.id,
+ name: view.name,
+ savedQueryId: view.id,
+ leaf: [],
+ }
+ await traverseAncestors(view.id, 1)
+ }
+ actions.setNodes(nodes)
+ },
+ })),
+ subscriptions(({ actions }) => ({
+ metadata: () => {
+ actions.loadNodes()
+ },
+ })),
+ selectors({
+ views: [
+ (s) => [s.metadata, s.dataWarehouseSavedQueryMap],
+ (metadata, dataWarehouseSavedQueryMap) => {
+ if (!metadata) {
+ return []
+ }
+ return (
+ metadata.table_names
+ ?.map((table_name: string) => {
+ const view = dataWarehouseSavedQueryMap[table_name]
+ if (view) {
+ return view
+ }
+ })
+ .filter(Boolean) || []
+ )
+ },
+ ],
+ sources: [
+ (s) => [s.metadata, s.dataWarehouseSavedQueryMap],
+ (metadata, dataWarehouseSavedQueryMap) => {
+ if (!metadata) {
+ return []
+ }
+ return (
+ metadata.table_names
+ ?.map((table_name: string) => {
+ const view = dataWarehouseSavedQueryMap[table_name]
+ if (!view) {
+ return table_name
+ }
+ })
+ .filter(Boolean) || []
+ )
+ },
+ ],
+ allNodes: [(s) => [s.nodeMap], (nodeMap) => [...Object.values(nodeMap)]],
+ }),
+ events(({ cache, actions }) => ({
+ afterMount: () => {
+ if (!cache.pollingInterval) {
+ cache.pollingInterval = setInterval(actions.loadDataWarehouseSavedQueries, 5000)
+ }
+ },
+ beforeUnmount: () => {
+ clearInterval(cache.pollingInterval)
+ },
+ })),
+])
diff --git a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx
index 85c9d80ef6270..d060984b41512 100644
--- a/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/QueryTabs.tsx
@@ -46,7 +46,7 @@ function QueryTabComponent({ model, active, onClear, onClick }: QueryTabProps):
onClear ? 'pl-3 pr-2' : 'px-3'
)}
>
- {model.view?.name ?? 'Untitled'}
+ {model.view?.name ?? 'New query'}
{onClear && (
{
diff --git a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx
index 02c2457a0381e..7bfbe9310d7e8 100644
--- a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx
@@ -36,7 +36,8 @@ export function QueryWindow(): JSX.Element {
})
const { allTabs, activeModelUri, queryInput, editingView, sourceQuery } = useValues(logic)
- const { selectTab, deleteTab, createTab, setQueryInput, runQuery, setError, setIsValidView } = useActions(logic)
+ const { selectTab, deleteTab, createTab, setQueryInput, runQuery, setError, setIsValidView, setMetadata } =
+ useActions(logic)
return (
@@ -51,7 +52,9 @@ export function QueryWindow(): JSX.Element {
{editingView && (
- Editing view "{editingView.name}"
+
+ Editing {editingView.status ? 'materialized view' : 'view'} "{editingView.name}"
+
)}
{
+ setMetadata(metadata)
+ },
}}
/>
diff --git a/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.tsx
similarity index 63%
rename from frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts
rename to frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.tsx
index cfd559e59506a..c45ea5559fb5a 100644
--- a/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.ts
+++ b/frontend/src/scenes/data-warehouse/editor/editorSidebarLogic.tsx
@@ -1,9 +1,9 @@
+import { Tooltip } from '@posthog/lemon-ui'
import Fuse from 'fuse.js'
import { connect, kea, path, selectors } from 'kea'
import { router } from 'kea-router'
import { subscriptions } from 'kea-subscriptions'
-import { FEATURE_FLAGS } from 'lib/constants'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+import { IconCalculate, IconClipboardEdit } from 'lib/lemon-ui/icons'
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
@@ -42,20 +42,6 @@ const savedQueriesfuse = new Fuse([], {
includeMatches: true,
})
-const nonMaterializedViewsfuse = new Fuse([], {
- keys: [{ name: 'name', weight: 2 }],
- threshold: 0.3,
- ignoreLocation: true,
- includeMatches: true,
-})
-
-const materializedViewsfuse = new Fuse([], {
- keys: [{ name: 'name', weight: 2 }],
- threshold: 0.3,
- ignoreLocation: true,
- includeMatches: true,
-})
-
export const editorSidebarLogic = kea([
path(['data-warehouse', 'editor', 'editorSidebarLogic']),
connect({
@@ -66,8 +52,6 @@ export const editorSidebarLogic = kea([
['dataWarehouseSavedQueries', 'dataWarehouseSavedQueryMapById', 'dataWarehouseSavedQueriesLoading'],
databaseTableListLogic,
['posthogTables', 'dataWarehouseTables', 'databaseLoading', 'views', 'viewsMapById'],
- featureFlagLogic,
- ['featureFlags'],
],
actions: [
editorSceneLogic,
@@ -86,19 +70,13 @@ export const editorSidebarLogic = kea([
s.relevantPosthogTables,
s.relevantDataWarehouseTables,
s.databaseLoading,
- s.relevantNonMaterializedViews,
- s.relevantMaterializedViews,
- s.featureFlags,
],
(
relevantSavedQueries,
dataWarehouseSavedQueriesLoading,
relevantPosthogTables,
relevantDataWarehouseTables,
- databaseLoading,
- relevantNonMaterializedViews,
- relevantMaterializedViews,
- featureFlags
+ databaseLoading
) => [
{
key: 'data-warehouse-sources',
@@ -163,13 +141,19 @@ export const editorSidebarLogic = kea([
key: 'data-warehouse-views',
noun: ['view', 'views'],
loading: dataWarehouseSavedQueriesLoading,
- items: (featureFlags[FEATURE_FLAGS.DATA_MODELING]
- ? relevantNonMaterializedViews
- : relevantSavedQueries
- ).map(([savedQuery, matches]) => ({
+ items: relevantSavedQueries.map(([savedQuery, matches]) => ({
key: savedQuery.id,
name: savedQuery.name,
url: '',
+ icon: savedQuery.status ? (
+
+
+
+ ) : (
+
+
+
+ ),
searchMatch: matches
? {
matchingFields: matches.map((match) => match.key),
@@ -195,16 +179,6 @@ export const editorSidebarLogic = kea([
actions.toggleJoinTableModal()
},
},
- ...(featureFlags[FEATURE_FLAGS.DATA_MODELING] && !savedQuery.status
- ? [
- {
- label: 'Materialize',
- onClick: () => {
- actions.runDataWarehouseSavedQuery(savedQuery.id)
- },
- },
- ]
- : []),
{
label: 'Delete',
status: 'danger',
@@ -215,63 +189,6 @@ export const editorSidebarLogic = kea([
],
})),
} as SidebarCategory,
- ...(featureFlags[FEATURE_FLAGS.DATA_MODELING]
- ? [
- {
- key: 'data-warehouse-materialized-views',
- noun: ['materialized view', 'materialized views'],
- loading: dataWarehouseSavedQueriesLoading,
- items: relevantMaterializedViews.map(([materializedView, matches]) => ({
- key: materializedView.id,
- name: materializedView.name,
- url: '',
- searchMatch: matches
- ? {
- matchingFields: matches.map((match) => match.key),
- nameHighlightRanges: matches.find((match) => match.key === 'name')?.indices,
- }
- : null,
- onClick: () => {
- actions.selectSchema(materializedView)
- },
- menuItems: [
- {
- label: 'Edit view definition',
- onClick: () => {
- multitabEditorLogic({
- key: `hogQLQueryEditor/${router.values.location.pathname}`,
- }).actions.createTab(materializedView.query.query, materializedView)
- },
- },
- {
- label: 'Add join',
- onClick: () => {
- actions.selectSourceTable(materializedView.name)
- actions.toggleJoinTableModal()
- },
- },
- ...(featureFlags[FEATURE_FLAGS.DATA_MODELING] && materializedView.status
- ? [
- {
- label: 'Run',
- onClick: () => {
- actions.runDataWarehouseSavedQuery(materializedView.id)
- },
- },
- ]
- : []),
- {
- label: 'Delete',
- status: 'danger',
- onClick: () => {
- actions.deleteDataWarehouseSavedQuery(materializedView.id)
- },
- },
- ],
- })),
- },
- ]
- : []),
],
],
nonMaterializedViews: [
@@ -327,28 +244,6 @@ export const editorSidebarLogic = kea([
return dataWarehouseSavedQueries.map((savedQuery) => [savedQuery, null])
},
],
- relevantNonMaterializedViews: [
- (s) => [s.nonMaterializedViews, navigation3000Logic.selectors.searchTerm],
- (nonMaterializedViews, searchTerm): [DataWarehouseSavedQuery, FuseSearchMatch[] | null][] => {
- if (searchTerm) {
- return nonMaterializedViewsfuse
- .search(searchTerm)
- .map((result) => [result.item, result.matches as FuseSearchMatch[]])
- }
- return nonMaterializedViews.map((view) => [view, null])
- },
- ],
- relevantMaterializedViews: [
- (s) => [s.materializedViews, navigation3000Logic.selectors.searchTerm],
- (materializedViews, searchTerm): [DataWarehouseSavedQuery, FuseSearchMatch[] | null][] => {
- if (searchTerm) {
- return materializedViewsfuse
- .search(searchTerm)
- .map((result) => [result.item, result.matches as FuseSearchMatch[]])
- }
- return materializedViews.map((view) => [view, null])
- },
- ],
})),
subscriptions({
dataWarehouseTables: (dataWarehouseTables) => {
diff --git a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx
index 740ea33aced83..7f713327b5197 100644
--- a/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/multitabEditorLogic.tsx
@@ -34,7 +34,8 @@ export interface MultitabEditorLogicProps {
}
export const editorModelsStateKey = (key: string | number): string => `${key}/editorModelQueries`
-export const activemodelStateKey = (key: string | number): string => `${key}/activeModelUri`
+export const activeModelStateKey = (key: string | number): string => `${key}/activeModelUri`
+export const activeModelVariablesStateKey = (key: string | number): string => `${key}/activeModelVariables`
export interface QueryTab {
uri: Uri
@@ -48,7 +49,12 @@ export const multitabEditorLogic = kea([
connect({
actions: [
dataWarehouseViewsLogic,
- ['deleteDataWarehouseSavedQuerySuccess', 'createDataWarehouseSavedQuerySuccess'],
+ [
+ 'loadDataWarehouseSavedQueriesSuccess',
+ 'deleteDataWarehouseSavedQuerySuccess',
+ 'createDataWarehouseSavedQuerySuccess',
+ 'runDataWarehouseSavedQuery',
+ ],
],
}),
actions({
@@ -66,13 +72,13 @@ export const multitabEditorLogic = kea([
initialize: true,
saveAsView: true,
saveAsViewSubmit: (name: string) => ({ name }),
- setMetadata: (query: string, metadata: HogQLMetadataResponse) => ({ query, metadata }),
saveAsInsight: true,
saveAsInsightSubmit: (name: string) => ({ name }),
setCacheLoading: (loading: boolean) => ({ loading }),
setError: (error: string | null) => ({ error }),
setIsValidView: (isValidView: boolean) => ({ isValidView }),
setSourceQuery: (sourceQuery: DataVisualizationNode) => ({ sourceQuery }),
+ setMetadata: (metadata: HogQLMetadataResponse) => ({ metadata }),
editView: (query: string, view: DataWarehouseSavedQuery) => ({ query, view }),
}),
propsChanged(({ actions, props }, oldProps) => {
@@ -80,7 +86,7 @@ export const multitabEditorLogic = kea([
actions.initialize()
}
}),
- reducers({
+ reducers(({ props }) => ({
cacheLoading: [
true,
{
@@ -149,7 +155,14 @@ export const multitabEditorLogic = kea([
setIsValidView: (_, { isValidView }) => isValidView,
},
],
- }),
+ metadata: [
+ null as HogQLMetadataResponse | null,
+ {
+ setMetadata: (_, { metadata }) => metadata,
+ },
+ ],
+ editorKey: [props.key],
+ })),
listeners(({ values, props, actions, asyncActions }) => ({
editView: ({ query, view }) => {
const maybeExistingTab = values.allTabs.find((tab) => tab.view?.id === view.id)
@@ -202,7 +215,7 @@ export const multitabEditorLogic = kea([
}
const path = tab.uri.path.split('/').pop()
- path && actions.setLocalState(activemodelStateKey(props.key), path)
+ path && actions.setLocalState(activeModelStateKey(props.key), path)
},
deleteTab: ({ tab: tabToRemove }) => {
if (props.monaco) {
@@ -232,7 +245,13 @@ export const multitabEditorLogic = kea([
},
initialize: () => {
const allModelQueries = localStorage.getItem(editorModelsStateKey(props.key))
- const activeModelUri = localStorage.getItem(activemodelStateKey(props.key))
+ const activeModelUri = localStorage.getItem(activeModelStateKey(props.key))
+ const activeModelVariablesString = localStorage.getItem(activeModelVariablesStateKey(props.key))
+ const activeModelVariables =
+ activeModelVariablesString && activeModelVariablesString != 'undefined'
+ ? JSON.parse(activeModelVariablesString)
+ : {}
+
const mountedCodeEditorLogic =
codeEditorLogic.findMounted() ||
codeEditorLogic({
@@ -273,6 +292,13 @@ export const multitabEditorLogic = kea([
activeModel && props.editor?.setModel(activeModel)
const val = activeModel?.getValue()
if (val) {
+ actions.setSourceQuery({
+ ...values.sourceQuery,
+ source: {
+ ...values.sourceQuery.source,
+ variables: activeModelVariables,
+ },
+ })
actions.setQueryInput(val)
actions.runQuery()
}
@@ -311,6 +337,11 @@ export const multitabEditorLogic = kea([
})
localStorage.setItem(editorModelsStateKey(props.key), JSON.stringify(queries))
},
+ setSourceQuery: ({ sourceQuery }) => {
+ // NOTE: this is a hack to get the variables to persist.
+ // Variables should be handled first in this logic and then in the downstream variablesLogic
+ localStorage.setItem(activeModelVariablesStateKey(props.key), JSON.stringify(sourceQuery.source.variables))
+ },
runQuery: ({ queryOverride, switchTab }) => {
const query = queryOverride || values.queryInput
@@ -388,6 +419,15 @@ export const multitabEditorLogic = kea([
router.actions.push(urls.insightView(insight.short_id))
},
+ loadDataWarehouseSavedQueriesSuccess: ({ dataWarehouseSavedQueries }) => {
+ // keep tab views up to date
+ const newTabs = values.allTabs.map((tab) => ({
+ ...tab,
+ view: dataWarehouseSavedQueries.find((v) => v.id === tab.view?.id),
+ }))
+ actions.setTabs(newTabs)
+ actions.updateState()
+ },
deleteDataWarehouseSavedQuerySuccess: ({ payload: viewId }) => {
const tabToRemove = values.allTabs.find((tab) => tab.view?.id === viewId)
if (tabToRemove) {
@@ -412,7 +452,7 @@ export const multitabEditorLogic = kea([
lemonToast.success('View updated')
},
})),
- subscriptions(({ props, actions }) => ({
+ subscriptions(({ props, actions, values }) => ({
activeModelUri: (activeModelUri) => {
if (props.monaco) {
const _model = props.monaco.editor.getModel(activeModelUri.uri)
@@ -421,6 +461,11 @@ export const multitabEditorLogic = kea([
actions.runQuery(undefined, true)
}
},
+ allTabs: () => {
+ // keep selected tab up to date
+ const activeTab = values.allTabs.find((tab) => tab.uri.path === values.activeModelUri?.uri.path)
+ activeTab && actions.selectTab(activeTab)
+ },
})),
selectors({
exportContext: [
@@ -435,5 +480,11 @@ export const multitabEditorLogic = kea([
} as ExportContext
},
],
+ isEditingMaterializedView: [
+ (s) => [s.editingView],
+ (editingView) => {
+ return !!editingView?.status
+ },
+ ],
}),
])
diff --git a/frontend/src/scenes/data-warehouse/editor/outputPaneLogic.ts b/frontend/src/scenes/data-warehouse/editor/outputPaneLogic.ts
index 659c79b440635..f54b13ed910d0 100644
--- a/frontend/src/scenes/data-warehouse/editor/outputPaneLogic.ts
+++ b/frontend/src/scenes/data-warehouse/editor/outputPaneLogic.ts
@@ -5,6 +5,8 @@ import type { outputPaneLogicType } from './outputPaneLogicType'
export enum OutputTab {
Results = 'results',
Visualization = 'visualization',
+ Info = 'info',
+ Lineage = 'lineage',
}
export const outputPaneLogic = kea([
diff --git a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx
index d66a0285526ba..9a08ba084246f 100644
--- a/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx
+++ b/frontend/src/scenes/data-warehouse/saved_queries/dataWarehouseViewsLogic.tsx
@@ -1,10 +1,8 @@
import { lemonToast } from '@posthog/lemon-ui'
import { actions, connect, events, kea, listeners, path, selectors } from 'kea'
import { loaders } from 'kea-loaders'
-import { router } from 'kea-router'
import api from 'lib/api'
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
-import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
import { DatabaseSchemaViewTable } from '~/queries/schema'
@@ -21,18 +19,12 @@ export const dataWarehouseViewsLogic = kea([
actions({
runDataWarehouseSavedQuery: (viewId: string) => ({ viewId }),
}),
- loaders(({ values, cache, actions }) => ({
+ loaders(({ values }) => ({
dataWarehouseSavedQueries: [
[] as DataWarehouseSavedQuery[],
{
loadDataWarehouseSavedQueries: async () => {
const savedQueries = await api.dataWarehouseSavedQueries.list()
-
- if (router.values.location.pathname.includes(urls.dataModel()) && !cache.pollingInterval) {
- cache.pollingInterval = setInterval(actions.loadDataWarehouseSavedQueries, 5000)
- } else {
- clearInterval(cache.pollingInterval)
- }
return savedQueries.results
},
createDataWarehouseSavedQuery: async (
@@ -70,8 +62,13 @@ export const dataWarehouseViewsLogic = kea([
actions.loadDatabase()
},
runDataWarehouseSavedQuery: async ({ viewId }) => {
- await api.dataWarehouseSavedQueries.run(viewId)
- actions.loadDataWarehouseSavedQueries()
+ try {
+ await api.dataWarehouseSavedQueries.run(viewId)
+ lemonToast.success('Materialization started')
+ actions.loadDataWarehouseSavedQueries()
+ } catch (error) {
+ lemonToast.error(`Failed to run materialization`)
+ }
},
})),
selectors({
@@ -92,13 +89,21 @@ export const dataWarehouseViewsLogic = kea([
)
},
],
+ dataWarehouseSavedQueryMap: [
+ (s) => [s.dataWarehouseSavedQueries],
+ (dataWarehouseSavedQueries) => {
+ return (
+ dataWarehouseSavedQueries?.reduce((acc, cur) => {
+ acc[cur.name] = cur
+ return acc
+ }, {} as Record) ?? {}
+ )
+ },
+ ],
}),
- events(({ actions, cache }) => ({
+ events(({ actions }) => ({
afterMount: () => {
actions.loadDataWarehouseSavedQueries()
},
- beforeUnmount: () => {
- clearInterval(cache.pollingInterval)
- },
})),
])
diff --git a/frontend/src/scenes/dataThemeLogic.tsx b/frontend/src/scenes/dataThemeLogic.tsx
new file mode 100644
index 0000000000000..af0bc658ff3a8
--- /dev/null
+++ b/frontend/src/scenes/dataThemeLogic.tsx
@@ -0,0 +1,94 @@
+import { actions, afterMount, connect, kea, path, props, reducers, selectors, useValues } from 'kea'
+import { loaders } from 'kea-loaders'
+import api from 'lib/api'
+import { DataColorTheme } from 'lib/colors'
+
+import { DataColorThemeModel } from '~/types'
+
+import type { dataThemeLogicType } from './dataThemeLogicType'
+import { teamLogic } from './teamLogic'
+
+export const ThemeName = ({ id }: { id: number }): JSX.Element => {
+ const { themes } = useValues(dataThemeLogic)
+ const theme = themes?.find((theme) => theme.id === id)
+
+ return theme ? {theme.name} : No theme found for id: {id}
+}
+
+export type DataThemeLogicProps = {
+ themes?: DataColorThemeModel[]
+}
+
+export const dataThemeLogic = kea([
+ props({} as DataThemeLogicProps),
+ path(['scenes', 'dataThemeLogic']),
+ connect({ values: [teamLogic, ['currentTeam']] }),
+ actions({ setThemes: (themes) => ({ themes }) }),
+ loaders(({ props }) => ({
+ themes: [
+ props.themes || null,
+ {
+ loadThemes: async () => await api.dataColorThemes.list(),
+ },
+ ],
+ })),
+ reducers({
+ themes: {
+ setThemes: (_, { themes }) => themes,
+ },
+ }),
+ selectors({
+ posthogTheme: [
+ (s) => [s.themes],
+ (themes) => {
+ if (!themes) {
+ return null
+ }
+
+ return themes.sort((theme) => theme.id).find((theme) => theme.is_global)
+ },
+ ],
+ defaultTheme: [
+ (s) => [s.currentTeam, s.themes, s.posthogTheme],
+ (currentTeam, themes, posthogTheme) => {
+ if (!currentTeam || !themes) {
+ return null
+ }
+
+ // use the posthog theme unless someone set a specfic theme for the team
+ const environmentTheme = themes.find((theme) => theme.id === currentTeam.default_data_theme)
+ return environmentTheme || posthogTheme
+ },
+ ],
+ getTheme: [
+ (s) => [s.themes, s.defaultTheme],
+ (themes, defaultTheme) =>
+ (themeId: string | number | null | undefined): DataColorTheme | null => {
+ let customTheme
+
+ if (Number.isInteger(themeId) && themes != null) {
+ customTheme = themes.find((theme) => theme.id === themeId)
+ }
+
+ if (customTheme) {
+ return customTheme.colors.reduce((theme, color, index) => {
+ theme[`preset-${index + 1}`] = color
+ return theme
+ }, {})
+ }
+
+ if (defaultTheme) {
+ return defaultTheme.colors.reduce((theme, color, index) => {
+ theme[`preset-${index + 1}`] = color
+ return theme
+ }, {})
+ }
+
+ return null
+ },
+ ],
+ }),
+ afterMount(({ actions }) => {
+ actions.loadThemes()
+ }),
+])
diff --git a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx
index fffe0a16abfdc..cf30eff83213b 100644
--- a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx
+++ b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx
@@ -276,7 +276,7 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element {
)}
-
+
{isEditingFeature || isNewEarlyAccessFeature ? (
@@ -333,14 +333,14 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element {
>
)}
{!isEditingFeature && !isNewEarlyAccessFeature && 'id' in earlyAccessFeature && (
<>
-
+
Users
diff --git a/frontend/src/scenes/experiments/Experiment.scss b/frontend/src/scenes/experiments/Experiment.scss
deleted file mode 100644
index df10e7141aafe..0000000000000
--- a/frontend/src/scenes/experiments/Experiment.scss
+++ /dev/null
@@ -1,161 +0,0 @@
-.experiment-form {
- .metrics-selection {
- width: 100%;
- padding-top: 1rem;
- border-top: 1px solid var(--border);
- }
-
- .person-selection {
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding-top: 1rem;
- border-top: 1px solid var(--border);
- }
-
- .experiment-preview {
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
- }
-
- .variants {
- padding-bottom: 1rem;
- margin-top: 0.5rem;
-
- .border-top {
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- }
-
- .border-bottom {
- border-bottom-right-radius: 4px;
- border-bottom-left-radius: 4px;
- }
-
- .feature-flag-variant {
- display: flex;
- align-items: center;
- padding: 0.5rem;
- background: var(--bg-light);
- border-color: var(--border);
- border-width: 1px;
- border-top-style: solid;
- border-right-style: solid;
- border-left-style: solid;
-
- .extend-variant-fully {
- flex: 1;
- }
- }
-
- .variant-label {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- min-width: 52px;
- padding: 2px 6px;
- margin-right: 8px;
- font-size: 12px;
- font-weight: 500;
- color: #fff;
- letter-spacing: 0.01em;
- border-radius: var(--radius);
- }
- }
-
- .secondary-metrics {
- width: 100%;
- padding-top: 1rem;
- margin-top: 1rem;
- margin-bottom: 1rem;
- border-top: 1px solid var(--border);
- }
-}
-
-.view-experiment {
- .draft-header {
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
- }
-
- .exp-description {
- font-style: italic;
- }
-
- .participants {
- background-color: white;
- }
-
- .variants-list {
- li {
- display: inline;
- }
-
- li::after {
- content: ', ';
- }
-
- li:last-child::after {
- content: '';
- }
- }
-
- .experiment-result {
- padding-top: 1rem;
- }
-
- .secondary-progress {
- margin-top: 0.5rem;
-
- li::before {
- display: inline-block;
- margin-right: 4px;
- font-weight: 900;
- content: '\2022';
- }
- }
-
- .no-experiment-results {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- min-height: 320px;
- margin-top: 1rem;
- font-size: 24px;
- background-color: var(--bg-3000);
- border: 1px solid var(--border);
- }
-
- .computation-time-and-sampling-notice {
- margin-top: 8px;
- }
-}
-
-.experiment-preview-row {
- padding-bottom: 1rem;
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
-
- &:last-child {
- padding-bottom: 0;
- margin-bottom: 0;
- border-bottom: none;
- }
-}
-
-.metric-name {
- flex: 1;
- padding: 8px 8px 8px 16px;
- margin-left: 0.5rem;
- border: 1px solid var(--border);
- border-radius: var(--radius);
-}
-
-.exp-flag-copy-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
diff --git a/frontend/src/scenes/experiments/Experiment.stories.tsx b/frontend/src/scenes/experiments/Experiment.stories.tsx
deleted file mode 100644
index 737b7f2973c12..0000000000000
--- a/frontend/src/scenes/experiments/Experiment.stories.tsx
+++ /dev/null
@@ -1,1488 +0,0 @@
-import { Meta, StoryFn } from '@storybook/react'
-import { router } from 'kea-router'
-import { useEffect } from 'react'
-import { App } from 'scenes/App'
-import { urls } from 'scenes/urls'
-
-import { mswDecorator } from '~/mocks/browser'
-import { toPaginatedResponse } from '~/mocks/handlers'
-import {
- BreakdownAttributionType,
- ChartDisplayType,
- Experiment,
- FunnelConversionWindowTimeUnit,
- FunnelExperimentResults,
- FunnelsFilterType,
- FunnelVizType,
- InsightType,
- PropertyFilterType,
- PropertyOperator,
- SignificanceCode,
- TrendsExperimentResults,
-} from '~/types'
-
-const MOCK_FUNNEL_EXPERIMENT: Experiment = {
- id: 1,
- name: 'New sign-up flow',
- description:
- "We've rebuilt our sign-up page to offer a more personalized experience. Let's see if this version performs better with potential users.",
- start_date: '2022-12-10T08:06:27.027740Z',
- end_date: '2023-02-07T16:12:54.055481Z',
- feature_flag_key: 'signup-page-4.0',
- feature_flag: {
- id: 1,
- team_id: 1,
- name: 'New sign-up page',
- key: 'signup-page-4.0',
- active: false,
- deleted: false,
- ensure_experience_continuity: false,
- filters: {
- groups: [
- {
- properties: [
- {
- key: 'company_name',
- type: PropertyFilterType.Group,
- value: 'awe',
- operator: PropertyOperator.IContains,
- group_type_index: 1,
- },
- ],
- variant: null,
- rollout_percentage: undefined,
- },
- ],
- payloads: {},
- multivariate: {
- variants: [
- {
- key: 'control',
- rollout_percentage: 33,
- },
- {
- key: 'test',
- rollout_percentage: 33,
- },
- {
- key: 'test_group_2',
- rollout_percentage: 34,
- },
- ],
- },
- aggregation_group_type_index: 1,
- },
- },
- parameters: {
- feature_flag_variants: [
- {
- key: 'control',
- rollout_percentage: 50,
- },
- {
- key: 'test',
- rollout_percentage: 50,
- },
- ],
- recommended_sample_size: 137,
- minimum_detectable_effect: 1,
- },
- secondary_metrics: [],
- filters: {
- events: [
- {
- id: '$pageview',
- name: '$pageview',
- type: 'events',
- order: 0,
- properties: [
- {
- key: '$current_url',
- type: 'event',
- value: 'https://hedgebox.net/signup/',
- operator: 'exact',
- },
- ],
- },
- {
- id: 'signed_up',
- name: 'signed_up',
- type: 'events',
- order: 1,
- },
- ],
- actions: [],
- insight: InsightType.FUNNELS,
- interval: 'day',
- filter_test_accounts: true,
- },
- metrics: [],
- metrics_secondary: [],
- archived: false,
- created_by: {
- id: 1,
- uuid: '01863799-062b-0000-8a61-b2842d5f8642',
- distinct_id: 'Sopz9Z4NMIfXGlJe6W1XF98GOqhHNui5J5eRe0tBGTE',
- first_name: 'Employee 427',
- email: 'test2@posthog.com',
- },
- created_at: '2022-12-10T07:06:27.027740Z',
- updated_at: '2023-02-09T19:13:57.137954Z',
-}
-
-const MOCK_TREND_EXPERIMENT: Experiment = {
- id: 2,
- name: 'aloha',
- start_date: '2023-02-11T10:37:17.634000Z',
- end_date: null,
- feature_flag_key: 'aloha',
- feature_flag: {
- id: 1,
- team_id: 1,
- name: 'Hellp everyone',
- key: 'aloha',
- active: false,
- deleted: false,
- ensure_experience_continuity: false,
- filters: {
- groups: [
- {
- properties: [
- {
- key: 'company_name',
- type: PropertyFilterType.Person,
- value: 'awesome',
- operator: PropertyOperator.IContains,
- },
- ],
- variant: null,
- rollout_percentage: undefined,
- },
- ],
- payloads: {},
- multivariate: {
- variants: [
- {
- key: 'control',
- rollout_percentage: 50,
- },
- {
- key: 'test',
- rollout_percentage: 50,
- },
- ],
- },
- },
- },
- metrics: [],
- metrics_secondary: [],
- parameters: {
- feature_flag_variants: [
- {
- key: 'control',
- rollout_percentage: 50,
- },
- {
- key: 'test',
- rollout_percentage: 50,
- },
- ],
- recommended_sample_size: 0,
- recommended_running_time: 28.3,
- },
- secondary_metrics: [],
- filters: {
- events: [
- {
- id: '$pageview',
- math: 'avg_count_per_actor',
- name: '$pageview',
- type: 'events',
- order: 0,
- },
- ],
- actions: [],
- date_to: '2023-05-19T23:59',
- insight: InsightType.TRENDS,
- interval: 'day',
- date_from: '2023-05-05T11:36',
- filter_test_accounts: false,
- },
- archived: false,
- created_by: {
- id: 1,
- uuid: '01881f35-b41a-0000-1d94-331938392cac',
- distinct_id: 'Xr1OY26ZsDh9ZbvA212ggq4l0Hf0dmEUjT33zvRPKrX',
- first_name: 'SS',
- email: 'test@posthog.com',
- is_email_verified: false,
- },
- created_at: '2022-03-15T21:31:00.192917Z',
- updated_at: '2022-03-15T21:31:00.192917Z',
-}
-
-const MOCK_WEB_EXPERIMENT_MANY_VARIANTS: Experiment = {
- id: 4,
- name: 'web-experiment',
- type: 'web',
- start_date: '2023-02-11T10:37:17.634000Z',
- end_date: null,
- feature_flag_key: 'web-experiment',
- feature_flag: {
- id: 1,
- team_id: 1,
- name: 'Web Experiment on Hawaii.com',
- key: 'web-experiment',
- active: false,
- deleted: false,
- ensure_experience_continuity: false,
- filters: {
- groups: [
- {
- properties: [
- {
- key: 'company_name',
- type: PropertyFilterType.Person,
- value: 'awesome',
- operator: PropertyOperator.IContains,
- },
- ],
- variant: null,
- rollout_percentage: undefined,
- },
- ],
- payloads: {},
- multivariate: {
- variants: [
- {
- key: 'control',
- rollout_percentage: 16,
- },
- {
- key: 'test_1',
- rollout_percentage: 16,
- },
- {
- key: 'test_2',
- rollout_percentage: 16,
- },
- {
- key: 'test_3',
- rollout_percentage: 16,
- },
- {
- key: 'test_4',
- rollout_percentage: 16,
- },
- {
- key: 'test_5',
- rollout_percentage: 20,
- },
- ],
- },
- },
- },
- metrics: [],
- metrics_secondary: [],
- parameters: {
- feature_flag_variants: [
- {
- key: 'control',
- rollout_percentage: 16,
- },
- {
- key: 'test_1',
- rollout_percentage: 16,
- },
- {
- key: 'test_2',
- rollout_percentage: 16,
- },
- {
- key: 'test_3',
- rollout_percentage: 16,
- },
- {
- key: 'test_4',
- rollout_percentage: 16,
- },
- {
- key: 'test_5',
- rollout_percentage: 20,
- },
- ],
- recommended_sample_size: 0,
- recommended_running_time: 28.3,
- },
- secondary_metrics: [],
- filters: {
- events: [
- {
- id: '$pageview',
- math: 'avg_count_per_actor',
- name: '$pageview',
- type: 'events',
- order: 0,
- },
- ],
- actions: [],
- date_to: '2023-05-19T23:59',
- insight: InsightType.TRENDS,
- interval: 'day',
- date_from: '2023-05-05T11:36',
- filter_test_accounts: false,
- },
- archived: false,
- created_by: {
- id: 1,
- uuid: '01881f35-b41a-0000-1d94-331938392cac',
- distinct_id: 'Xr1OY26ZsDh9ZbvA212ggq4l0Hf0dmEUjT33zvRPKrX',
- first_name: 'SS',
- email: 'test@posthog.com',
- is_email_verified: false,
- },
- created_at: '2022-03-15T21:31:00.192917Z',
- updated_at: '2022-03-15T21:31:00.192917Z',
-}
-
-const MOCK_TREND_EXPERIMENT_MANY_VARIANTS: Experiment = {
- id: 3,
- name: 'aloha',
- start_date: '2023-02-11T10:37:17.634000Z',
- end_date: null,
- feature_flag_key: 'aloha',
- feature_flag: {
- id: 1,
- team_id: 1,
- name: 'Hellp everyone',
- key: 'aloha',
- active: false,
- deleted: false,
- ensure_experience_continuity: false,
- filters: {
- groups: [
- {
- properties: [
- {
- key: 'company_name',
- type: PropertyFilterType.Person,
- value: 'awesome',
- operator: PropertyOperator.IContains,
- },
- ],
- variant: null,
- rollout_percentage: undefined,
- },
- ],
- payloads: {},
- multivariate: {
- variants: [
- {
- key: 'control',
- rollout_percentage: 16,
- },
- {
- key: 'test_1',
- rollout_percentage: 16,
- },
- {
- key: 'test_2',
- rollout_percentage: 16,
- },
- {
- key: 'test_3',
- rollout_percentage: 16,
- },
- {
- key: 'test_4',
- rollout_percentage: 16,
- },
- {
- key: 'test_5',
- rollout_percentage: 20,
- },
- ],
- },
- },
- },
- metrics: [],
- metrics_secondary: [],
- parameters: {
- feature_flag_variants: [
- {
- key: 'control',
- rollout_percentage: 16,
- },
- {
- key: 'test_1',
- rollout_percentage: 16,
- },
- {
- key: 'test_2',
- rollout_percentage: 16,
- },
- {
- key: 'test_3',
- rollout_percentage: 16,
- },
- {
- key: 'test_4',
- rollout_percentage: 16,
- },
- {
- key: 'test_5',
- rollout_percentage: 20,
- },
- ],
- recommended_sample_size: 0,
- recommended_running_time: 28.3,
- },
- secondary_metrics: [],
- filters: {
- events: [
- {
- id: '$pageview',
- math: 'avg_count_per_actor',
- name: '$pageview',
- type: 'events',
- order: 0,
- },
- ],
- actions: [],
- date_to: '2023-05-19T23:59',
- insight: InsightType.TRENDS,
- interval: 'day',
- date_from: '2023-05-05T11:36',
- filter_test_accounts: false,
- },
- archived: false,
- created_by: {
- id: 1,
- uuid: '01881f35-b41a-0000-1d94-331938392cac',
- distinct_id: 'Xr1OY26ZsDh9ZbvA212ggq4l0Hf0dmEUjT33zvRPKrX',
- first_name: 'SS',
- email: 'test@posthog.com',
- is_email_verified: false,
- },
- created_at: '2022-03-15T21:31:00.192917Z',
- updated_at: '2022-03-15T21:31:00.192917Z',
-}
-
-const MOCK_EXPERIMENT_RESULTS: FunnelExperimentResults = {
- result: {
- fakeInsightId: '123',
- insight: [
- [
- {
- action_id: '$pageview',
- name: '$pageview',
- order: 0,
- people: [],
- count: 71,
- type: 'events',
- average_conversion_time: null,
- median_conversion_time: null,
- breakdown: ['test'],
- breakdown_value: ['test'],
- converted_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22test%22%5D&funnel_step=1&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- dropped_people_url: null,
- },
- {
- action_id: 'signed_up',
- name: 'signed_up',
- order: 1,
- people: [],
- count: 43,
- type: 'events',
- average_conversion_time: 53.04651162790697,
- median_conversion_time: 53,
- breakdown: ['test'],
- breakdown_value: ['test'],
- converted_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22test%22%5D&funnel_step=2&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- dropped_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22test%22%5D&funnel_step=-2&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- },
- ],
- [
- {
- action_id: '$pageview',
- name: '$pageview',
- custom_name: null,
- order: 0,
- people: [],
- count: 69,
- type: 'events',
- average_conversion_time: null,
- median_conversion_time: null,
- breakdown: ['control'],
- breakdown_value: ['control'],
- converted_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22control%22%5D&funnel_step=1&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- dropped_people_url: null,
- },
- {
- action_id: 'signed_up',
- name: 'signed_up',
- custom_name: null,
- order: 1,
- people: [],
- count: 31,
- type: 'events',
- average_conversion_time: 66.6774193548387,
- median_conversion_time: 63,
- breakdown: ['control'],
- breakdown_value: ['control'],
- converted_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22control%22%5D&funnel_step=2&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- dropped_people_url:
- '/api/person/funnel/?breakdown=%5B%22%24feature%2Fsignup-page-4.0%22%5D&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2022-12-10T08%3A06%3A27.027740%2B00%3A00&date_to=2023-02-07T16%3A12%3A54.055481%2B00%3A00&explicit_date=true&display=FunnelViz&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24current_url%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%22https%3A%2F%2Fhedgebox.net%2Fsignup%2F%22%7D%5D%7D%7D%2C+%7B%22id%22%3A+%22signed_up%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+1%2C+%22name%22%3A+%22signed_up%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+null%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&filter_test_accounts=True&funnel_step_breakdown=%5B%22control%22%5D&funnel_step=-2&funnel_viz_type=steps&funnel_window_interval=14&funnel_window_interval_unit=day&insight=FUNNELS&interval=day&limit=100&smoothing_intervals=1',
- },
- ],
- ],
- probability: {
- control: 0.03264999999999996,
- test: 0.96735,
- },
- significant: false,
- filters: {
- breakdown: ['$feature/signup-page-4.0'],
- breakdown_attribution_type: BreakdownAttributionType.FirstTouch,
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2022-12-10T08:06:27.027740+00:00',
- date_to: '2023-02-07T16:12:54.055481+00:00',
- explicit_date: 'true',
- display: 'FunnelViz',
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: null,
- math_property: null,
- math_group_type_index: null,
- properties: {
- type: 'AND',
- values: [
- {
- key: '$current_url',
- operator: 'exact',
- type: 'event',
- value: 'https://hedgebox.net/signup/',
- },
- ],
- },
- },
- {
- id: 'signed_up',
- type: 'events',
- order: 1,
- name: 'signed_up',
- custom_name: null,
- math: null,
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- filter_test_accounts: true,
- funnel_viz_type: FunnelVizType.Steps,
- funnel_window_interval: 14,
- funnel_window_interval_unit: FunnelConversionWindowTimeUnit.Day,
- insight: InsightType.FUNNELS,
- interval: 'day',
- limit: 100,
- smoothing_intervals: 1,
- sampling_factor: 0.1,
- } as FunnelsFilterType,
- significance_code: SignificanceCode.NotEnoughExposure,
- expected_loss: 1,
- variants: [
- {
- key: 'control',
- success_count: 31,
- failure_count: 38,
- },
- {
- key: 'test',
- success_count: 43,
- failure_count: 28,
- },
- ],
- credible_intervals: {
- control: [0.0126, 0.0526],
- test: [0.0526, 0.0826],
- },
- },
-}
-
-const MOCK_TREND_EXPERIMENT_RESULTS: TrendsExperimentResults = {
- result: {
- fakeInsightId: '1234',
- insight: [
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test',
- count: 26,
- data: [2.5416666666666, 4.5416666666665, 3.5416666665, 1.666666666665, 8.366666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: null,
- properties: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - control',
- count: 11.421053,
- data: [
- 2.4210526315789473, 1.4210526315789473, 3.4210526315789473, 0.4210526315789473, 3.4210526315789473,
- ],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'control',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- ],
- probability: {
- control: 0.407580005,
- test: 0.59242,
- },
- significant: false,
- filters: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- exposure_filters: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- math: 'dau',
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test'],
- },
- ],
- },
- significance_code: SignificanceCode.NotEnoughExposure,
- p_value: 1,
- variants: [
- {
- key: 'control',
- count: 46,
- exposure: 1,
- absolute_exposure: 19,
- },
- {
- key: 'test',
- count: 61,
- exposure: 1.263157894736842,
- absolute_exposure: 24,
- },
- ],
- credible_intervals: {
- control: [1.5678, 3.8765],
- test: [1.2345, 3.4567],
- },
- },
- last_refresh: '2023-02-11T10:37:17.634000Z',
- is_cached: true,
-}
-
-const MOCK_TREND_EXPERIMENT_MANY_VARIANTS_RESULTS: TrendsExperimentResults = {
- result: {
- fakeInsightId: '12345',
- insight: [
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test_1',
- count: 26,
- data: [2.5416666666666, 4.5416666666665, 3.5416666665, 1.666666666665, 8.366666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test_1',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test_2',
- count: 26,
- data: [3.5416666666666, 5.5416666666665, 4.5416666665, 2.666666666665, 9.366666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test_2',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test_3',
- count: 26,
- data: [1.8416666666666, 3.7416666666665, 2.2416666665, 1.166666666665, 8.866666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test_3',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test_4',
- count: 26,
- data: [4.5416666666666, 6.5416666666665, 5.5416666665, 3.666666666665, 10.366666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test_4',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - test_5',
- count: 26,
- data: [0.5416666666666, 2.5416666666665, 1.5416666665, 0.666666666665, 5.366666665],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'test_5',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- date_to: '2023-02-16T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- {
- action: {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: undefined,
- math_group_type_index: null,
- properties: undefined,
- },
- aggregated_value: 0,
- label: '$pageview - control',
- count: 11.421053,
- data: [
- 2.8210526315789473, 2.4210526315789473, 1.4210526315789473, 1.4210526315789473, 2.4210526315789473,
- ],
- labels: ['11-Feb-2023', '12-Feb-2023', '13-Feb-2023', '14-Feb-2023', '15-Feb-2023'],
- days: ['2023-02-11', '2023-02-12', '2023-02-13', '2023-02-14', '2023-02-15'],
- breakdown_value: 'control',
- persons_urls: [
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- {
- url: 'api/projects/1/persons/trends/?breakdown=%24feature%2Faloha&breakdown_attribution_type=first_touch&breakdown_normalize_url=False&breakdown_type=event&date_from=2023-05-19T00%3A00%3A00%2B00%3A00&explicit_date=true&display=ActionsLineGraph&events=%5B%7B%22id%22%3A+%22%24pageview%22%2C+%22type%22%3A+%22events%22%2C+%22order%22%3A+0%2C+%22name%22%3A+%22%24pageview%22%2C+%22custom_name%22%3A+null%2C+%22math%22%3A+%22avg_count_per_actor%22%2C+%22math_property%22%3A+null%2C+%22math_group_type_index%22%3A+null%2C+%22properties%22%3A+%7B%7D%7D%5D&insight=TRENDS&interval=day&properties=%7B%22type%22%3A+%22AND%22%2C+%22values%22%3A+%5B%7B%22key%22%3A+%22%24feature%2Faloha%22%2C+%22operator%22%3A+%22exact%22%2C+%22type%22%3A+%22event%22%2C+%22value%22%3A+%5B%22control%22%2C+%22test%22%5D%7D%5D%7D&sampling_factor=&smoothing_intervals=1&entity_id=%24pageview&entity_type=events&entity_math=avg_count_per_actor&date_to=2023-05-19T00%3A00%3A00%2B00%3A00&breakdown_value=control&cache_invalidation_key=iaDd6ork',
- },
- ],
- filter: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- },
- ],
- probability: {
- control: 0.407580005,
- test_1: 0.59242,
- test_2: 0.49242,
- test_3: 0.29242,
- test_4: 0.19242,
- test_5: 0.09242,
- },
- significant: false,
- filters: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- custom_name: null,
- math: 'avg_count_per_actor',
- math_property: null,
- math_group_type_index: null,
- properties: {},
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- sampling_factor: undefined,
- smoothing_intervals: 1,
- },
- exposure_filters: {
- breakdown: '$feature/aloha',
- breakdown_normalize_url: false,
- breakdown_type: 'event',
- date_from: '2023-02-11T10:37:17.634000Z',
- explicit_date: 'true',
- display: ChartDisplayType.ActionsLineGraph,
- events: [
- {
- id: '$pageview',
- type: 'events',
- order: 0,
- name: '$pageview',
- math: 'dau',
- },
- ],
- insight: InsightType.TRENDS,
- interval: 'day',
- properties: [
- {
- key: '$feature/aloha',
- operator: PropertyOperator.Exact,
- type: PropertyFilterType.Event,
- value: ['control', 'test_1', 'test_2', 'test_3', 'test_4', 'test_5'],
- },
- ],
- },
- significance_code: SignificanceCode.NotEnoughExposure,
- p_value: 1,
- variants: [
- {
- key: 'control',
- count: 46,
- exposure: 1,
- absolute_exposure: 19,
- },
- {
- key: 'test_1',
- count: 63,
- exposure: 1.263157894736842,
- absolute_exposure: 24,
- },
- {
- key: 'test_2',
- count: 21,
- exposure: 5.463157894736842,
- absolute_exposure: 34,
- },
- {
- key: 'test_3',
- count: 31,
- exposure: 4.463157894736842,
- absolute_exposure: 44,
- },
- {
- key: 'test_4',
- count: 41,
- exposure: 3.463157894736842,
- absolute_exposure: 54,
- },
- {
- key: 'test_5',
- count: 51,
- exposure: 2.463157894736842,
- absolute_exposure: 64,
- },
- ],
- credible_intervals: {
- control: [1.5678, 3.8765],
- test_1: [1.2345, 3.4567],
- test_2: [1.3345, 3.5567],
- test_3: [1.4345, 3.5567],
- test_4: [1.5345, 3.5567],
- test_5: [1.6345, 3.6567],
- },
- },
- last_refresh: '2023-02-11T10:37:17.634000Z',
- is_cached: true,
-}
-
-const meta: Meta = {
- title: 'Scenes-App/Experiments',
- parameters: {
- layout: 'fullscreen',
- viewMode: 'story',
- mockDate: '2023-02-15', // To stabilize relative dates
- },
- decorators: [
- mswDecorator({
- get: {
- '/api/projects/:team_id/experiments/': toPaginatedResponse([
- MOCK_FUNNEL_EXPERIMENT,
- MOCK_TREND_EXPERIMENT,
- MOCK_TREND_EXPERIMENT_MANY_VARIANTS,
- MOCK_WEB_EXPERIMENT_MANY_VARIANTS,
- ]),
- '/api/projects/:team_id/experiments/1/': MOCK_FUNNEL_EXPERIMENT,
- '/api/projects/:team_id/experiments/1/results/': MOCK_EXPERIMENT_RESULTS,
- '/api/projects/:team_id/experiments/2/': MOCK_TREND_EXPERIMENT,
- '/api/projects/:team_id/experiments/2/results/': MOCK_TREND_EXPERIMENT_RESULTS,
- '/api/projects/:team_id/experiments/3/': MOCK_TREND_EXPERIMENT_MANY_VARIANTS,
- '/api/projects/:team_id/experiments/3/results/': MOCK_TREND_EXPERIMENT_MANY_VARIANTS_RESULTS,
- '/api/projects/:team_id/experiments/4/': MOCK_WEB_EXPERIMENT_MANY_VARIANTS,
- '/api/projects/:team_id/experiments/4/results/': MOCK_TREND_EXPERIMENT_MANY_VARIANTS_RESULTS,
- },
- }),
- ],
-}
-export default meta
-export const ExperimentsList: StoryFn = () => {
- useEffect(() => {
- router.actions.push(urls.experiments())
- }, [])
- return
-}
-
-export const CompleteFunnelExperiment: StoryFn = () => {
- useEffect(() => {
- router.actions.push(urls.experiment(MOCK_FUNNEL_EXPERIMENT.id))
- }, [])
- return
-}
-CompleteFunnelExperiment.parameters = {
- testOptions: {
- waitForSelector: '.card-secondary',
- },
-}
-
-export const RunningTrendExperiment: StoryFn = () => {
- useEffect(() => {
- router.actions.push(urls.experiment(MOCK_TREND_EXPERIMENT.id))
- }, [])
-
- return
-}
-RunningTrendExperiment.parameters = {
- testOptions: {
- waitForSelector: '.LemonBanner .LemonIcon',
- },
-}
-
-export const RunningTrendExperimentManyVariants: StoryFn = () => {
- useEffect(() => {
- router.actions.push(urls.experiment(MOCK_TREND_EXPERIMENT_MANY_VARIANTS.id))
- }, [])
-
- return
-}
-RunningTrendExperimentManyVariants.parameters = {
- testOptions: {
- waitForSelector: '.LemonBanner .LemonIcon',
- },
-}
-
-export const ExperimentNotFound: StoryFn = () => {
- useEffect(() => {
- router.actions.push(urls.experiment('1200000'))
- }, [])
- return
-}
diff --git a/frontend/src/scenes/experiments/Experiment.tsx b/frontend/src/scenes/experiments/Experiment.tsx
index 6127fa87795fc..cca319e6f486e 100644
--- a/frontend/src/scenes/experiments/Experiment.tsx
+++ b/frontend/src/scenes/experiments/Experiment.tsx
@@ -1,5 +1,3 @@
-import './Experiment.scss'
-
import { useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
import { SceneExport } from 'scenes/sceneTypes'
diff --git a/frontend/src/scenes/experiments/ExperimentForm.tsx b/frontend/src/scenes/experiments/ExperimentForm.tsx
index 125fb2320ddab..9715e32406c2a 100644
--- a/frontend/src/scenes/experiments/ExperimentForm.tsx
+++ b/frontend/src/scenes/experiments/ExperimentForm.tsx
@@ -1,5 +1,3 @@
-import './Experiment.scss'
-
import { IconMagicWand, IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonDivider, LemonInput, LemonTextArea, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentImplementationDetails.tsx b/frontend/src/scenes/experiments/ExperimentImplementationDetails.tsx
index 526bd52595f4d..50d7c81373ef0 100644
--- a/frontend/src/scenes/experiments/ExperimentImplementationDetails.tsx
+++ b/frontend/src/scenes/experiments/ExperimentImplementationDetails.tsx
@@ -192,7 +192,7 @@ export function ExperimentImplementationDetails({ experiment }: ExperimentImplem
}
return (
-
+
Implementation
diff --git a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx
index 7f4378a7ec5ef..ef0bff8ac948a 100644
--- a/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/CumulativeExposuresChart.tsx
@@ -10,11 +10,11 @@ import { BaseMathType, ChartDisplayType, InsightType, PropertyFilterType, Proper
import { experimentLogic } from '../experimentLogic'
export function CumulativeExposuresChart(): JSX.Element {
- const { experiment, experimentResults, getMetricType } = useValues(experimentLogic)
+ const { experiment, metricResults, _getMetricType } = useValues(experimentLogic)
const metricIdx = 0
- const metricType = getMetricType(metricIdx)
-
+ const metricType = _getMetricType(experiment.metrics[metricIdx])
+ const result = metricResults?.[metricIdx]
const variants = experiment.parameters?.feature_flag_variants?.map((variant) => variant.key) || []
if (experiment.holdout) {
variants.push(`holdout-${experiment.holdout.id}`)
@@ -25,7 +25,7 @@ export function CumulativeExposuresChart(): JSX.Element {
if (metricType === InsightType.TRENDS) {
query = {
kind: NodeKind.InsightVizNode,
- source: (experimentResults as CachedExperimentTrendsQueryResponse)?.exposure_query || {
+ source: (result as CachedExperimentTrendsQueryResponse)?.exposure_query || {
kind: NodeKind.TrendsQuery,
series: [],
interval: 'day',
diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
index b6a69aeababa3..5d0b7e1389a52 100644
--- a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconInfo } from '@posthog/icons'
import { LemonButton, LemonDivider, LemonModal, Link, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
@@ -19,7 +17,7 @@ export function DataCollection(): JSX.Element {
const {
experimentId,
experiment,
- getMetricType,
+ _getMetricType,
funnelResultsPersonsTotal,
actualRunningTime,
minimumDetectableEffect,
@@ -27,14 +25,14 @@ export function DataCollection(): JSX.Element {
const { openExperimentCollectionGoalModal } = useActions(experimentLogic)
- const metricType = getMetricType(0)
+ const metricType = _getMetricType(experiment.metrics[0])
const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1
const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100
const experimentProgressPercent =
metricType === InsightType.FUNNELS
- ? (funnelResultsPersonsTotal / recommendedSampleSize) * 100
+ ? (funnelResultsPersonsTotal(0) / recommendedSampleSize) * 100
: (actualRunningTime / recommendedRunningTime) * 100
const hasHighRunningTime = recommendedRunningTime > 62
@@ -82,7 +80,7 @@ export function DataCollection(): JSX.Element {
{metricType === InsightType.TRENDS && (
@@ -111,7 +109,7 @@ export function DataCollection(): JSX.Element {
Saw
- {humanFriendlyNumber(funnelResultsPersonsTotal)} of{' '}
+ {humanFriendlyNumber(funnelResultsPersonsTotal(0))} of{' '}
{humanFriendlyNumber(recommendedSampleSize)}{' '}
{' '}
{formatUnitByQuantity(recommendedSampleSize, 'participant')}
@@ -174,7 +172,8 @@ export function DataCollection(): JSX.Element {
export function DataCollectionGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
const {
isExperimentCollectionGoalModalOpen,
- getMetricType,
+ experiment,
+ _getMetricType,
trendMetricInsightLoading,
funnelMetricInsightLoading,
} = useValues(experimentLogic({ experimentId }))
@@ -183,7 +182,9 @@ export function DataCollectionGoalModal({ experimentId }: { experimentId: Experi
)
const isInsightLoading =
- getMetricType(0) === InsightType.TRENDS ? trendMetricInsightLoading : funnelMetricInsightLoading
+ _getMetricType(experiment.metrics[0]) === InsightType.TRENDS
+ ? trendMetricInsightLoading
+ : funnelMetricInsightLoading
return (
- {getMetricType(0) === InsightType.TRENDS ? (
+ {_getMetricType(experiment.metrics[0]) === InsightType.TRENDS ? (
) : (
diff --git a/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx b/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx
deleted file mode 100644
index 77a7b9d0359b3..0000000000000
--- a/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-import { useValues } from 'kea'
-import { useEffect, useRef, useState } from 'react'
-
-import { InsightType } from '~/types'
-
-import { experimentLogic } from '../experimentLogic'
-import { VariantTag } from './components'
-
-const BAR_HEIGHT = 8
-const BAR_PADDING = 10
-const TICK_PANEL_HEIGHT = 20
-const VIEW_BOX_WIDTH = 800
-const HORIZONTAL_PADDING = 20
-const CONVERSION_RATE_RECT_WIDTH = 2
-const TICK_FONT_SIZE = 7
-
-const COLORS = {
- BOUNDARY_LINES: '#d0d0d0',
- ZERO_LINE: '#666666',
- BAR_NEGATIVE: '#F44435',
- BAR_BEST: '#4DAF4F',
- BAR_DEFAULT: '#d9d9d9',
- BAR_CONTROL: 'rgba(217, 217, 217, 0.4)',
- BAR_MIDDLE_POINT: 'black',
- BAR_MIDDLE_POINT_CONTROL: 'rgba(0, 0, 0, 0.4)',
-}
-
-// Helper function to find nice round numbers for ticks
-export function getNiceTickValues(maxAbsValue: number): number[] {
- // Round up maxAbsValue to ensure we cover all values
- maxAbsValue = Math.ceil(maxAbsValue * 10) / 10
-
- const magnitude = Math.floor(Math.log10(maxAbsValue))
- const power = Math.pow(10, magnitude)
-
- let baseUnit
- const normalizedMax = maxAbsValue / power
- if (normalizedMax <= 1) {
- baseUnit = 0.2 * power
- } else if (normalizedMax <= 2) {
- baseUnit = 0.5 * power
- } else if (normalizedMax <= 5) {
- baseUnit = 1 * power
- } else {
- baseUnit = 2 * power
- }
-
- // Calculate how many baseUnits we need to exceed maxAbsValue
- const unitsNeeded = Math.ceil(maxAbsValue / baseUnit)
-
- // Determine appropriate number of decimal places based on magnitude
- const decimalPlaces = Math.max(0, -magnitude + 1)
-
- const ticks: number[] = []
- for (let i = -unitsNeeded; i <= unitsNeeded; i++) {
- // Round each tick value to avoid floating point precision issues
- const tickValue = Number((baseUnit * i).toFixed(decimalPlaces))
- ticks.push(tickValue)
- }
- return ticks
-}
-
-function formatTickValue(value: number): string {
- if (value === 0) {
- return '0%'
- }
-
- // Determine number of decimal places needed
- const absValue = Math.abs(value)
- let decimals = 0
-
- if (absValue < 0.01) {
- decimals = 3
- } else if (absValue < 0.1) {
- decimals = 2
- } else if (absValue < 1) {
- decimals = 1
- } else {
- decimals = 0
- }
-
- return `${(value * 100).toFixed(decimals)}%`
-}
-
-export function DeltaViz(): JSX.Element {
- const { experiment, experimentResults, getMetricType, metricResults } = useValues(experimentLogic)
-
- if (!experimentResults) {
- return <>>
- }
-
- const variants = experiment.parameters.feature_flag_variants
- const allResults = [...(metricResults || [])]
-
- return (
-
-
- {allResults.map((results, metricIndex) => {
- if (!results) {
- return null
- }
-
- const isFirstMetric = metricIndex === 0
-
- return (
-
-
-
- )
- })}
-
-
- )
-}
-
-function Chart({
- results,
- variants,
- metricType,
- isFirstMetric,
-}: {
- results: any
- variants: any[]
- metricType: InsightType
- isFirstMetric: boolean
-}): JSX.Element {
- const { credibleIntervalForVariant, conversionRateForVariant, experimentId } = useValues(experimentLogic)
- const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null)
-
- // Update chart height calculation to include only one BAR_PADDING for each space between bars
- const chartHeight = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * variants.length
-
- // Find the maximum absolute value from all credible intervals
- const maxAbsValue = Math.max(
- ...variants.flatMap((variant) => {
- const interval = credibleIntervalForVariant(results, variant.key, metricType)
- return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : []
- })
- )
-
- // Add padding to the range
- const padding = Math.max(maxAbsValue * 0.05, 0.02)
- const chartBound = maxAbsValue + padding
-
- const tickValues = getNiceTickValues(chartBound)
- const maxTick = Math.max(...tickValues)
-
- const valueToX = (value: number): number => {
- // Scale the value to fit within the padded area
- const percentage = (value / maxTick + 1) / 2
- return HORIZONTAL_PADDING + percentage * (VIEW_BOX_WIDTH - 2 * HORIZONTAL_PADDING)
- }
-
- const infoPanelWidth = '10%'
-
- const ticksSvgRef = useRef(null)
- const chartSvgRef = useRef(null)
- // :TRICKY: We need to track SVG heights dynamically because
- // we're fitting regular divs to match SVG viewports. SVGs scale
- // based on their viewBox and the viewport size, making it challenging
- // to match their effective rendered heights with regular div elements.
- const [ticksSvgHeight, setTicksSvgHeight] = useState(0)
- const [chartSvgHeight, setChartSvgHeight] = useState(0)
-
- useEffect(() => {
- const ticksSvg = ticksSvgRef.current
- const chartSvg = chartSvgRef.current
-
- // eslint-disable-next-line compat/compat
- const resizeObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- if (entry.target === ticksSvg) {
- setTicksSvgHeight(entry.contentRect.height)
- } else if (entry.target === chartSvg) {
- setChartSvgHeight(entry.contentRect.height)
- }
- }
- })
-
- if (ticksSvg) {
- resizeObserver.observe(ticksSvg)
- }
- if (chartSvg) {
- resizeObserver.observe(chartSvg)
- }
-
- return () => {
- resizeObserver.disconnect()
- }
- }, [])
-
- return (
-
- {/* eslint-disable-next-line react/forbid-dom-props */}
-
- {isFirstMetric && (
-
- )}
- {isFirstMetric &&
}
- {/* eslint-disable-next-line react/forbid-dom-props */}
-
- {variants.map((variant) => (
-
-
-
- ))}
-
-
-
- {/* SVGs container */}
-
- {/* Ticks */}
- {isFirstMetric && (
-
- {tickValues.map((value, index) => {
- const x = valueToX(value)
- return (
-
-
- {formatTickValue(value)}
-
-
- )
- })}
-
- )}
- {isFirstMetric &&
}
- {/* Chart */}
-
- {/* Vertical grid lines */}
- {tickValues.map((value, index) => {
- const x = valueToX(value)
- return (
-
- )
- })}
-
- {variants.map((variant, index) => {
- const interval = credibleIntervalForVariant(results, variant.key, metricType)
- const [lower, upper] = interval ? [interval[0] / 100, interval[1] / 100] : [0, 0]
-
- const variantRate = conversionRateForVariant(results, variant.key)
- const controlRate = conversionRateForVariant(results, 'control')
- const delta = variantRate && controlRate ? (variantRate - controlRate) / controlRate : 0
-
- // Find the highest delta among all variants
- const maxDelta = Math.max(
- ...variants.map((v) => {
- const vRate = conversionRateForVariant(results, v.key)
- return vRate && controlRate ? (vRate - controlRate) / controlRate : 0
- })
- )
-
- let barColor
- if (variant.key === 'control') {
- barColor = COLORS.BAR_DEFAULT
- } else if (delta < 0) {
- barColor = COLORS.BAR_NEGATIVE
- } else if (delta === maxDelta) {
- barColor = COLORS.BAR_BEST
- } else {
- barColor = COLORS.BAR_DEFAULT
- }
-
- const y = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * index
- const x1 = valueToX(lower)
- const x2 = valueToX(upper)
- const deltaX = valueToX(delta)
-
- return (
- {
- const rect = e.currentTarget.getBoundingClientRect()
- setTooltipData({
- x: rect.left + rect.width / 2,
- y: rect.top - 10,
- variant: variant.key,
- })
- }}
- onMouseLeave={() => setTooltipData(null)}
- >
- {/* Invisible full-width rect to ensure consistent hover */}
-
- {/* Visible elements */}
-
-
-
- )
- })}
-
-
- {/* Tooltip */}
- {tooltipData && (
-
-
-
-
- Conversion rate:
-
- {conversionRateForVariant(results, tooltipData.variant)?.toFixed(2)}%
-
-
-
- Delta:
-
- {tooltipData.variant === 'control' ? (
- Baseline
- ) : (
- (() => {
- const variantRate = conversionRateForVariant(results, tooltipData.variant)
- const controlRate = conversionRateForVariant(results, 'control')
- const delta =
- variantRate && controlRate
- ? (variantRate - controlRate) / controlRate
- : 0
- return delta ? (
- 0 ? 'text-success' : 'text-danger'}>
- {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
-
- ) : (
- 'ā'
- )
- })()
- )}
-
-
-
- Credible interval:
-
- {(() => {
- const interval = credibleIntervalForVariant(
- results,
- tooltipData.variant,
- metricType
- )
- const [lower, upper] = interval
- ? [interval[0] / 100, interval[1] / 100]
- : [0, 0]
- return `[${lower > 0 ? '+' : ''}${(lower * 100).toFixed(2)}%, ${
- upper > 0 ? '+' : ''
- }${(upper * 100).toFixed(2)}%]`
- })()}
-
-
-
-
- )}
-
-
- )
-}
diff --git a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
index 5ebf192769a2d..d34901f9716a1 100644
--- a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconBalance, IconFlag } from '@posthog/icons'
import {
LemonBanner,
@@ -25,12 +23,11 @@ import { VariantScreenshot } from './VariantScreenshot'
export function DistributionModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
const { experiment, experimentLoading, isDistributionModalOpen } = useValues(experimentLogic({ experimentId }))
- const { closeDistributionModal, updateExperiment } = useActions(experimentLogic({ experimentId }))
+ const { closeDistributionModal, updateDistributionModal } = useActions(experimentLogic({ experimentId }))
const _featureFlagLogic = featureFlagLogic({ id: experiment.feature_flag?.id ?? null } as FeatureFlagLogicProps)
const { featureFlag, areVariantRolloutsValid, variantRolloutSum } = useValues(_featureFlagLogic)
- const { setFeatureFlagFilters, distributeVariantsEqually, saveSidebarExperimentFeatureFlag } =
- useActions(_featureFlagLogic)
+ const { setFeatureFlagFilters, distributeVariantsEqually } = useActions(_featureFlagLogic)
const handleRolloutPercentageChange = (index: number, value: number | undefined): void => {
if (!featureFlag?.filters?.multivariate || !value) {
@@ -63,13 +60,7 @@ export function DistributionModal({ experimentId }: { experimentId: Experiment['
{
- saveSidebarExperimentFeatureFlag(featureFlag)
- updateExperiment({
- holdout_id: experiment.holdout_id,
- parameters: {
- feature_flag_variants: featureFlag?.filters?.multivariate?.variants ?? [],
- },
- })
+ updateDistributionModal(featureFlag)
closeDistributionModal()
}}
type="primary"
@@ -139,9 +130,11 @@ export function DistributionModal({ experimentId }: { experimentId: Experiment['
export function DistributionTable(): JSX.Element {
const { openDistributionModal } = useActions(experimentLogic)
- const { experimentId, experiment, experimentResults } = useValues(experimentLogic)
+ const { experimentId, experiment, metricResults } = useValues(experimentLogic)
const { reportExperimentReleaseConditionsViewed } = useActions(experimentLogic)
+ const result = metricResults?.[0]
+
const onSelectElement = (variant: string): void => {
LemonDialog.open({
title: 'Select a domain',
@@ -168,7 +161,7 @@ export function DistributionTable(): JSX.Element {
key: 'key',
title: 'Variant',
render: function Key(_, item): JSX.Element {
- if (!experimentResults || !experimentResults.insight) {
+ if (!result || !result.insight) {
return {item.key}
}
return
diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
index 8225391583fc9..06c1ba1707b22 100644
--- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
@@ -1,18 +1,23 @@
-import '../Experiment.scss'
-
import { LemonDivider, LemonTabs } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
+import { FEATURE_FLAGS } from 'lib/constants'
import { PostHogFeature } from 'posthog-js/react'
import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperimentImplementationDetails'
import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails'
import { experimentLogic } from '../experimentLogic'
+import { MetricModal } from '../Metrics/MetricModal'
+import { MetricSourceModal } from '../Metrics/MetricSourceModal'
+import { SavedMetricModal } from '../Metrics/SavedMetricModal'
+import { MetricsView } from '../MetricsView/MetricsView'
import {
ExperimentLoadingAnimation,
+ ExploreButton,
LoadingState,
NoResultsEmptyState,
PageHeaderCustom,
ResultsHeader,
+ ResultsQuery,
} from './components'
import { CumulativeExposuresChart } from './CumulativeExposuresChart'
import { DataCollection } from './DataCollection'
@@ -23,15 +28,58 @@ import { Overview } from './Overview'
import { ReleaseConditionsModal, ReleaseConditionsTable } from './ReleaseConditionsTable'
import { Results } from './Results'
import { SecondaryMetricsTable } from './SecondaryMetricsTable'
+import { SummaryTable } from './SummaryTable'
-const ResultsTab = (): JSX.Element => {
- const { experiment, experimentResults } = useValues(experimentLogic)
+const NewResultsTab = (): JSX.Element => {
+ const { experiment, metricResults } = useValues(experimentLogic)
+ const hasSomeResults = metricResults?.some((result) => result?.insight)
- const hasResultsInsight = experimentResults && experimentResults.insight
+ const hasSinglePrimaryMetric = experiment.metrics.length === 1
return (
-
- {hasResultsInsight ? (
+ <>
+ {!hasSomeResults && (
+ <>
+ {experiment.type === 'web' ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+ {/* Show overview if there's only a single primary metric */}
+ {hasSinglePrimaryMetric && (
+
+
+
+ )}
+
+ {/* Show detailed results if there's only a single primary metric */}
+ {hasSomeResults && hasSinglePrimaryMetric && (
+
+ )}
+
+ >
+ )
+}
+
+const OldResultsTab = (): JSX.Element => {
+ const { experiment, metricResults } = useValues(experimentLogic)
+ const hasSomeResults = metricResults?.some((result) => result?.insight)
+
+ return (
+ <>
+ {hasSomeResults ? (
) : (
<>
@@ -50,10 +98,15 @@ const ResultsTab = (): JSX.Element => {
>
)}
-
+ >
)
}
+const ResultsTab = (): JSX.Element => {
+ const { featureFlags } = useValues(experimentLogic)
+ return <>{featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? : }>
+}
+
const VariantsTab = (): JSX.Element => {
return (
@@ -67,12 +120,19 @@ const VariantsTab = (): JSX.Element => {
}
export function ExperimentView(): JSX.Element {
- const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey } =
- useValues(experimentLogic)
+ const {
+ experimentLoading,
+ metricResultsLoading,
+ secondaryMetricResultsLoading,
+ experimentId,
+ metricResults,
+ tabKey,
+ featureFlags,
+ } = useValues(experimentLogic)
const { setTabKey } = useActions(experimentLogic)
-
- const hasResultsInsight = experimentResults && experimentResults.insight
+ // Instead, check if any result in the array has an insight
+ const hasSomeResults = metricResults?.some((result) => result?.insight)
return (
<>
@@ -83,24 +143,32 @@ export function ExperimentView(): JSX.Element {
) : (
<>
- {experimentResultsLoading ? (
+ {metricResultsLoading || secondaryMetricResultsLoading ? (
) : (
<>
- {hasResultsInsight ? (
+ {hasSomeResults && !featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
+
Summary
) : null}
-
-
-
-
-
-
-
+ {featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
>
)}
+
+
+
+
+
+
+
+
+
>
diff --git a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx
index 59766e480529f..e9bd49756b1de 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Goal.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Goal.tsx
@@ -11,7 +11,6 @@ import { ExperimentFunnelsQuery, ExperimentTrendsQuery, FunnelsQuery, NodeKind,
import { ActionFilter, AnyPropertyFilter, ChartDisplayType, Experiment, FilterType, InsightType } from '~/types'
import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric } from '../experimentLogic'
-import { PrimaryMetricModal } from '../Metrics/PrimaryMetricModal'
import { PrimaryTrendsExposureModal } from '../Metrics/PrimaryTrendsExposureModal'
export function MetricDisplayTrends({ query }: { query: TrendsQuery | undefined }): JSX.Element {
@@ -115,7 +114,7 @@ export function MetricDisplayOld({ filters }: { filters?: FilterType }): JSX.Ele
export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
const { experiment, featureFlags } = useValues(experimentLogic({ experimentId }))
- const { updateExperimentExposure, loadExperiment, setExperiment } = useActions(experimentLogic({ experimentId }))
+ const { updateExperimentGoal, loadExperiment, setExperiment } = useActions(experimentLogic({ experimentId }))
const [isModalOpen, setIsModalOpen] = useState(false)
const metricIdx = 0
@@ -214,16 +213,13 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'
status="danger"
size="xsmall"
onClick={() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setExperiment({
- ...experiment,
- metrics: experiment.metrics.map((metric, idx) =>
- idx === metricIdx ? { ...metric, exposure_query: undefined } : metric
- ),
- })
- }
- updateExperimentExposure(null)
+ setExperiment({
+ ...experiment,
+ metrics: experiment.metrics.map((metric, idx) =>
+ idx === metricIdx ? { ...metric, exposure_query: undefined } : metric
+ ),
+ })
+ updateExperimentGoal()
}}
>
Reset
@@ -244,11 +240,10 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'
}
export function Goal(): JSX.Element {
- const { experiment, experimentId, getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } =
+ const { experiment, experimentId, _getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } =
useValues(experimentLogic)
- const { setExperiment, loadExperiment } = useActions(experimentLogic)
- const [isModalOpen, setIsModalOpen] = useState(false)
- const metricType = getMetricType(0)
+ const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic)
+ const metricType = _getMetricType(experiment.metrics[0])
// :FLAG: CLEAN UP AFTER MIGRATION
const isDataWarehouseMetric =
@@ -298,7 +293,7 @@ export function Goal(): JSX.Element {
filters: getDefaultFilters(InsightType.FUNNELS, undefined),
})
}
- setIsModalOpen(true)
+ openPrimaryMetricModal(0)
}}
>
Add goal
@@ -324,7 +319,7 @@ export function Goal(): JSX.Element {
) : (
)}
-
setIsModalOpen(true)}>
+ openPrimaryMetricModal(0)}>
Change goal
@@ -342,14 +337,6 @@ export function Goal(): JSX.Element {
)}
)}
-
{
- setIsModalOpen(false)
- loadExperiment()
- }}
- />
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/Info.tsx b/frontend/src/scenes/experiments/ExperimentView/Info.tsx
index df08b130fe4ad..ef7940f5fa28e 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Info.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Info.tsx
@@ -1,10 +1,9 @@
-import '../Experiment.scss'
-
import { IconWarning } from '@posthog/icons'
import { Link, ProfilePicture, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { EditableField } from 'lib/components/EditableField/EditableField'
+import { FEATURE_FLAGS } from 'lib/constants'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { urls } from 'scenes/urls'
@@ -16,7 +15,7 @@ import { ActionBanner, ResultsTag, StatusTag } from './components'
import { ExperimentDates } from './ExperimentDates'
export function Info(): JSX.Element {
- const { experiment } = useValues(experimentLogic)
+ const { experiment, featureFlags } = useValues(experimentLogic)
const { updateExperiment } = useActions(experimentLogic)
const { created_by } = experiment
@@ -33,10 +32,12 @@ export function Info(): JSX.Element {
Status
-
+ {!featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && (
+
+ )}
{experiment.feature_flag && (
@@ -98,7 +99,7 @@ export function Info(): JSX.Element {
compactButtons
/>
-
+ {!featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] &&
}
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/Overview.tsx b/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
index 35f18c3c13b67..a6765a64f3f9f 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
@@ -1,50 +1,79 @@
-import '../Experiment.scss'
-
import { useValues } from 'kea'
+import { CachedExperimentFunnelsQueryResponse, CachedExperimentTrendsQueryResponse } from '~/queries/schema'
+import { ExperimentIdType } from '~/types'
+
import { experimentLogic } from '../experimentLogic'
import { VariantTag } from './components'
-export function Overview(): JSX.Element {
- const { experimentId, experimentResults, getIndexForVariant, getHighestProbabilityVariant, areResultsSignificant } =
- useValues(experimentLogic)
-
- function WinningVariantText(): JSX.Element {
- const highestProbabilityVariant = getHighestProbabilityVariant(experimentResults)
- const index = getIndexForVariant(experimentResults, highestProbabilityVariant || '')
- if (highestProbabilityVariant && index !== null && experimentResults) {
- const { probability } = experimentResults
-
- return (
-
-
- is winning with a
-
- {`${(probability[highestProbabilityVariant] * 100).toFixed(2)}% probability`}
-
- of being best.
-
- )
- }
+export function WinningVariantText({
+ result,
+ experimentId,
+}: {
+ result: CachedExperimentFunnelsQueryResponse | CachedExperimentTrendsQueryResponse
+ experimentId: ExperimentIdType
+}): JSX.Element {
+ const { getIndexForVariant, getHighestProbabilityVariant } = useValues(experimentLogic)
- return <>>
- }
+ const highestProbabilityVariant = getHighestProbabilityVariant(result)
+ const index = getIndexForVariant(result, highestProbabilityVariant || '')
+ if (highestProbabilityVariant && index !== null && result) {
+ const { probability } = result
- function SignificanceText(): JSX.Element {
return (
-
-
Your results are
-
{`${areResultsSignificant ? 'significant' : 'not significant'}`}.
+
+
+ is winning with a
+
+ {`${(probability[highestProbabilityVariant] * 100).toFixed(2)}% probability`}
+
+ of being best.
)
}
+ return <>>
+}
+
+export function SignificanceText({
+ metricIndex,
+ isSecondary = false,
+}: {
+ metricIndex: number
+ isSecondary?: boolean
+}): JSX.Element {
+ const { isPrimaryMetricSignificant, isSecondaryMetricSignificant } = useValues(experimentLogic)
+
+ return (
+
+ Your results are
+
+ {`${
+ isSecondary
+ ? isSecondaryMetricSignificant(metricIndex)
+ : isPrimaryMetricSignificant(metricIndex)
+ ? 'significant'
+ : 'not significant'
+ }`}
+ .
+
+
+ )
+}
+
+export function Overview({ metricIndex = 0 }: { metricIndex?: number }): JSX.Element {
+ const { experimentId, metricResults } = useValues(experimentLogic)
+
+ const result = metricResults?.[metricIndex]
+ if (!result) {
+ return <>>
+ }
+
return (
)
diff --git a/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
index dfe6130db788e..fc90635d9a6af 100644
--- a/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconFlag } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonModal, LemonTable, LemonTableColumns, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentView/Results.tsx b/frontend/src/scenes/experiments/ExperimentView/Results.tsx
index 1f34f96fd7518..b031ae5bdf594 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Results.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Results.tsx
@@ -1,22 +1,21 @@
-import '../Experiment.scss'
-
import { useValues } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
import { experimentLogic } from '../experimentLogic'
import { ResultsHeader, ResultsQuery } from './components'
-import { DeltaViz } from './DeltaViz'
import { SummaryTable } from './SummaryTable'
export function Results(): JSX.Element {
- const { experimentResults, featureFlags } = useValues(experimentLogic)
+ const { experiment, metricResults } = useValues(experimentLogic)
+ const result = metricResults?.[0]
+ if (!result) {
+ return <>>
+ }
return (
-
- {featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && }
-
+
+
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
index 52a189c4c324a..3b1db8847de0c 100644
--- a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
@@ -1,36 +1,28 @@
-import { IconInfo, IconPencil, IconPlus } from '@posthog/icons'
+import { IconInfo, IconPencil } from '@posthog/icons'
import { LemonButton, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { EntityFilterInfo } from 'lib/components/EntityFilterInfo'
-import { FEATURE_FLAGS } from 'lib/constants'
import { IconAreaChart } from 'lib/lemon-ui/icons'
import { capitalizeFirstLetter, humanFriendlyNumber } from 'lib/utils'
import { useState } from 'react'
import { Experiment, InsightType } from '~/types'
-import {
- experimentLogic,
- getDefaultFilters,
- getDefaultFunnelsMetric,
- TabularSecondaryMetricResults,
-} from '../experimentLogic'
+import { experimentLogic, TabularSecondaryMetricResults } from '../experimentLogic'
import { SecondaryMetricChartModal } from '../Metrics/SecondaryMetricChartModal'
-import { SecondaryMetricModal } from '../Metrics/SecondaryMetricModal'
+import { MAX_SECONDARY_METRICS } from '../MetricsView/const'
+import { AddSecondaryMetric } from '../MetricsView/MetricsView'
import { VariantTag } from './components'
-const MAX_SECONDARY_METRICS = 10
-
export function SecondaryMetricsTable({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
- const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [isChartModalOpen, setIsChartModalOpen] = useState(false)
const [modalMetricIdx, setModalMetricIdx] = useState
(null)
const {
- experimentResults,
+ metricResults,
secondaryMetricResultsLoading,
experiment,
- getSecondaryMetricType,
+ _getMetricType,
secondaryMetricResults,
tabularSecondaryMetricResults,
countDataForVariant,
@@ -39,20 +31,8 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
credibleIntervalForVariant,
experimentMathAggregationForTrends,
getHighestProbabilityVariant,
- featureFlags,
} = useValues(experimentLogic({ experimentId }))
- const { loadExperiment } = useActions(experimentLogic({ experimentId }))
-
- const openEditModal = (idx: number): void => {
- setModalMetricIdx(idx)
- setIsEditModalOpen(true)
- }
-
- const closeEditModal = (): void => {
- setIsEditModalOpen(false)
- setModalMetricIdx(null)
- loadExperiment()
- }
+ const { openSecondaryMetricModal } = useActions(experimentLogic({ experimentId }))
const openChartModal = (idx: number): void => {
setModalMetricIdx(idx)
@@ -64,13 +44,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
setModalMetricIdx(null)
}
- // :FLAG: CLEAN UP AFTER MIGRATION
- let metrics
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- metrics = experiment.metrics_secondary
- } else {
- metrics = experiment.secondary_metrics
- }
+ const metrics = experiment.metrics_secondary
const columns: LemonTableColumns = [
{
@@ -78,7 +52,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
{
title: Variant
,
render: function Key(_, item: TabularSecondaryMetricResults): JSX.Element {
- if (!experimentResults || !experimentResults.insight) {
+ if (!metricResults?.[0] || !metricResults?.[0].insight) {
return {item.variant}
}
return (
@@ -95,7 +69,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
metrics?.forEach((metric, idx) => {
const targetResults = secondaryMetricResults?.[idx]
const winningVariant = getHighestProbabilityVariant(targetResults || null)
- const metricType = getSecondaryMetricType(idx)
+ const metricType = _getMetricType(metric)
const Header = (): JSX.Element => (
@@ -120,7 +94,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
type="secondary"
size="xsmall"
icon={ }
- onClick={() => openEditModal(idx)}
+ onClick={() => openSecondaryMetricModal(idx)}
/>
@@ -279,11 +253,7 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
{metrics && metrics.length > 0 && (
)}
@@ -305,21 +275,11 @@ export function SecondaryMetricsTable({ experimentId }: { experimentId: Experime
Add up to {MAX_SECONDARY_METRICS} secondary metrics to monitor side effects of your
experiment.
-
+
)}
-
)
}
-
-const AddSecondaryMetricButton = ({
- experimentId,
- metrics,
- openEditModal,
-}: {
- experimentId: Experiment['id']
- metrics: any
- openEditModal: (metricIdx: number) => void
-}): JSX.Element => {
- const { experiment, featureFlags } = useValues(experimentLogic({ experimentId }))
- const { setExperiment } = useActions(experimentLogic({ experimentId }))
- return (
- }
- type="secondary"
- size="small"
- onClick={() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()]
- setExperiment({
- metrics_secondary: newMetricsSecondary,
- })
- openEditModal(newMetricsSecondary.length - 1)
- } else {
- const newSecondaryMetrics = [
- ...experiment.secondary_metrics,
- {
- name: '',
- filters: getDefaultFilters(InsightType.FUNNELS, undefined),
- },
- ]
- setExperiment({
- secondary_metrics: newSecondaryMetrics,
- })
- openEditModal(newSecondaryMetrics.length - 1)
- }
- }}
- disabledReason={
- metrics.length >= MAX_SECONDARY_METRICS
- ? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.`
- : undefined
- }
- >
- Add metric
-
- )
-}
diff --git a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
index 4ba16ded0e86c..536aaa75aa615 100644
--- a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconInfo, IconRewindPlay } from '@posthog/icons'
import { LemonButton, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui'
import { useValues } from 'kea'
@@ -10,9 +8,9 @@ import { humanFriendlyNumber } from 'lib/utils'
import posthog from 'posthog-js'
import { urls } from 'scenes/urls'
+import { ExperimentFunnelsQuery, ExperimentTrendsQuery } from '~/queries/schema'
import {
FilterLogicalOperator,
- FunnelExperimentVariant,
InsightType,
PropertyFilterType,
PropertyOperator,
@@ -25,13 +23,22 @@ import {
import { experimentLogic } from '../experimentLogic'
import { VariantTag } from './components'
-export function SummaryTable(): JSX.Element {
+export function SummaryTable({
+ metric,
+ metricIndex = 0,
+ isSecondary = false,
+}: {
+ metric: ExperimentTrendsQuery | ExperimentFunnelsQuery
+ metricIndex?: number
+ isSecondary?: boolean
+}): JSX.Element {
const {
experimentId,
experiment,
- experimentResults,
+ metricResults,
+ secondaryMetricResults,
tabularExperimentResults,
- getMetricType,
+ _getMetricType,
exposureCountDataForVariant,
conversionRateForVariant,
experimentMathAggregationForTrends,
@@ -39,15 +46,15 @@ export function SummaryTable(): JSX.Element {
getHighestProbabilityVariant,
credibleIntervalForVariant,
} = useValues(experimentLogic)
- const metricType = getMetricType(0)
-
- if (!experimentResults) {
+ const metricType = _getMetricType(metric)
+ const result = isSecondary ? secondaryMetricResults?.[metricIndex] : metricResults?.[metricIndex]
+ if (!result) {
return <>>
}
- const winningVariant = getHighestProbabilityVariant(experimentResults)
+ const winningVariant = getHighestProbabilityVariant(result)
- const columns: LemonTableColumns = [
+ const columns: LemonTableColumns = [
{
key: 'variants',
title: 'Variant',
@@ -66,14 +73,14 @@ export function SummaryTable(): JSX.Element {
key: 'counts',
title: (
- {experimentResults.insight?.[0] && 'action' in experimentResults.insight[0] && (
-
+ {result.insight?.[0] && 'action' in result.insight[0] && (
+
)}
{experimentMathAggregationForTrends() ? 'metric' : 'count'}
),
render: function Key(_, variant): JSX.Element {
- const count = countDataForVariant(experimentResults, variant.key)
+ const count = countDataForVariant(result, variant.key)
if (!count) {
return <>ā>
}
@@ -85,7 +92,7 @@ export function SummaryTable(): JSX.Element {
key: 'exposure',
title: 'Exposure',
render: function Key(_, variant): JSX.Element {
- const exposure = exposureCountDataForVariant(experimentResults, variant.key)
+ const exposure = exposureCountDataForVariant(result, variant.key)
if (!exposure) {
return <>ā>
}
@@ -122,7 +129,7 @@ export function SummaryTable(): JSX.Element {
return Baseline
}
- const controlVariant = (experimentResults.variants as TrendExperimentVariant[]).find(
+ const controlVariant = (result.variants as TrendExperimentVariant[]).find(
({ key }) => key === 'control'
) as TrendExperimentVariant
@@ -163,7 +170,7 @@ export function SummaryTable(): JSX.Element {
return Baseline
}
- const credibleInterval = credibleIntervalForVariant(experimentResults || null, variant.key, metricType)
+ const credibleInterval = credibleIntervalForVariant(result || null, variant.key, metricType)
if (!credibleInterval) {
return <>ā>
}
@@ -183,7 +190,7 @@ export function SummaryTable(): JSX.Element {
key: 'conversionRate',
title: 'Conversion rate',
render: function Key(_, item): JSX.Element {
- const conversionRate = conversionRateForVariant(experimentResults, item.key)
+ const conversionRate = conversionRateForVariant(result, item.key)
if (!conversionRate) {
return <>ā>
}
@@ -206,8 +213,8 @@ export function SummaryTable(): JSX.Element {
return Baseline
}
- const controlConversionRate = conversionRateForVariant(experimentResults, 'control')
- const variantConversionRate = conversionRateForVariant(experimentResults, item.key)
+ const controlConversionRate = conversionRateForVariant(result, 'control')
+ const variantConversionRate = conversionRateForVariant(result, item.key)
if (!controlConversionRate || !variantConversionRate) {
return <>ā>
@@ -237,7 +244,7 @@ export function SummaryTable(): JSX.Element {
return Baseline
}
- const credibleInterval = credibleIntervalForVariant(experimentResults || null, item.key, metricType)
+ const credibleInterval = credibleIntervalForVariant(result || null, item.key, metricType)
if (!credibleInterval) {
return <>ā>
}
@@ -256,15 +263,13 @@ export function SummaryTable(): JSX.Element {
key: 'winProbability',
title: 'Win probability',
sorter: (a, b) => {
- const aPercentage = (experimentResults?.probability?.[a.key] || 0) * 100
- const bPercentage = (experimentResults?.probability?.[b.key] || 0) * 100
+ const aPercentage = (result?.probability?.[a.key] || 0) * 100
+ const bPercentage = (result?.probability?.[b.key] || 0) * 100
return aPercentage - bPercentage
},
render: function Key(_, item): JSX.Element {
const variantKey = item.key
- const percentage =
- experimentResults?.probability?.[variantKey] != undefined &&
- experimentResults.probability?.[variantKey] * 100
+ const percentage = result?.probability?.[variantKey] != undefined && result.probability?.[variantKey] * 100
const isWinning = variantKey === winningVariant
return (
@@ -353,7 +358,7 @@ export function SummaryTable(): JSX.Element {
return (
-
+
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx
index df8580fee68dd..b52df2d7207a8 100644
--- a/frontend/src/scenes/experiments/ExperimentView/components.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconArchive, IconCheck, IconFlask, IconX } from '@posthog/icons'
import {
LemonBanner,
@@ -22,36 +20,23 @@ import { FEATURE_FLAGS } from 'lib/constants'
import { dayjs } from 'lib/dayjs'
import { IconAreaChart } from 'lib/lemon-ui/icons'
import { More } from 'lib/lemon-ui/LemonButton/More'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { useEffect, useState } from 'react'
import { urls } from 'scenes/urls'
import { groupsModel } from '~/models/groupsModel'
-import { filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
-import { queryFromFilters } from '~/queries/nodes/InsightViz/utils'
import { Query } from '~/queries/Query/Query'
import {
- CachedExperimentFunnelsQueryResponse,
- CachedExperimentTrendsQueryResponse,
ExperimentFunnelsQueryResponse,
ExperimentTrendsQueryResponse,
InsightQueryNode,
InsightVizNode,
NodeKind,
} from '~/queries/schema'
-import {
- Experiment,
- Experiment as ExperimentType,
- ExperimentIdType,
- ExperimentResults,
- FilterType,
- InsightShortId,
- InsightType,
-} from '~/types'
+import { Experiment, Experiment as ExperimentType, ExperimentIdType, InsightShortId, InsightType } from '~/types'
import { experimentLogic } from '../experimentLogic'
import { getExperimentStatus, getExperimentStatusColor } from '../experimentsLogic'
-import { getExperimentInsightColour, transformResultFilters } from '../utils'
+import { getExperimentInsightColour } from '../utils'
export function VariantTag({
experimentId,
@@ -64,34 +49,40 @@ export function VariantTag({
muted?: boolean
fontSize?: number
}): JSX.Element {
- const { experiment, experimentResults, getIndexForVariant } = useValues(experimentLogic({ experimentId }))
+ const { experiment, getIndexForVariant, metricResults } = useValues(experimentLogic({ experimentId }))
+
+ if (!metricResults) {
+ return <>>
+ }
if (experiment.holdout && variantKey === `holdout-${experiment.holdout_id}`) {
return (
-
+
- {experiment.holdout.name}
+
+ {experiment.holdout.name}
+
)
}
return (
-
+
@@ -101,15 +92,15 @@ export function VariantTag({
)
}
-export function ResultsTag(): JSX.Element {
- const { areResultsSignificant, significanceDetails } = useValues(experimentLogic)
- const result: { color: LemonTagType; label: string } = areResultsSignificant
+export function ResultsTag({ metricIndex = 0 }: { metricIndex?: number }): JSX.Element {
+ const { isPrimaryMetricSignificant, significanceDetails } = useValues(experimentLogic)
+ const result: { color: LemonTagType; label: string } = isPrimaryMetricSignificant(metricIndex)
? { color: 'success', label: 'Significant' }
: { color: 'primary', label: 'Not significant' }
- if (significanceDetails) {
+ if (significanceDetails(metricIndex)) {
return (
-
+
{result.label}
@@ -125,79 +116,39 @@ export function ResultsTag(): JSX.Element {
}
export function ResultsQuery({
- targetResults,
+ result,
showTable,
}: {
- targetResults: ExperimentResults['result'] | ExperimentTrendsQueryResponse | ExperimentFunnelsQueryResponse | null
+ result: ExperimentTrendsQueryResponse | ExperimentFunnelsQueryResponse | null
showTable: boolean
}): JSX.Element {
- const { featureFlags } = useValues(featureFlagLogic)
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const newQueryResults = targetResults as unknown as
- | CachedExperimentTrendsQueryResponse
- | CachedExperimentFunnelsQueryResponse
-
- const query =
- newQueryResults.kind === NodeKind.ExperimentTrendsQuery
- ? newQueryResults.count_query
- : newQueryResults.funnels_query
- const fakeInsightId = Math.random().toString(36).substring(2, 15)
-
- return (
-
- )
- }
-
- const oldQueryResults = targetResults as ExperimentResults['result']
-
- if (!oldQueryResults?.filters) {
+ if (!result) {
return <>>
}
+ const query = result.kind === NodeKind.ExperimentTrendsQuery ? result.count_query : result.funnels_query
+ const fakeInsightId = Math.random().toString(36).substring(2, 15)
+
return (
}: { icon?: JSX.Element }): JSX.Element {
- const { experimentResults, experiment, featureFlags } = useValues(experimentLogic)
-
- // keep in sync with https://github.com/PostHog/posthog/blob/master/ee/clickhouse/queries/experiments/funnel_experiment_result.py#L71
- // :TRICKY: In the case of no results, we still want users to explore the query, so they can debug further.
- // This generates a close enough query that the backend would use to compute results.
- const filtersFromExperiment: Partial = {
- ...experiment.filters,
- date_from: experiment.start_date,
- date_to: experiment.end_date,
- explicit_date: true,
- breakdown: `$feature/${experiment.feature_flag_key ?? experiment.feature_flag?.key}`,
- breakdown_type: 'event',
- properties: [],
+export function ExploreButton({
+ result,
+ size = 'small',
+}: {
+ result: ExperimentTrendsQueryResponse | ExperimentFunnelsQueryResponse | null
+ size?: 'xsmall' | 'small' | 'large'
+}): JSX.Element {
+ if (!result) {
+ return <>>
}
- let query: InsightVizNode
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const newQueryResults = experimentResults as unknown as
- | CachedExperimentTrendsQueryResponse
- | CachedExperimentFunnelsQueryResponse
-
- const source =
- newQueryResults.kind === NodeKind.ExperimentTrendsQuery
- ? newQueryResults.count_query
- : newQueryResults.funnels_query
-
- query = {
- kind: NodeKind.InsightVizNode,
- source: source as InsightQueryNode,
- }
- } else {
- const oldQueryResults = experimentResults as ExperimentResults['result']
-
- if (!oldQueryResults?.filters) {
- return <>>
- }
-
- query = {
- kind: NodeKind.InsightVizNode,
- source: filtersToQueryNode(
- transformResultFilters(
- oldQueryResults?.filters
- ? { ...oldQueryResults.filters, explicit_date: true }
- : filtersFromExperiment
- )
- ),
- showTable: true,
- showLastComputation: true,
- showLastComputationRefresh: false,
- }
+ const query: InsightVizNode = {
+ kind: NodeKind.InsightVizNode,
+ source: (result.kind === NodeKind.ExperimentTrendsQuery
+ ? result.count_query
+ : result.funnels_query) as InsightQueryNode,
}
return (
}
to={urls.insightNew(undefined, undefined, query)}
+ targetBlank
>
- Explore results
+ Explore as Insight
)
}
export function ResultsHeader(): JSX.Element {
- const { experimentResults } = useValues(experimentLogic)
+ const { metricResults } = useValues(experimentLogic)
+
+ const result = metricResults?.[0]
return (
@@ -286,16 +205,17 @@ export function ResultsHeader(): JSX.Element {
-
{experimentResults && }
+
{result && }
)
}
-export function NoResultsEmptyState(): JSX.Element {
+export function NoResultsEmptyState({ metricIndex = 0 }: { metricIndex?: number }): JSX.Element {
type ErrorCode = 'no-events' | 'no-flag-info' | 'no-control-variant' | 'no-test-variant'
- const { experimentResultsLoading, experimentResultCalculationError } = useValues(experimentLogic)
+ const { metricResultsLoading, primaryMetricsResultErrors } = useValues(experimentLogic)
+ const metricError = primaryMetricsResultErrors?.[metricIndex]
function ChecklistItem({ errorCode, value }: { errorCode: ErrorCode; value: boolean }): JSX.Element {
const failureText = {
@@ -329,28 +249,25 @@ export function NoResultsEmptyState(): JSX.Element {
)
}
- if (experimentResultsLoading) {
+ if (metricResultsLoading) {
return <>>
}
// Validation errors return 400 and are rendered as a checklist
- if (experimentResultCalculationError?.statusCode === 400) {
- let parsedDetail: Record
- try {
- parsedDetail = JSON.parse(experimentResultCalculationError.detail)
- } catch (error) {
+ if (metricError?.statusCode === 400) {
+ if (!metricError.hasDiagnostics) {
return (
Experiment results could not be calculated
-
{experimentResultCalculationError.detail}
+
{metricError.detail}
)
}
const checklistItems = []
- for (const [errorCode, value] of Object.entries(parsedDetail)) {
+ for (const [errorCode, value] of Object.entries(metricError.detail as Record)) {
checklistItems.push( )
}
@@ -379,14 +296,14 @@ export function NoResultsEmptyState(): JSX.Element {
)
}
- if (experimentResultCalculationError?.statusCode === 504) {
+ if (metricError?.statusCode === 504) {
return (
Experiment results timed out
- {!!experimentResultCalculationError && (
+ {!!metricError && (
This may occur when the experiment has a large amount of data or is particularly
complex. We are actively working on fixing this. In the meantime, please try refreshing
@@ -406,11 +323,7 @@ export function NoResultsEmptyState(): JSX.Element {
Experiment results could not be calculated
- {!!experimentResultCalculationError && (
-
- {experimentResultCalculationError.detail}
-
- )}
+ {!!metricError &&
{metricError.detail}
}
@@ -457,7 +370,7 @@ export function PageHeaderCustom(): JSX.Element {
experiment,
isExperimentRunning,
isExperimentStopped,
- areResultsSignificant,
+ isPrimaryMetricSignificant,
isSingleVariantShipped,
featureFlags,
hasGoalSet,
@@ -466,7 +379,7 @@ export function PageHeaderCustom(): JSX.Element {
launchExperiment,
endExperiment,
archiveExperiment,
- loadExperimentResults,
+ loadMetricResults,
loadSecondaryMetricResults,
createExposureCohort,
openShipVariantModal,
@@ -507,11 +420,11 @@ export function PageHeaderCustom(): JSX.Element {
{exposureCohortId ? 'View' : 'Create'} exposure cohort
loadExperimentResults(true)}
+ onClick={() => loadMetricResults(true)}
fullWidth
data-attr="refresh-experiment"
>
- Refresh experiment results
+ Refresh primary metrics
loadSecondaryMetricResults(true)}
@@ -590,7 +503,7 @@ export function PageHeaderCustom(): JSX.Element {
)}
{featureFlags[FEATURE_FLAGS.EXPERIMENT_MAKE_DECISION] &&
- areResultsSignificant &&
+ isPrimaryMetricSignificant(0) &&
!isSingleVariantShipped && (
<>
@@ -612,12 +525,17 @@ export function PageHeaderCustom(): JSX.Element {
}
export function ShipVariantModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
- const { experiment, sortedWinProbabilities, isShipVariantModalOpen } = useValues(experimentLogic({ experimentId }))
+ const { experiment, isShipVariantModalOpen } = useValues(experimentLogic({ experimentId }))
const { closeShipVariantModal, shipVariant } = useActions(experimentLogic({ experimentId }))
const { aggregationLabel } = useValues(groupsModel)
const [selectedVariantKey, setSelectedVariantKey] = useState()
- useEffect(() => setSelectedVariantKey(sortedWinProbabilities[0]?.key), [sortedWinProbabilities])
+ useEffect(() => {
+ if (experiment.parameters?.feature_flag_variants?.length > 1) {
+ // First test variant selected by default
+ setSelectedVariantKey(experiment.parameters.feature_flag_variants[1].key)
+ }
+ }, [experiment])
const aggregationTargetName =
experiment.filters.aggregation_group_type_index != null
@@ -657,20 +575,19 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i
className="w-full"
data-attr="metrics-selector"
value={selectedVariantKey}
- onChange={(variantKey) => setSelectedVariantKey(variantKey)}
- options={sortedWinProbabilities.map(({ key }) => ({
- value: key,
- label: (
-
-
- {key === sortedWinProbabilities[0]?.key && (
-
- Winning
-
- )}
-
- ),
- }))}
+ onChange={(variantKey) => {
+ setSelectedVariantKey(variantKey)
+ }}
+ options={
+ experiment.parameters?.feature_flag_variants?.map(({ key }) => ({
+ value: key,
+ label: (
+
+
+
+ ),
+ })) || []
+ }
/>
@@ -694,12 +611,12 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i
export function ActionBanner(): JSX.Element {
const {
experiment,
- getMetricType,
- experimentResults,
+ _getMetricType,
+ metricResults,
experimentLoading,
- experimentResultsLoading,
+ metricResultsLoading,
isExperimentRunning,
- areResultsSignificant,
+ isPrimaryMetricSignificant,
isExperimentStopped,
funnelResultsPersonsTotal,
actualRunningTime,
@@ -708,11 +625,12 @@ export function ActionBanner(): JSX.Element {
featureFlags,
} = useValues(experimentLogic)
+ const result = metricResults?.[0]
const { archiveExperiment } = useActions(experimentLogic)
const { aggregationLabel } = useValues(groupsModel)
- const metricType = getMetricType(0)
+ const metricType = _getMetricType(experiment.metrics[0])
const aggregationTargetName =
experiment.filters.aggregation_group_type_index != null
@@ -722,7 +640,7 @@ export function ActionBanner(): JSX.Element {
const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1
const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100
- if (!experiment || experimentLoading || experimentResultsLoading) {
+ if (!experiment || experimentLoading || metricResultsLoading) {
return <>>
}
@@ -768,12 +686,12 @@ export function ActionBanner(): JSX.Element {
}
// Running, results present, not significant
- if (isExperimentRunning && experimentResults && !isExperimentStopped && !areResultsSignificant) {
+ if (isExperimentRunning && result && !isExperimentStopped && !isPrimaryMetricSignificant(0)) {
// Results insignificant, but a large enough sample/running time has been achieved
// Further collection unlikely to change the result -> recommmend cutting the losses
if (
metricType === InsightType.FUNNELS &&
- funnelResultsPersonsTotal > Math.max(recommendedSampleSize, 500) &&
+ funnelResultsPersonsTotal(0) > Math.max(recommendedSampleSize, 500) &&
dayjs().diff(experiment.start_date, 'day') > 2 // at least 2 days running
) {
return (
@@ -802,9 +720,9 @@ export function ActionBanner(): JSX.Element {
}
// Running, results significant
- if (isExperimentRunning && !isExperimentStopped && areResultsSignificant && experimentResults) {
- const { probability } = experimentResults
- const winningVariant = getHighestProbabilityVariant(experimentResults)
+ if (isExperimentRunning && !isExperimentStopped && isPrimaryMetricSignificant(0) && result) {
+ const { probability } = result
+ const winningVariant = getHighestProbabilityVariant(result)
if (!winningVariant) {
return <>>
}
@@ -814,7 +732,7 @@ export function ActionBanner(): JSX.Element {
// Win probability only slightly over 0.9 and the recommended sample/time just met -> proceed with caution
if (
metricType === InsightType.FUNNELS &&
- funnelResultsPersonsTotal < recommendedSampleSize + 50 &&
+ funnelResultsPersonsTotal(0) < recommendedSampleSize + 50 &&
winProbability < 0.93
) {
return (
@@ -850,7 +768,7 @@ export function ActionBanner(): JSX.Element {
}
// Stopped, results significant
- if (isExperimentStopped && areResultsSignificant) {
+ if (isExperimentStopped && isPrimaryMetricSignificant(0)) {
return (
You have stopped this experiment, and it is no longer collecting data. With significant results in hand,
@@ -868,7 +786,7 @@ export function ActionBanner(): JSX.Element {
}
// Stopped, results not significant
- if (isExperimentStopped && experimentResults && !areResultsSignificant) {
+ if (isExperimentStopped && result && !isPrimaryMetricSignificant(0)) {
return (
You have stopped this experiment, and it is no longer collecting data. Because your results are not
diff --git a/frontend/src/scenes/experiments/Experiments.tsx b/frontend/src/scenes/experiments/Experiments.tsx
index 26c84171c6a8c..e31d1958000cd 100644
--- a/frontend/src/scenes/experiments/Experiments.tsx
+++ b/frontend/src/scenes/experiments/Experiments.tsx
@@ -5,6 +5,7 @@ import { ExperimentsHog } from 'lib/components/hedgehogs'
import { MemberSelect } from 'lib/components/MemberSelect'
import { PageHeader } from 'lib/components/PageHeader'
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
+import { FEATURE_FLAGS } from 'lib/constants'
import { dayjs } from 'lib/dayjs'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { More } from 'lib/lemon-ui/LemonButton/More'
@@ -23,6 +24,7 @@ import { Experiment, ExperimentsTabs, ProductKey, ProgressStatus } from '~/types
import { experimentsLogic, getExperimentStatus } from './experimentsLogic'
import { StatusTag } from './ExperimentView/components'
import { Holdouts } from './Holdouts'
+import { SavedMetrics } from './SavedMetrics/SavedMetrics'
export const scene: SceneExport = {
component: Experiments,
@@ -30,8 +32,16 @@ export const scene: SceneExport = {
}
export function Experiments(): JSX.Element {
- const { filteredExperiments, experimentsLoading, tab, searchTerm, shouldShowEmptyState, searchStatus, userFilter } =
- useValues(experimentsLogic)
+ const {
+ filteredExperiments,
+ experimentsLoading,
+ tab,
+ searchTerm,
+ shouldShowEmptyState,
+ searchStatus,
+ userFilter,
+ featureFlags,
+ } = useValues(experimentsLogic)
const { setExperimentsTab, deleteExperiment, archiveExperiment, setSearchStatus, setSearchTerm, setUserFilter } =
useActions(experimentsLogic)
@@ -211,11 +221,16 @@ export function Experiments(): JSX.Element {
{ key: ExperimentsTabs.Yours, label: 'Your experiments' },
{ key: ExperimentsTabs.Archived, label: 'Archived experiments' },
{ key: ExperimentsTabs.Holdouts, label: 'Holdout groups' },
+ ...(featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS]
+ ? [{ key: ExperimentsTabs.SavedMetrics, label: 'Shared metrics' }]
+ : []),
]}
/>
{tab === ExperimentsTabs.Holdouts ? (
+ ) : tab === ExperimentsTabs.SavedMetrics && featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
+
) : (
<>
{tab === ExperimentsTabs.Archived ? (
diff --git a/frontend/src/scenes/experiments/Metrics/FunnelsMetricForm.tsx b/frontend/src/scenes/experiments/Metrics/FunnelsMetricForm.tsx
new file mode 100644
index 0000000000000..46d6cccce4c5e
--- /dev/null
+++ b/frontend/src/scenes/experiments/Metrics/FunnelsMetricForm.tsx
@@ -0,0 +1,183 @@
+import { LemonLabel } from '@posthog/lemon-ui'
+import { LemonInput } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
+import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
+import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
+import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
+import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
+import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
+import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
+import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
+import { Query } from '~/queries/Query/Query'
+import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
+import { BreakdownAttributionType, FilterType } from '~/types'
+
+import { experimentLogic } from '../experimentLogic'
+import {
+ commonActionFilterProps,
+ FunnelAggregationSelect,
+ FunnelAttributionSelect,
+ FunnelConversionWindowFilter,
+} from './Selectors'
+export function FunnelsMetricForm({ isSecondary = false }: { isSecondary?: boolean }): JSX.Element {
+ const { currentTeam } = useValues(teamLogic)
+ const { experiment, isExperimentRunning, editingPrimaryMetricIndex, editingSecondaryMetricIndex } =
+ useValues(experimentLogic)
+ const { setFunnelsMetric } = useActions(experimentLogic)
+ const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+
+ const metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics
+ const metricIdx = isSecondary ? editingSecondaryMetricIndex : editingPrimaryMetricIndex
+
+ if (!metricIdx && metricIdx !== 0) {
+ return <>>
+ }
+
+ const currentMetric = metrics[metricIdx] as ExperimentFunnelsQuery
+
+ const actionFilterProps = {
+ ...commonActionFilterProps,
+ actionsTaxonomicGroupTypes: [TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions],
+ }
+
+ return (
+ <>
+
+ Name (optional)
+ {
+ setFunnelsMetric({
+ metricIdx,
+ name: newName,
+ isSecondary,
+ })
+ }}
+ />
+
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.None
+ )
+
+ setFunnelsMetric({
+ metricIdx,
+ series,
+ isSecondary,
+ })
+ }}
+ typeKey="experiment-metric"
+ mathAvailability={MathAvailability.None}
+ buttonCopy="Add funnel step"
+ showSeriesIndicator={true}
+ seriesIndicatorType="numeric"
+ sortable={true}
+ showNestedArrow={true}
+ {...actionFilterProps}
+ />
+
+ {
+ setFunnelsMetric({
+ metricIdx,
+ funnelAggregateByHogQL: value,
+ isSecondary,
+ })
+ }}
+ />
+ {
+ setFunnelsMetric({
+ metricIdx,
+ funnelWindowInterval: funnelWindowInterval,
+ isSecondary,
+ })
+ }}
+ onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
+ setFunnelsMetric({
+ metricIdx,
+ funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
+ isSecondary,
+ })
+ }}
+ />
+ {
+ const breakdownAttributionType =
+ currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType
+ const breakdownAttributionValue =
+ currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue
+
+ const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
+ !breakdownAttributionType
+ ? BreakdownAttributionType.FirstTouch
+ : breakdownAttributionType === BreakdownAttributionType.Step
+ ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
+ : breakdownAttributionType
+
+ return currentValue
+ })()}
+ onChange={(value) => {
+ const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
+ setFunnelsMetric({
+ metricIdx,
+ breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
+ breakdownAttributionValue: breakdownAttributionValue
+ ? parseInt(breakdownAttributionValue)
+ : undefined,
+ isSecondary,
+ })
+ }}
+ stepsLength={currentMetric.funnels_query?.series?.length}
+ />
+ {
+ const val = currentMetric.funnels_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setFunnelsMetric({
+ metricIdx,
+ filterTestAccounts: checked,
+ isSecondary,
+ })
+ }}
+ fullWidth
+ />
+
+ {isExperimentRunning && (
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
+ mismatch between the preview and the actual results.
+
+ )}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/scenes/experiments/Metrics/MetricModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx
new file mode 100644
index 0000000000000..889d68ae4d31f
--- /dev/null
+++ b/frontend/src/scenes/experiments/Metrics/MetricModal.tsx
@@ -0,0 +1,138 @@
+import { LemonButton, LemonDialog, LemonModal, LemonSelect } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+
+import { ExperimentFunnelsQuery } from '~/queries/schema'
+import { Experiment, InsightType } from '~/types'
+
+import { experimentLogic, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
+import { FunnelsMetricForm } from './FunnelsMetricForm'
+import { TrendsMetricForm } from './TrendsMetricForm'
+
+export function MetricModal({
+ experimentId,
+ isSecondary,
+}: {
+ experimentId: Experiment['id']
+ isSecondary?: boolean
+}): JSX.Element {
+ const {
+ experiment,
+ experimentLoading,
+ _getMetricType,
+ isPrimaryMetricModalOpen,
+ isSecondaryMetricModalOpen,
+ editingPrimaryMetricIndex,
+ editingSecondaryMetricIndex,
+ } = useValues(experimentLogic({ experimentId }))
+ const { updateExperimentGoal, setExperiment, closePrimaryMetricModal, closeSecondaryMetricModal } = useActions(
+ experimentLogic({ experimentId })
+ )
+
+ const metricIdx = isSecondary ? editingSecondaryMetricIndex : editingPrimaryMetricIndex
+ const metricsField = isSecondary ? 'metrics_secondary' : 'metrics'
+
+ if (!metricIdx && metricIdx !== 0) {
+ return <>>
+ }
+
+ const metrics = experiment[metricsField]
+ const metric = metrics[metricIdx]
+ const metricType = _getMetricType(metric)
+ const funnelStepsLength = (metric as ExperimentFunnelsQuery)?.funnels_query?.series?.length || 0
+
+ return (
+
+ {
+ LemonDialog.open({
+ title: 'Delete this metric?',
+ content: This action cannot be undone.
,
+ primaryButton: {
+ children: 'Delete',
+ type: 'primary',
+ onClick: () => {
+ const newMetrics = metrics.filter((_, idx) => idx !== metricIdx)
+ setExperiment({
+ [metricsField]: newMetrics,
+ })
+ updateExperimentGoal()
+ },
+ size: 'small',
+ },
+ secondaryButton: {
+ children: 'Cancel',
+ type: 'tertiary',
+ size: 'small',
+ },
+ })
+ }}
+ >
+ Delete
+
+
+
+ Cancel
+
+ {
+ updateExperimentGoal()
+ }}
+ type="primary"
+ loading={experimentLoading}
+ data-attr="create-annotation-submit"
+ >
+ Save
+
+
+
+ }
+ >
+
+ Metric type
+ {
+ setExperiment({
+ ...experiment,
+ [metricsField]: [
+ ...metrics.slice(0, metricIdx),
+ newMetricType === InsightType.TRENDS
+ ? getDefaultTrendsMetric()
+ : getDefaultFunnelsMetric(),
+ ...metrics.slice(metricIdx + 1),
+ ],
+ })
+ }}
+ options={[
+ { value: InsightType.TRENDS, label: Trends },
+ { value: InsightType.FUNNELS, label: Funnels },
+ ]}
+ />
+
+ {metricType === InsightType.TRENDS ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx
new file mode 100644
index 0000000000000..bd2134359d9f8
--- /dev/null
+++ b/frontend/src/scenes/experiments/Metrics/MetricSourceModal.tsx
@@ -0,0 +1,73 @@
+import { LemonModal } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+
+import { Experiment } from '~/types'
+
+import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic'
+
+export function MetricSourceModal({
+ experimentId,
+ isSecondary,
+}: {
+ experimentId: Experiment['id']
+ isSecondary?: boolean
+}): JSX.Element {
+ const { experiment, isPrimaryMetricSourceModalOpen, isSecondaryMetricSourceModalOpen } = useValues(
+ experimentLogic({ experimentId })
+ )
+ const {
+ setExperiment,
+ closePrimaryMetricSourceModal,
+ closeSecondaryMetricSourceModal,
+ openPrimaryMetricModal,
+ openPrimarySavedMetricModal,
+ openSecondaryMetricModal,
+ openSecondarySavedMetricModal,
+ } = useActions(experimentLogic({ experimentId }))
+
+ const metricsField = isSecondary ? 'metrics_secondary' : 'metrics'
+ const isOpen = isSecondary ? isSecondaryMetricSourceModalOpen : isPrimaryMetricSourceModalOpen
+ const closeCurrentModal = isSecondary ? closeSecondaryMetricSourceModal : closePrimaryMetricSourceModal
+ const openMetricModal = isSecondary ? openSecondaryMetricModal : openPrimaryMetricModal
+ const openSavedMetricModal = isSecondary ? openSecondarySavedMetricModal : openPrimarySavedMetricModal
+
+ return (
+
+
+
{
+ closeCurrentModal()
+
+ const newMetrics = [...experiment[metricsField], getDefaultFunnelsMetric()]
+ setExperiment({
+ [metricsField]: newMetrics,
+ })
+ openMetricModal(newMetrics.length - 1)
+ }}
+ >
+
+ Custom
+
+
+ Create a new metric specific to this experiment.
+
+
+
{
+ closeCurrentModal()
+ openSavedMetricModal(null)
+ }}
+ >
+
+ Shared
+
+
+ Use a pre-configured metric that can be reused across experiments.
+
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
deleted file mode 100644
index 50468541a0d9b..0000000000000
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
+++ /dev/null
@@ -1,316 +0,0 @@
-import { LemonLabel } from '@posthog/lemon-ui'
-import { LemonInput } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
-import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
-import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
-import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
-import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
-import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
-import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
-import { teamLogic } from 'scenes/teamLogic'
-
-import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
-import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
-import { Query } from '~/queries/Query/Query'
-import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
-import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types'
-
-import { experimentLogic } from '../experimentLogic'
-import {
- commonActionFilterProps,
- FunnelAggregationSelect,
- FunnelAttributionSelect,
- FunnelConversionWindowFilter,
-} from './Selectors'
-export function PrimaryGoalFunnels(): JSX.Element {
- const { currentTeam } = useValues(teamLogic)
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
- const { setExperiment, setFunnelsMetric } = useActions(experimentLogic)
- const hasFilters = (currentTeam?.test_account_filters || []).length > 0
-
- const metricIdx = 0
- const currentMetric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery
-
- const actionFilterProps = {
- ...commonActionFilterProps,
- // Remove data warehouse from the list because it's not supported in experiments
- actionsTaxonomicGroupTypes: [TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions],
- }
-
- return (
- <>
-
- Name (optional)
- {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
- {
- setFunnelsMetric({
- metricIdx,
- name: newName,
- })
- }}
- />
- )}
-
-
{
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return queryNodeToFilter(currentMetric.funnels_query)
- }
- return experiment.filters
- })()}
- setFilters={({ actions, events, data_warehouse }: Partial): void => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const series = actionsAndEventsToSeries(
- { actions, events, data_warehouse } as any,
- true,
- MathAvailability.None
- )
-
- setFunnelsMetric({
- metricIdx,
- series,
- })
- } else {
- if (actions?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- actions,
- events: undefined,
- data_warehouse: undefined,
- },
- })
- } else if (events?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- events,
- actions: undefined,
- data_warehouse: undefined,
- },
- })
- } else if (data_warehouse?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- data_warehouse,
- actions: undefined,
- events: undefined,
- },
- })
- }
- }
- }}
- typeKey="experiment-metric"
- mathAvailability={MathAvailability.None}
- buttonCopy="Add funnel step"
- showSeriesIndicator={true}
- seriesIndicatorType="numeric"
- sortable={true}
- showNestedArrow={true}
- {...actionFilterProps}
- />
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return getHogQLValue(
- currentMetric.funnels_query.aggregation_group_type_index ?? undefined,
- currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined
- )
- }
- return getHogQLValue(
- experiment.filters.aggregation_group_type_index,
- (experiment.filters as FunnelsFilterType).funnel_aggregate_by_hogql
- )
- })()}
- onChange={(value) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelAggregateByHogQL: value,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- funnel_aggregate_by_hogql: value,
- },
- })
- }
- }}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval
- }
- return (experiment.filters as FunnelsFilterType).funnel_window_interval
- })()}
- funnelWindowIntervalUnit={(() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit
- }
- return (experiment.filters as FunnelsFilterType).funnel_window_interval_unit
- })()}
- onFunnelWindowIntervalChange={(funnelWindowInterval) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelWindowInterval: funnelWindowInterval,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- funnel_window_interval: funnelWindowInterval,
- },
- })
- }
- }}
- onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- funnel_window_interval_unit: funnelWindowIntervalUnit || undefined,
- },
- })
- }
- }}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- let breakdownAttributionType
- let breakdownAttributionValue
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- breakdownAttributionType =
- currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType
- breakdownAttributionValue =
- currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue
- } else {
- breakdownAttributionType = (experiment.filters as FunnelsFilterType)
- .breakdown_attribution_type
- breakdownAttributionValue = (experiment.filters as FunnelsFilterType)
- .breakdown_attribution_value
- }
-
- const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
- !breakdownAttributionType
- ? BreakdownAttributionType.FirstTouch
- : breakdownAttributionType === BreakdownAttributionType.Step
- ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
- : breakdownAttributionType
-
- return currentValue
- })()}
- onChange={(value) => {
- const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
- breakdownAttributionValue: breakdownAttributionValue
- ? parseInt(breakdownAttributionValue)
- : undefined,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- breakdown_attribution_type: breakdownAttributionType as BreakdownAttributionType,
- breakdown_attribution_value: breakdownAttributionValue
- ? parseInt(breakdownAttributionValue)
- : 0,
- },
- })
- }
- }}
- stepsLength={(() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.series?.length
- }
- return Math.max(
- experiment.filters.actions?.length ?? 0,
- experiment.filters.events?.length ?? 0,
- experiment.filters.data_warehouse?.length ?? 0
- )
- })()}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const val = (experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query
- ?.filterTestAccounts
- return hasFilters ? !!val : false
- }
- return hasFilters ? !!experiment.filters.filter_test_accounts : false
- })()}
- onChange={(checked: boolean) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- filterTestAccounts: checked,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- filter_test_accounts: checked,
- },
- })
- }
- }}
- fullWidth
- />
-
- {isExperimentRunning && (
-
- Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
- mismatch between the preview and the actual results.
-
- )}
-
- {/* :FLAG: CLEAN UP AFTER MIGRATION */}
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query
- }
- return filtersToQueryNode(experiment.filters)
- })(),
- showTable: false,
- showLastComputation: true,
- showLastComputationRefresh: false,
- }}
- readOnly
- />
-
- >
- )
-}
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
index 0ce1cb72e33da..7ab7bc0880b5a 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
@@ -1,160 +1,280 @@
-import { LemonInput, LemonLabel } from '@posthog/lemon-ui'
+import { IconCheckCircle } from '@posthog/icons'
+import { LemonInput, LemonLabel, LemonTabs, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
-import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
+import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
+import { dayjs } from 'lib/dayjs'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
+import { useState } from 'react'
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
import { teamLogic } from 'scenes/teamLogic'
-import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
+import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
import { Query } from '~/queries/Query/Query'
-import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema'
-import { FilterType } from '~/types'
+import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema'
+import { BaseMathType, ChartDisplayType, FilterType } from '~/types'
import { experimentLogic } from '../experimentLogic'
import { commonActionFilterProps } from './Selectors'
export function PrimaryGoalTrends(): JSX.Element {
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
- const { setExperiment, setTrendsMetric } = useActions(experimentLogic)
+ const { experiment, isExperimentRunning, editingPrimaryMetricIndex } = useValues(experimentLogic)
+ const { setTrendsMetric, setTrendsExposureMetric, setExperiment } = useActions(experimentLogic)
const { currentTeam } = useValues(teamLogic)
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+ const [activeTab, setActiveTab] = useState('main')
- const metricIdx = 0
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
+
+ const metricIdx = editingPrimaryMetricIndex
const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery
return (
<>
-
- Name (optional)
- {featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
- {
- setTrendsMetric({
- metricIdx,
- name: newName,
- })
- }}
- />
- )}
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return queryNodeToFilter(currentMetric.count_query)
- }
- return experiment.filters
- })()}
- setFilters={({ actions, events, data_warehouse }: Partial): void => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const series = actionsAndEventsToSeries(
- { actions, events, data_warehouse } as any,
- true,
- MathAvailability.All
- )
+ setActiveTab(newKey)}
+ tabs={[
+ {
+ key: 'main',
+ label: 'Main metric',
+ content: (
+ <>
+
+ Name (optional)
+ {
+ setTrendsMetric({
+ metricIdx,
+ name: newName,
+ })
+ }}
+ />
+
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
+
+ setTrendsMetric({
+ metricIdx,
+ series,
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ {...commonActionFilterProps}
+ />
+
+ {
+ setTrendsMetric({
+ metricIdx,
+ filterTestAccounts: checked,
+ })
+ }}
+ fullWidth
+ />
+
+ {isExperimentRunning && (
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of
+ data. This can cause a mismatch between the preview and the actual results.
+
+ )}
+
+
+
+ >
+ ),
+ },
+ {
+ key: 'exposure',
+ label: 'Exposure',
+ content: (
+ <>
+
+
{
+ setExperiment({
+ ...experiment,
+ metrics: experiment.metrics.map((metric, idx) =>
+ idx === metricIdx
+ ? { ...metric, exposure_query: undefined }
+ : metric
+ ),
+ })
+ }}
+ >
+
+ Default
+ {!currentMetric.exposure_query && (
+
+ )}
+
+
+ Uses the number of unique users who trigger the{' '}
+ $feature_flag_called event as your exposure count. This
+ is the recommended setting for most experiments, as it accurately tracks
+ variant exposure.
+
+
+
{
+ setExperiment({
+ ...experiment,
+ metrics: experiment.metrics.map((metric, idx) =>
+ idx === metricIdx
+ ? {
+ ...metric,
+ exposure_query: {
+ kind: NodeKind.TrendsQuery,
+ series: [
+ {
+ kind: NodeKind.EventsNode,
+ name: '$feature_flag_called',
+ event: '$feature_flag_called',
+ math: BaseMathType.UniqueUsers,
+ },
+ ],
+ interval: 'day',
+ dateRange: {
+ date_from: dayjs()
+ .subtract(EXPERIMENT_DEFAULT_DURATION, 'day')
+ .format('YYYY-MM-DDTHH:mm'),
+ date_to: dayjs()
+ .endOf('d')
+ .format('YYYY-MM-DDTHH:mm'),
+ explicitDate: true,
+ },
+ trendsFilter: {
+ display: ChartDisplayType.ActionsLineGraph,
+ },
+ filterTestAccounts: true,
+ },
+ }
+ : metric
+ ),
+ })
+ }}
+ >
+
+ Custom
+ {currentMetric.exposure_query && (
+
+ )}
+
+
+ Define your own exposure metric for specific use cases, such as counting by
+ sessions instead of users. This gives you full control but requires careful
+ configuration.
+
+
+
+ {currentMetric.exposure_query && (
+ <>
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
- setTrendsMetric({
- metricIdx,
- series,
- })
- } else {
- if (actions?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- actions,
- events: undefined,
- data_warehouse: undefined,
- },
- })
- } else if (events?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- events,
- actions: undefined,
- data_warehouse: undefined,
- },
- })
- } else if (data_warehouse?.length) {
- setExperiment({
- filters: {
- ...experiment.filters,
- data_warehouse,
- actions: undefined,
- events: undefined,
- },
- })
- }
- }
- }}
- typeKey="experiment-metric"
- buttonCopy="Add graph series"
- showSeriesIndicator={true}
- entitiesLimit={1}
- showNumericalPropsOnly={true}
- {...commonActionFilterProps}
+ setTrendsExposureMetric({
+ metricIdx,
+ series,
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ {...commonActionFilterProps}
+ />
+
+ {
+ const val = currentMetric.exposure_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setTrendsExposureMetric({
+ metricIdx,
+ filterTestAccounts: checked,
+ })
+ }}
+ fullWidth
+ />
+
+ {isExperimentRunning && (
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION}{' '}
+ days of data. This can cause a mismatch between the preview and the
+ actual results.
+
+ )}
+
+
+
+ >
+ )}
+ >
+ ),
+ },
+ ]}
/>
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const val = currentMetric.count_query?.filterTestAccounts
- return hasFilters ? !!val : false
- }
- return hasFilters ? !!experiment.filters.filter_test_accounts : false
- })()}
- onChange={(checked: boolean) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setTrendsMetric({
- metricIdx,
- filterTestAccounts: checked,
- })
- } else {
- setExperiment({
- filters: {
- ...experiment.filters,
- filter_test_accounts: checked,
- },
- })
- }
- }}
- fullWidth
- />
-
- {isExperimentRunning && (
-
- Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
- mismatch between the preview and the actual results.
-
- )}
-
- {/* :FLAG: CLEAN UP AFTER MIGRATION */}
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.count_query
- }
- return filtersToQueryNode(experiment.filters)
- })(),
- showTable: false,
- showLastComputation: true,
- showLastComputationRefresh: false,
- }}
- readOnly
- />
-
>
)
}
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
index 4ebe43c30e928..1dfa4aa8b08a4 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
@@ -16,11 +16,17 @@ import { experimentLogic } from '../experimentLogic'
import { commonActionFilterProps } from './Selectors'
export function PrimaryGoalTrendsExposure(): JSX.Element {
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
+ const { experiment, isExperimentRunning, featureFlags, editingPrimaryMetricIndex } = useValues(experimentLogic)
const { setExperiment, setTrendsExposureMetric } = useActions(experimentLogic)
const { currentTeam } = useValues(teamLogic)
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const currentMetric = experiment.metrics[0] as ExperimentTrendsQuery
+
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
+
+ const metricIdx = editingPrimaryMetricIndex
+ const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery
return (
<>
@@ -43,7 +49,7 @@ export function PrimaryGoalTrendsExposure(): JSX.Element {
)
setTrendsExposureMetric({
- metricIdx: 0,
+ metricIdx,
series,
})
} else {
@@ -109,7 +115,7 @@ export function PrimaryGoalTrendsExposure(): JSX.Element {
// :FLAG: CLEAN UP AFTER MIGRATION
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
setTrendsExposureMetric({
- metricIdx: 0,
+ metricIdx,
filterTestAccounts: checked,
})
} else {
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx
deleted file mode 100644
index 14fd6c7d4e967..0000000000000
--- a/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
-
-import { ExperimentFunnelsQuery } from '~/queries/schema'
-import { Experiment, InsightType } from '~/types'
-
-import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
-import { PrimaryGoalFunnels } from '../Metrics/PrimaryGoalFunnels'
-import { PrimaryGoalTrends } from '../Metrics/PrimaryGoalTrends'
-
-export function PrimaryMetricModal({
- experimentId,
- isOpen,
- onClose,
-}: {
- experimentId: Experiment['id']
- isOpen: boolean
- onClose: () => void
-}): JSX.Element {
- const { experiment, experimentLoading, getMetricType, featureFlags } = useValues(experimentLogic({ experimentId }))
- const { updateExperimentGoal, setExperiment } = useActions(experimentLogic({ experimentId }))
-
- const metricIdx = 0
- const metricType = getMetricType(metricIdx)
-
- let funnelStepsLength = 0
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && metricType === InsightType.FUNNELS) {
- const metric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery
- funnelStepsLength = metric?.funnels_query?.series?.length || 0
- } else {
- funnelStepsLength = (experiment.filters?.events?.length || 0) + (experiment.filters?.actions?.length || 0)
- }
-
- return (
-
-
- Cancel
-
- {
- updateExperimentGoal(experiment.filters)
- }}
- type="primary"
- loading={experimentLoading}
- data-attr="create-annotation-submit"
- >
- Save
-
-
- }
- >
-
- Metric type
- {
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setExperiment({
- ...experiment,
- metrics: [
- ...experiment.metrics.slice(0, metricIdx),
- newMetricType === InsightType.TRENDS
- ? getDefaultTrendsMetric()
- : getDefaultFunnelsMetric(),
- ...experiment.metrics.slice(metricIdx + 1),
- ],
- })
- } else {
- setExperiment({
- ...experiment,
- filters: getDefaultFilters(newMetricType, undefined),
- })
- }
- }}
- options={[
- { value: InsightType.TRENDS, label: Trends },
- { value: InsightType.FUNNELS, label: Funnels },
- ]}
- />
-
- {metricType === InsightType.TRENDS ?
:
}
-
- )
-}
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx
index 7c4f49c114a73..a7d410258d662 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryTrendsExposureModal.tsx
@@ -1,6 +1,5 @@
import { LemonButton, LemonModal } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
import { Experiment } from '~/types'
@@ -16,8 +15,8 @@ export function PrimaryTrendsExposureModal({
isOpen: boolean
onClose: () => void
}): JSX.Element {
- const { experiment, experimentLoading, featureFlags } = useValues(experimentLogic({ experimentId }))
- const { updateExperimentExposure, updateExperiment } = useActions(experimentLogic({ experimentId }))
+ const { experiment, experimentLoading } = useValues(experimentLogic({ experimentId }))
+ const { updateExperiment } = useActions(experimentLogic({ experimentId }))
return (
{
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- updateExperiment({
- metrics: experiment.metrics,
- })
- } else {
- updateExperimentExposure(experiment.parameters.custom_exposure_filter ?? null)
- }
+ updateExperiment({
+ metrics: experiment.metrics,
+ })
}}
type="primary"
loading={experimentLoading}
diff --git a/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx
new file mode 100644
index 0000000000000..3f6cfbc01f2c9
--- /dev/null
+++ b/frontend/src/scenes/experiments/Metrics/SavedMetricModal.tsx
@@ -0,0 +1,143 @@
+import { LemonButton, LemonModal, LemonSelect, Link } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { IconOpenInNew } from 'lib/lemon-ui/icons'
+import { useEffect, useState } from 'react'
+import { urls } from 'scenes/urls'
+
+import { Experiment } from '~/types'
+
+import { experimentLogic } from '../experimentLogic'
+import { MetricDisplayFunnels, MetricDisplayTrends } from '../ExperimentView/Goal'
+import { SavedMetric } from '../SavedMetrics/savedMetricLogic'
+
+export function SavedMetricModal({
+ experimentId,
+ isSecondary,
+}: {
+ experimentId: Experiment['id']
+ isSecondary?: boolean
+}): JSX.Element {
+ const { savedMetrics, isPrimarySavedMetricModalOpen, isSecondarySavedMetricModalOpen, editingSavedMetricId } =
+ useValues(experimentLogic({ experimentId }))
+ const {
+ closePrimarySavedMetricModal,
+ closeSecondarySavedMetricModal,
+ addSavedMetricToExperiment,
+ removeSavedMetricFromExperiment,
+ } = useActions(experimentLogic({ experimentId }))
+
+ const [selectedMetricId, setSelectedMetricId] = useState(null)
+ const [mode, setMode] = useState<'create' | 'edit'>('create')
+
+ useEffect(() => {
+ if (editingSavedMetricId) {
+ setSelectedMetricId(editingSavedMetricId)
+ setMode('edit')
+ }
+ }, [editingSavedMetricId])
+
+ if (!savedMetrics) {
+ return <>>
+ }
+
+ const isOpen = isSecondary ? isSecondarySavedMetricModalOpen : isPrimarySavedMetricModalOpen
+ const closeModal = isSecondary ? closeSecondarySavedMetricModal : closePrimarySavedMetricModal
+
+ return (
+
+
+ {editingSavedMetricId && (
+ {
+ removeSavedMetricFromExperiment(editingSavedMetricId)
+ }}
+ type="secondary"
+ >
+ Remove from experiment
+
+ )}
+
+
+
+ Cancel
+
+ {/* Changing the existing metric is a pain because saved metrics are stored separately */}
+ {/* Only allow deletion for now */}
+ {mode === 'create' && (
+ {
+ if (selectedMetricId) {
+ addSavedMetricToExperiment(selectedMetricId, {
+ type: isSecondary ? 'secondary' : 'primary',
+ })
+ }
+ }}
+ type="primary"
+ disabledReason={!selectedMetricId ? 'Please select a metric' : undefined}
+ >
+ Add metric
+
+ )}
+
+
+ }
+ >
+ {mode === 'create' && (
+
+ ({
+ label: metric.name,
+ value: metric.id,
+ }))}
+ placeholder="Select a saved metric"
+ loading={false}
+ value={selectedMetricId}
+ onSelect={(value) => {
+ setSelectedMetricId(value)
+ }}
+ />
+
+ )}
+
+ {selectedMetricId && (
+
+ {(() => {
+ const metric = savedMetrics.find((m: SavedMetric) => m.id === selectedMetricId)
+ if (!metric) {
+ return <>>
+ }
+
+ return (
+
+
+
{metric.name}
+
+
+
+
+ {metric.description &&
{metric.description}
}
+ {metric.query.kind === 'ExperimentTrendsQuery' && (
+
+ )}
+ {metric.query.kind === 'ExperimentFunnelsQuery' && (
+
+ )}
+
+ )
+ })()}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx
deleted file mode 100644
index a0e903fdeab84..0000000000000
--- a/frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx
+++ /dev/null
@@ -1,391 +0,0 @@
-import { LemonLabel } from '@posthog/lemon-ui'
-import { LemonInput } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
-import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
-import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
-import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
-import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
-import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
-import { teamLogic } from 'scenes/teamLogic'
-
-import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
-import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
-import { Query } from '~/queries/Query/Query'
-import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
-import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types'
-
-import { experimentLogic } from '../experimentLogic'
-import {
- commonActionFilterProps,
- FunnelAggregationSelect,
- FunnelAttributionSelect,
- FunnelConversionWindowFilter,
-} from './Selectors'
-
-export function SecondaryGoalFunnels({ metricIdx }: { metricIdx: number }): JSX.Element {
- const { currentTeam } = useValues(teamLogic)
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
- const { setExperiment, setFunnelsMetric } = useActions(experimentLogic)
- const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery
-
- return (
- <>
-
- Name (optional)
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.name
- }
- return experiment.secondary_metrics[metricIdx].name
- })()}
- onChange={(newName) => {
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- name: newName,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx ? { ...metric, name: newName } : metric
- ),
- })
- }
- }}
- />
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return queryNodeToFilter(currentMetric.funnels_query)
- }
- return experiment.secondary_metrics[metricIdx].filters
- })()}
- setFilters={({ actions, events, data_warehouse }: Partial): void => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const series = actionsAndEventsToSeries(
- { actions, events, data_warehouse } as any,
- true,
- MathAvailability.None
- )
-
- setFunnelsMetric({
- metricIdx,
- series,
- isSecondary: true,
- })
- } else {
- if (actions?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- actions,
- events: undefined,
- data_warehouse: undefined,
- },
- }
- : metric
- ),
- })
- } else if (events?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- events,
- actions: undefined,
- data_warehouse: undefined,
- },
- }
- : metric
- ),
- })
- } else if (data_warehouse?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- data_warehouse,
- actions: undefined,
- events: undefined,
- },
- }
- : metric
- ),
- })
- }
- }
- }}
- typeKey="experiment-metric"
- mathAvailability={MathAvailability.None}
- buttonCopy="Add funnel step"
- showSeriesIndicator={true}
- seriesIndicatorType="numeric"
- sortable={true}
- showNestedArrow={true}
- {...commonActionFilterProps}
- />
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return getHogQLValue(
- currentMetric.funnels_query.aggregation_group_type_index ?? undefined,
- currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined
- )
- }
- return getHogQLValue(
- experiment.secondary_metrics[metricIdx].filters.aggregation_group_type_index,
- (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
- .funnel_aggregate_by_hogql
- )
- })()}
- onChange={(value) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelAggregateByHogQL: value,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- funnel_aggregate_by_hogql: value,
- },
- }
- : metric
- ),
- })
- }
- }}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval
- }
- return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
- .funnel_window_interval
- })()}
- funnelWindowIntervalUnit={(() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit
- }
- return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
- .funnel_window_interval_unit
- })()}
- onFunnelWindowIntervalChange={(funnelWindowInterval) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelWindowInterval: funnelWindowInterval,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- funnel_window_interval: funnelWindowInterval,
- },
- }
- : metric
- ),
- })
- }
- }}
- onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- funnel_window_interval_unit: funnelWindowIntervalUnit || undefined,
- },
- }
- : metric
- ),
- })
- }
- }}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- let breakdownAttributionType
- let breakdownAttributionValue
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- breakdownAttributionType =
- currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType
- breakdownAttributionValue =
- currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue
- } else {
- breakdownAttributionType = (
- experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType
- ).breakdown_attribution_type
- breakdownAttributionValue = (
- experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType
- ).breakdown_attribution_value
- }
-
- const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
- !breakdownAttributionType
- ? BreakdownAttributionType.FirstTouch
- : breakdownAttributionType === BreakdownAttributionType.Step
- ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
- : breakdownAttributionType
-
- return currentValue
- })()}
- onChange={(value) => {
- const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
- breakdownAttributionValue: breakdownAttributionValue
- ? parseInt(breakdownAttributionValue)
- : undefined,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- breakdown_attribution_type:
- breakdownAttributionType as BreakdownAttributionType,
- breakdown_attribution_value: breakdownAttributionValue
- ? parseInt(breakdownAttributionValue)
- : 0,
- },
- }
- : metric
- ),
- })
- }
- }}
- stepsLength={(() => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query?.series?.length
- }
- return Math.max(
- experiment.secondary_metrics[metricIdx].filters.actions?.length ?? 0,
- experiment.secondary_metrics[metricIdx].filters.events?.length ?? 0,
- experiment.secondary_metrics[metricIdx].filters.data_warehouse?.length ?? 0
- )
- })()}
- />
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const val = (experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery)
- .funnels_query?.filterTestAccounts
- return hasFilters ? !!val : false
- }
- return hasFilters
- ? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts
- : false
- })()}
- onChange={(checked: boolean) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setFunnelsMetric({
- metricIdx,
- filterTestAccounts: checked,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- filter_test_accounts: checked,
- },
- }
- : metric
- ),
- })
- }
- }}
- fullWidth
- />
-
- {isExperimentRunning && (
-
- Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
- mismatch between the preview and the actual results.
-
- )}
-
- {/* :FLAG: CLEAN UP AFTER MIGRATION */}
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.funnels_query
- }
- return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters)
- })(),
- showTable: false,
- showLastComputation: true,
- showLastComputationRefresh: false,
- }}
- readOnly
- />
-
- >
- )
-}
diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx
deleted file mode 100644
index 20aae645e6e1e..0000000000000
--- a/frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import { LemonLabel } from '@posthog/lemon-ui'
-import { LemonInput } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
-import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
-import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
-import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
-import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
-import { teamLogic } from 'scenes/teamLogic'
-
-import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
-import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
-import { Query } from '~/queries/Query/Query'
-import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema'
-import { FilterType } from '~/types'
-
-import { experimentLogic } from '../experimentLogic'
-import { commonActionFilterProps } from './Selectors'
-
-export function SecondaryGoalTrends({ metricIdx }: { metricIdx: number }): JSX.Element {
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
- const { setExperiment, setTrendsMetric } = useActions(experimentLogic)
- const { currentTeam } = useValues(teamLogic)
- const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentTrendsQuery
-
- return (
- <>
-
- Name (optional)
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.name
- }
- return experiment.secondary_metrics[metricIdx].name
- })()}
- onChange={(newName) => {
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setTrendsMetric({
- metricIdx,
- name: newName,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx ? { ...metric, name: newName } : metric
- ),
- })
- }
- }}
- />
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return queryNodeToFilter(currentMetric.count_query)
- }
- return experiment.secondary_metrics[metricIdx].filters
- })()}
- setFilters={({ actions, events, data_warehouse }: Partial): void => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const series = actionsAndEventsToSeries(
- { actions, events, data_warehouse } as any,
- true,
- MathAvailability.All
- )
-
- setTrendsMetric({
- metricIdx,
- series,
- isSecondary: true,
- })
- } else {
- if (actions?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- actions,
- events: undefined,
- data_warehouse: undefined,
- },
- }
- : metric
- ),
- })
- } else if (events?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- events,
- actions: undefined,
- data_warehouse: undefined,
- },
- }
- : metric
- ),
- })
- } else if (data_warehouse?.length) {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- data_warehouse,
- actions: undefined,
- events: undefined,
- },
- }
- : metric
- ),
- })
- }
- }
- }}
- typeKey="experiment-metric"
- buttonCopy="Add graph series"
- showSeriesIndicator={true}
- entitiesLimit={1}
- showNumericalPropsOnly={true}
- {...commonActionFilterProps}
- />
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const val = currentMetric.count_query?.filterTestAccounts
- return hasFilters ? !!val : false
- }
- return hasFilters
- ? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts
- : false
- })()}
- onChange={(checked: boolean) => {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setTrendsMetric({
- metricIdx,
- filterTestAccounts: checked,
- isSecondary: true,
- })
- } else {
- setExperiment({
- secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
- idx === metricIdx
- ? {
- ...metric,
- filters: {
- ...metric.filters,
- filter_test_accounts: checked,
- },
- }
- : metric
- ),
- })
- }
- }}
- fullWidth
- />
-
- {isExperimentRunning && (
-
- Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
- mismatch between the preview and the actual results.
-
- )}
-
- {/* :FLAG: CLEAN UP AFTER MIGRATION */}
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return currentMetric.count_query
- }
- return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters)
- })(),
- showTable: false,
- showLastComputation: true,
- showLastComputationRefresh: false,
- }}
- readOnly
- />
-
- >
- )
-}
diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx
index ec540aa43c056..184137bb59be6 100644
--- a/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx
+++ b/frontend/src/scenes/experiments/Metrics/SecondaryMetricChartModal.tsx
@@ -32,7 +32,7 @@ export function SecondaryMetricChartModal({
}
>
-
+
)
}
diff --git a/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx
deleted file mode 100644
index 14a8304b973e2..0000000000000
--- a/frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
-
-import { Experiment, InsightType } from '~/types'
-
-import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
-import { SecondaryGoalFunnels } from './SecondaryGoalFunnels'
-import { SecondaryGoalTrends } from './SecondaryGoalTrends'
-
-export function SecondaryMetricModal({
- experimentId,
- metricIdx,
- isOpen,
- onClose,
-}: {
- experimentId: Experiment['id']
- metricIdx: number
- isOpen: boolean
- onClose: () => void
-}): JSX.Element {
- const { experiment, experimentLoading, getSecondaryMetricType, featureFlags } = useValues(
- experimentLogic({ experimentId })
- )
- const { setExperiment, updateExperiment } = useActions(experimentLogic({ experimentId }))
- const metricType = getSecondaryMetricType(metricIdx)
-
- return (
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const newMetricsSecondary = experiment.metrics_secondary.filter(
- (_, idx) => idx !== metricIdx
- )
- setExperiment({
- metrics_secondary: newMetricsSecondary,
- })
- updateExperiment({
- metrics_secondary: newMetricsSecondary,
- })
- } else {
- const newSecondaryMetrics = experiment.secondary_metrics.filter(
- (_, idx) => idx !== metricIdx
- )
- setExperiment({
- secondary_metrics: newSecondaryMetrics,
- })
- updateExperiment({
- secondary_metrics: newSecondaryMetrics,
- })
- }
- }}
- >
- Delete
-
-
-
- Cancel
-
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- updateExperiment({
- metrics_secondary: experiment.metrics_secondary,
- })
- } else {
- updateExperiment({
- secondary_metrics: experiment.secondary_metrics,
- })
- }
- }}
- type="primary"
- loading={experimentLoading}
- data-attr="create-annotation-submit"
- >
- Save
-
-
-
- }
- >
-
- Metric type
- {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- setExperiment({
- ...experiment,
- metrics_secondary: [
- ...experiment.metrics_secondary.slice(0, metricIdx),
- newMetricType === InsightType.TRENDS
- ? getDefaultTrendsMetric()
- : getDefaultFunnelsMetric(),
- ...experiment.metrics_secondary.slice(metricIdx + 1),
- ],
- })
- } else {
- setExperiment({
- ...experiment,
- secondary_metrics: [
- ...experiment.secondary_metrics.slice(0, metricIdx),
- newMetricType === InsightType.TRENDS
- ? { name: '', filters: getDefaultFilters(InsightType.TRENDS, undefined) }
- : { name: '', filters: getDefaultFilters(InsightType.FUNNELS, undefined) },
- ...experiment.secondary_metrics.slice(metricIdx + 1),
- ],
- })
- }
- }}
- options={[
- { value: InsightType.TRENDS, label: Trends },
- { value: InsightType.FUNNELS, label: Funnels },
- ]}
- />
-
- {metricType === InsightType.TRENDS ? (
-
- ) : (
-
- )}
-
- )
-}
diff --git a/frontend/src/scenes/experiments/Metrics/TrendsMetricForm.tsx b/frontend/src/scenes/experiments/Metrics/TrendsMetricForm.tsx
new file mode 100644
index 0000000000000..bfe5cb88ec168
--- /dev/null
+++ b/frontend/src/scenes/experiments/Metrics/TrendsMetricForm.tsx
@@ -0,0 +1,291 @@
+import { IconCheckCircle } from '@posthog/icons'
+import { LemonInput, LemonLabel, LemonTabs, LemonTag } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
+import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
+import { dayjs } from 'lib/dayjs'
+import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
+import { useState } from 'react'
+import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
+import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
+import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
+import { Query } from '~/queries/Query/Query'
+import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema'
+import { BaseMathType, ChartDisplayType, FilterType, PropertyMathType } from '~/types'
+
+import { experimentLogic } from '../experimentLogic'
+import { commonActionFilterProps } from './Selectors'
+
+export function TrendsMetricForm({ isSecondary = false }: { isSecondary?: boolean }): JSX.Element {
+ const { experiment, isExperimentRunning, editingPrimaryMetricIndex, editingSecondaryMetricIndex } =
+ useValues(experimentLogic)
+ const { setTrendsMetric, setTrendsExposureMetric, setExperiment } = useActions(experimentLogic)
+ const { currentTeam } = useValues(teamLogic)
+ const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+ const [activeTab, setActiveTab] = useState('main')
+
+ const metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics
+ const metricIdx = isSecondary ? editingSecondaryMetricIndex : editingPrimaryMetricIndex
+
+ if (!metricIdx && metricIdx !== 0) {
+ return <>>
+ }
+
+ const currentMetric = metrics[metricIdx] as ExperimentTrendsQuery
+
+ return (
+ <>
+
setActiveTab(newKey)}
+ tabs={[
+ {
+ key: 'main',
+ label: 'Main metric',
+ content: (
+ <>
+
+ Name (optional)
+ {
+ setTrendsMetric({
+ metricIdx,
+ name: newName,
+ isSecondary,
+ })
+ }}
+ />
+
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
+
+ setTrendsMetric({
+ metricIdx,
+ series,
+ isSecondary,
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ onlyPropertyMathDefinitions={[PropertyMathType.Average]}
+ {...commonActionFilterProps}
+ />
+
+ {
+ setTrendsMetric({
+ metricIdx,
+ filterTestAccounts: checked,
+ isSecondary,
+ })
+ }}
+ fullWidth
+ />
+
+ {isExperimentRunning && (
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of
+ data. This can cause a mismatch between the preview and the actual results.
+
+ )}
+
+
+
+ >
+ ),
+ },
+ {
+ key: 'exposure',
+ label: 'Exposure',
+ content: (
+ <>
+
+
{
+ const metricsField = isSecondary ? 'metrics_secondary' : 'metrics'
+ setExperiment({
+ ...experiment,
+ [metricsField]: metrics.map((metric, idx) =>
+ idx === metricIdx
+ ? { ...metric, exposure_query: undefined }
+ : metric
+ ),
+ })
+ }}
+ >
+
+ Default
+ {!currentMetric.exposure_query && (
+
+ )}
+
+
+ Uses the number of unique users who trigger the{' '}
+ $feature_flag_called event as your exposure count. This
+ is the recommended setting for most experiments, as it accurately tracks
+ variant exposure.
+
+
+
{
+ const metricsField = isSecondary ? 'metrics_secondary' : 'metrics'
+ setExperiment({
+ ...experiment,
+ [metricsField]: metrics.map((metric, idx) =>
+ idx === metricIdx
+ ? {
+ ...metric,
+ exposure_query: {
+ kind: NodeKind.TrendsQuery,
+ series: [
+ {
+ kind: NodeKind.EventsNode,
+ name: '$feature_flag_called',
+ event: '$feature_flag_called',
+ math: BaseMathType.UniqueUsers,
+ },
+ ],
+ interval: 'day',
+ dateRange: {
+ date_from: dayjs()
+ .subtract(EXPERIMENT_DEFAULT_DURATION, 'day')
+ .format('YYYY-MM-DDTHH:mm'),
+ date_to: dayjs()
+ .endOf('d')
+ .format('YYYY-MM-DDTHH:mm'),
+ explicitDate: true,
+ },
+ trendsFilter: {
+ display: ChartDisplayType.ActionsLineGraph,
+ },
+ filterTestAccounts: true,
+ },
+ }
+ : metric
+ ),
+ })
+ }}
+ >
+
+ Custom
+ {currentMetric.exposure_query && (
+
+ )}
+
+
+ Define your own exposure metric for specific use cases, such as counting by
+ sessions instead of users. This gives you full control but requires careful
+ configuration.
+
+
+
+ {currentMetric.exposure_query && (
+ <>
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
+
+ setTrendsExposureMetric({
+ metricIdx,
+ series,
+ isSecondary,
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ {...commonActionFilterProps}
+ />
+
+ {
+ const val = currentMetric.exposure_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setTrendsExposureMetric({
+ metricIdx,
+ filterTestAccounts: checked,
+ isSecondary,
+ })
+ }}
+ fullWidth
+ />
+
+ {isExperimentRunning && (
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION}{' '}
+ days of data. This can cause a mismatch between the preview and the
+ actual results.
+
+ )}
+
+
+
+ >
+ )}
+ >
+ ),
+ },
+ ]}
+ />
+ >
+ )
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx
new file mode 100644
index 0000000000000..88d24827fa63a
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx
@@ -0,0 +1,881 @@
+import {
+ IconActivity,
+ IconArrowRight,
+ IconFunnels,
+ IconGraph,
+ IconMinus,
+ IconPencil,
+ IconTrending,
+} from '@posthog/icons'
+import { LemonBanner, LemonButton, LemonModal, LemonTag, LemonTagType, Tooltip } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonProgress } from 'lib/lemon-ui/LemonProgress'
+import { humanFriendlyNumber } from 'lib/utils'
+import { useEffect, useRef, useState } from 'react'
+
+import { themeLogic } from '~/layout/navigation-3000/themeLogic'
+import { InsightType, TrendExperimentVariant } from '~/types'
+
+import { experimentLogic } from '../experimentLogic'
+import { ExploreButton, ResultsQuery, VariantTag } from '../ExperimentView/components'
+import { SignificanceText, WinningVariantText } from '../ExperimentView/Overview'
+import { SummaryTable } from '../ExperimentView/SummaryTable'
+import { NoResultEmptyState } from './NoResultEmptyState'
+
+function formatTickValue(value: number): string {
+ if (value === 0) {
+ return '0%'
+ }
+
+ // Determine number of decimal places needed
+ const absValue = Math.abs(value)
+ let decimals = 0
+
+ if (absValue < 0.01) {
+ decimals = 3
+ } else if (absValue < 0.1) {
+ decimals = 2
+ } else if (absValue < 1) {
+ decimals = 1
+ } else {
+ decimals = 0
+ }
+
+ return `${(value * 100).toFixed(decimals)}%`
+}
+const getMetricTitle = (metric: any, metricType: InsightType): JSX.Element => {
+ if (metric.name) {
+ return {metric.name}
+ }
+
+ if (metricType === InsightType.TRENDS && metric.count_query?.series?.[0]?.name) {
+ return {metric.count_query.series[0].name}
+ }
+
+ if (metricType === InsightType.FUNNELS && metric.funnels_query?.series) {
+ const series = metric.funnels_query.series
+ if (series.length > 0) {
+ const firstStep = series[0]?.name
+ const lastStep = series[series.length - 1]?.name
+
+ return (
+
+
+ {firstStep}
+
+ {lastStep}
+
+ )
+ }
+ }
+
+ return Untitled metric
+}
+
+export function DeltaChart({
+ isSecondary,
+ result,
+ error,
+ variants,
+ metricType,
+ metricIndex,
+ isFirstMetric,
+ metric,
+ tickValues,
+ chartBound,
+}: {
+ isSecondary: boolean
+ result: any
+ error: any
+ variants: any[]
+ metricType: InsightType
+ metricIndex: number
+ isFirstMetric: boolean
+ metric: any
+ tickValues: number[]
+ chartBound: number
+}): JSX.Element {
+ const {
+ credibleIntervalForVariant,
+ conversionRateForVariant,
+ experimentId,
+ countDataForVariant,
+ exposureCountDataForVariant,
+ metricResultsLoading,
+ } = useValues(experimentLogic)
+
+ const { experiment } = useValues(experimentLogic)
+ const {
+ openPrimaryMetricModal,
+ openSecondaryMetricModal,
+ openPrimarySavedMetricModal,
+ openSecondarySavedMetricModal,
+ } = useActions(experimentLogic)
+ const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null)
+ const [emptyStateTooltipVisible, setEmptyStateTooltipVisible] = useState(true)
+ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 })
+ const [isModalOpen, setIsModalOpen] = useState(false)
+
+ const getScaleAddition = (variantCount: number): number => {
+ if (variantCount < 3) {
+ return 6
+ }
+ if (variantCount < 4) {
+ return 3
+ }
+ if (variantCount < 5) {
+ return 1
+ }
+ return 0
+ }
+
+ const BAR_HEIGHT = 8 + getScaleAddition(variants.length)
+ const BAR_PADDING = 10 + getScaleAddition(variants.length)
+ const TICK_PANEL_HEIGHT = 20
+ const VIEW_BOX_WIDTH = 800
+ const HORIZONTAL_PADDING = 20
+ const CONVERSION_RATE_RECT_WIDTH = 2
+ const TICK_FONT_SIZE = 9
+
+ const { isDarkModeOn } = useValues(themeLogic)
+ const COLORS = {
+ TICK_TEXT_COLOR: 'var(--text-secondary-3000)',
+ BOUNDARY_LINES: 'var(--border-3000)',
+ ZERO_LINE: 'var(--border-bold)',
+ BAR_NEGATIVE: isDarkModeOn ? '#c32f45' : '#f84257',
+ BAR_POSITIVE: isDarkModeOn ? '#12a461' : '#36cd6f',
+ BAR_DEFAULT: isDarkModeOn ? 'rgb(121 121 121)' : 'rgb(217 217 217)',
+ BAR_CONTROL: isDarkModeOn ? 'rgba(217, 217, 217, 0.2)' : 'rgba(217, 217, 217, 0.4)',
+ BAR_MIDDLE_POINT: 'black',
+ BAR_MIDDLE_POINT_CONTROL: 'rgba(0, 0, 0, 0.4)',
+ }
+
+ // Update chart height calculation to include only one BAR_PADDING for each space between bars
+ const chartHeight = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * variants.length
+
+ const valueToX = (value: number): number => {
+ // Scale the value to fit within the padded area
+ const percentage = (value / chartBound + 1) / 2
+ return HORIZONTAL_PADDING + percentage * (VIEW_BOX_WIDTH - 2 * HORIZONTAL_PADDING)
+ }
+
+ const metricTitlePanelWidth = '20%'
+ const variantsPanelWidth = '10%'
+ const detailedResultsPanelWidth = '125px'
+
+ const ticksSvgRef = useRef(null)
+ const chartSvgRef = useRef(null)
+ // :TRICKY: We need to track SVG heights dynamically because
+ // we're fitting regular divs to match SVG viewports. SVGs scale
+ // based on their viewBox and the viewport size, making it challenging
+ // to match their effective rendered heights with regular div elements.
+ const [ticksSvgHeight, setTicksSvgHeight] = useState(0)
+ const [chartSvgHeight, setChartSvgHeight] = useState(0)
+
+ useEffect(() => {
+ const ticksSvg = ticksSvgRef.current
+ const chartSvg = chartSvgRef.current
+
+ // eslint-disable-next-line compat/compat
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ if (entry.target === ticksSvg) {
+ setTicksSvgHeight(entry.contentRect.height)
+ } else if (entry.target === chartSvg) {
+ setChartSvgHeight(entry.contentRect.height)
+ }
+ }
+ })
+
+ if (ticksSvg) {
+ resizeObserver.observe(ticksSvg)
+ }
+ if (chartSvg) {
+ resizeObserver.observe(chartSvg)
+ }
+
+ return () => {
+ resizeObserver.disconnect()
+ }
+ }, [])
+
+ return (
+
+ {/* Metric title panel */}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {isFirstMetric && (
+
+ )}
+ {isFirstMetric &&
}
+
+
+
+
+
+ {metricIndex + 1}.
+ {getMetricTitle(metric, metricType)}
+
+
}
+ onClick={() => {
+ if (metric.isSavedMetric) {
+ if (isSecondary) {
+ openSecondarySavedMetricModal(metric.savedMetricId)
+ } else {
+ openPrimarySavedMetricModal(metric.savedMetricId)
+ }
+ return
+ }
+ isSecondary
+ ? openSecondaryMetricModal(metricIndex)
+ : openPrimaryMetricModal(metricIndex)
+ }}
+ />
+
+
+
+ {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'}
+
+ {metric.isSavedMetric && (
+
+ Shared
+
+ )}
+
+
+
+
+
+ {/* Detailed results panel */}
+
+ {isFirstMetric && (
+
+ )}
+ {isFirstMetric &&
}
+ {result && (
+
+
+ {experiment.metrics.length > 1 && (
+
+ }
+ onClick={() => setIsModalOpen(true)}
+ >
+ Detailed results
+
+
+ )}
+
+ )}
+
+ {/* Variants panel */}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {isFirstMetric && (
+
+ )}
+ {isFirstMetric &&
}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {result &&
+ variants.map((variant) => (
+
+ ))}
+
+
+ {/* SVGs container */}
+
+ {/* Ticks */}
+ {isFirstMetric && (
+
+ {tickValues.map((value, index) => {
+ const x = valueToX(value)
+ return (
+
+
+ {formatTickValue(value)}
+
+
+ )
+ })}
+
+ )}
+ {isFirstMetric &&
}
+ {/* Chart */}
+ {result ? (
+
+ {/* Vertical grid lines */}
+ {tickValues.map((value, index) => {
+ const x = valueToX(value)
+ return (
+
+ )
+ })}
+
+ {variants.map((variant, index) => {
+ const interval = credibleIntervalForVariant(result, variant.key, metricType)
+ const [lower, upper] = interval ? [interval[0] / 100, interval[1] / 100] : [0, 0]
+
+ let delta: number
+ if (metricType === InsightType.TRENDS) {
+ const controlVariant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === 'control'
+ ) as TrendExperimentVariant
+
+ const variantData = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === variant.key
+ ) as TrendExperimentVariant
+
+ if (
+ !variantData?.count ||
+ !variantData?.absolute_exposure ||
+ !controlVariant?.count ||
+ !controlVariant?.absolute_exposure
+ ) {
+ delta = 0
+ } else {
+ const controlMean = controlVariant.count / controlVariant.absolute_exposure
+ const variantMean = variantData.count / variantData.absolute_exposure
+ delta = (variantMean - controlMean) / controlMean
+ }
+ } else {
+ const variantRate = conversionRateForVariant(result, variant.key)
+ const controlRate = conversionRateForVariant(result, 'control')
+ delta = variantRate && controlRate ? (variantRate - controlRate) / controlRate : 0
+ }
+
+ const y = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * index
+ const x1 = valueToX(lower)
+ const x2 = valueToX(upper)
+ const deltaX = valueToX(delta)
+
+ return (
+ {
+ const rect = e.currentTarget.getBoundingClientRect()
+ setTooltipData({
+ x: rect.left + rect.width / 2,
+ y: rect.top - 10,
+ variant: variant.key,
+ })
+ }}
+ onMouseLeave={() => setTooltipData(null)}
+ >
+ {variant.key === 'control' ? (
+ // Control variant - single gray bar
+ <>
+
+
+ >
+ ) : (
+ // Test variants - split into positive and negative sections if needed
+ <>
+
+ {lower < 0 && upper > 0 ? (
+ // Bar spans across zero - need to split
+ <>
+
+
+ >
+ ) : (
+ // Bar is entirely positive or negative
+
+ )}
+ >
+ )}
+ {/* Delta marker */}
+
+
+ )
+ })}
+
+ ) : metricResultsLoading ? (
+
+
+
+ Results loading…
+
+
+
+ ) : (
+
+ {!experiment.start_date ? (
+
+
+ Waiting for experiment to start…
+
+
+ ) : (
+ {
+ const rect = e.currentTarget.getBoundingClientRect()
+ setTooltipPosition({
+ x: rect.left + rect.width / 2,
+ y: rect.top,
+ })
+ setEmptyStateTooltipVisible(true)
+ }}
+ onMouseLeave={() => setEmptyStateTooltipVisible(false)}
+ >
+
+ {error?.hasDiagnostics ? (
+
+
+
+ {(() => {
+ try {
+ const detail = JSON.parse(error.detail)
+ return Object.values(detail).filter((v) => v === false).length
+ } catch {
+ return '0'
+ }
+ })()}
+
+ /4
+
+ ) : (
+
+ Error
+
+ )}
+ Results not yet available
+
+
+ )}
+
+ )}
+
+ {/* Variant result tooltip */}
+ {tooltipData && (
+
+
+
+
+ Win probability:
+ {result?.probability?.[tooltipData.variant] !== undefined ? (
+
+
+
+ {(result.probability[tooltipData.variant] * 100).toFixed(2)}%
+
+
+ ) : (
+ 'ā'
+ )}
+
+ {metricType === InsightType.TRENDS ? (
+ <>
+
+ Count:
+
+ {(() => {
+ const count = countDataForVariant(result, tooltipData.variant)
+ return count !== null ? humanFriendlyNumber(count) : 'ā'
+ })()}
+
+
+
+ Exposure:
+
+ {(() => {
+ const exposure = exposureCountDataForVariant(
+ result,
+ tooltipData.variant
+ )
+ return exposure !== null ? humanFriendlyNumber(exposure) : 'ā'
+ })()}
+
+
+
+ Mean:
+
+ {(() => {
+ const variant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === tooltipData.variant
+ )
+ return variant?.count && variant?.absolute_exposure
+ ? (variant.count / variant.absolute_exposure).toFixed(2)
+ : 'ā'
+ })()}
+
+
+ >
+ ) : (
+
+ Conversion rate:
+
+ {conversionRateForVariant(result, tooltipData.variant)?.toFixed(2)}%
+
+
+ )}
+
+ Delta:
+
+ {tooltipData.variant === 'control' ? (
+ Baseline
+ ) : (
+ (() => {
+ if (metricType === InsightType.TRENDS) {
+ const controlVariant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === 'control'
+ )
+ const variant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === tooltipData.variant
+ )
+
+ if (
+ !variant?.count ||
+ !variant?.absolute_exposure ||
+ !controlVariant?.count ||
+ !controlVariant?.absolute_exposure
+ ) {
+ return 'ā'
+ }
+
+ const controlMean =
+ controlVariant.count / controlVariant.absolute_exposure
+ const variantMean = variant.count / variant.absolute_exposure
+ const delta = (variantMean - controlMean) / controlMean
+ return delta ? (
+ 0 ? 'text-success' : 'text-danger'}>
+ {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
+
+ ) : (
+ 'ā'
+ )
+ }
+
+ const variantRate = conversionRateForVariant(result, tooltipData.variant)
+ const controlRate = conversionRateForVariant(result, 'control')
+ const delta =
+ variantRate && controlRate
+ ? (variantRate - controlRate) / controlRate
+ : 0
+ return delta ? (
+ 0 ? 'text-success' : 'text-danger'}>
+ {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
+
+ ) : (
+ 'ā'
+ )
+ })()
+ )}
+
+
+
+ Credible interval:
+
+ {(() => {
+ const interval = credibleIntervalForVariant(
+ result,
+ tooltipData.variant,
+ metricType
+ )
+ const [lower, upper] = interval
+ ? [interval[0] / 100, interval[1] / 100]
+ : [0, 0]
+ return `[${lower > 0 ? '+' : ''}${(lower * 100).toFixed(2)}%, ${
+ upper > 0 ? '+' : ''
+ }${(upper * 100).toFixed(2)}%]`
+ })()}
+
+
+
+
+ )}
+
+ {/* Empty state tooltip */}
+ {emptyStateTooltipVisible && (
+
+
+
+ )}
+
+
+
setIsModalOpen(false)}
+ width={1200}
+ title={`Metric results: ${metric.name || 'Untitled metric'}`}
+ footer={
+ setIsModalOpen(false)}
+ >
+ Close
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function SignificanceHighlight({
+ metricIndex = 0,
+ isSecondary = false,
+}: {
+ metricIndex?: number
+ isSecondary?: boolean
+}): JSX.Element {
+ const { isPrimaryMetricSignificant, isSecondaryMetricSignificant, significanceDetails } = useValues(experimentLogic)
+ const isSignificant = isSecondary
+ ? isSecondaryMetricSignificant(metricIndex)
+ : isPrimaryMetricSignificant(metricIndex)
+ const result: { color: LemonTagType; label: string } = isSignificant
+ ? { color: 'success', label: 'Significant' }
+ : { color: 'primary', label: 'Not significant' }
+
+ const inner = isSignificant ? (
+
+
+ {result.label}
+
+ ) : (
+
+
+ {result.label}
+
+ )
+
+ const details = significanceDetails(metricIndex)
+
+ return details ? (
+
+ {inner}
+
+ ) : (
+ {inner}
+ )
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
new file mode 100644
index 0000000000000..2b5735778e1c3
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
@@ -0,0 +1,268 @@
+import { IconInfo, IconPlus } from '@posthog/icons'
+import { LemonButton, LemonDivider, Tooltip } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { IconAreaChart } from 'lib/lemon-ui/icons'
+
+import { experimentLogic } from '../experimentLogic'
+import { MAX_PRIMARY_METRICS, MAX_SECONDARY_METRICS } from './const'
+import { DeltaChart } from './DeltaChart'
+
+// Helper function to find nice round numbers for ticks
+export function getNiceTickValues(maxAbsValue: number): number[] {
+ // Round up maxAbsValue to ensure we cover all values
+ maxAbsValue = Math.ceil(maxAbsValue * 10) / 10
+
+ const magnitude = Math.floor(Math.log10(maxAbsValue))
+ const power = Math.pow(10, magnitude)
+
+ let baseUnit
+ const normalizedMax = maxAbsValue / power
+ if (normalizedMax <= 1) {
+ baseUnit = 0.2 * power
+ } else if (normalizedMax <= 2) {
+ baseUnit = 0.5 * power
+ } else if (normalizedMax <= 5) {
+ baseUnit = 1 * power
+ } else {
+ baseUnit = 2 * power
+ }
+
+ // Calculate how many baseUnits we need to exceed maxAbsValue
+ const unitsNeeded = Math.ceil(maxAbsValue / baseUnit)
+
+ // Determine appropriate number of decimal places based on magnitude
+ const decimalPlaces = Math.max(0, -magnitude + 1)
+
+ const ticks: number[] = []
+ for (let i = -unitsNeeded; i <= unitsNeeded; i++) {
+ // Round each tick value to avoid floating point precision issues
+ const tickValue = Number((baseUnit * i).toFixed(decimalPlaces))
+ ticks.push(tickValue)
+ }
+ return ticks
+}
+
+function AddPrimaryMetric(): JSX.Element {
+ const { experiment } = useValues(experimentLogic)
+ const { openPrimaryMetricSourceModal } = useActions(experimentLogic)
+
+ return (
+ }
+ type="secondary"
+ size="xsmall"
+ onClick={() => {
+ openPrimaryMetricSourceModal()
+ }}
+ disabledReason={
+ experiment.metrics.length >= MAX_PRIMARY_METRICS
+ ? `You can only add up to ${MAX_PRIMARY_METRICS} primary metrics.`
+ : undefined
+ }
+ >
+ Add primary metric
+
+ )
+}
+
+export function AddSecondaryMetric(): JSX.Element {
+ const { experiment } = useValues(experimentLogic)
+ const { openSecondaryMetricSourceModal } = useActions(experimentLogic)
+ return (
+ }
+ type="secondary"
+ size="xsmall"
+ onClick={() => {
+ openSecondaryMetricSourceModal()
+ }}
+ disabledReason={
+ experiment.metrics_secondary.length >= MAX_SECONDARY_METRICS
+ ? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.`
+ : undefined
+ }
+ >
+ Add secondary metric
+
+ )
+}
+
+export function MetricsView({ isSecondary }: { isSecondary?: boolean }): JSX.Element {
+ const {
+ experiment,
+ _getMetricType,
+ metricResults,
+ secondaryMetricResults,
+ primaryMetricsResultErrors,
+ secondaryMetricsResultErrors,
+ credibleIntervalForVariant,
+ } = useValues(experimentLogic)
+
+ const variants = experiment.parameters.feature_flag_variants
+ const results = isSecondary ? secondaryMetricResults : metricResults
+ const errors = isSecondary ? secondaryMetricsResultErrors : primaryMetricsResultErrors
+ const hasSomeResults = results?.some((result) => result?.insight)
+
+ let metrics = isSecondary ? experiment.metrics_secondary : experiment.metrics
+ const savedMetrics = experiment.saved_metrics
+ .filter((savedMetric) => savedMetric.metadata.type === (isSecondary ? 'secondary' : 'primary'))
+ .map((savedMetric) => ({
+ ...savedMetric.query,
+ name: savedMetric.name,
+ savedMetricId: savedMetric.saved_metric,
+ isSavedMetric: true,
+ }))
+
+ if (savedMetrics) {
+ metrics = [...metrics, ...savedMetrics]
+ }
+
+ // Calculate the maximum absolute value across ALL metrics
+ const maxAbsValue = Math.max(
+ ...metrics.flatMap((metric, metricIndex) => {
+ const result = results?.[metricIndex]
+ if (!result) {
+ return []
+ }
+ return variants.flatMap((variant) => {
+ const metricType = _getMetricType(metric)
+ const interval = credibleIntervalForVariant(result, variant.key, metricType)
+ return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : []
+ })
+ })
+ )
+
+ const padding = Math.max(maxAbsValue * 0.05, 0.1)
+ const chartBound = maxAbsValue + padding
+
+ const commonTickValues = getNiceTickValues(chartBound)
+
+ return (
+
+
+
+
+
+ {isSecondary ? 'Secondary metrics' : 'Primary metrics'}
+
+ {metrics.length > 0 && (
+
+
+
+ )}
+ {hasSomeResults && !isSecondary && (
+ <>
+
+
+
+ Each bar shows how a variant is performing compared to the control (the
+ gray bar) for this metric, using a{' '}
+ 95% credible interval. That means there's a 95% chance
+ the true difference for that variant falls within this range. The
+ vertical "0%" line is your baseline:
+
+
+
+ To the right (green): The metric is higher (an
+ improvement).
+
+
+ To the left (red): The metric is lower (a
+ decrease).
+
+
+
+ The width of the bar represents uncertainty. A{' '}
+ narrower bar means we're more confident in that result,
+ while a wider bar means it could shift either way.
+
+
+ The control (baseline) is always shown in gray. Other bars will be green
+ or redāor even a mixādepending on whether the change is positive or
+ negative.
+
+
+
+ }
+ >
+
How to read
+
+ >
+ )}
+
+
+
+
+
+ {metrics.length > 0 && (
+
+ )}
+
+
+
+ {metrics.length > 0 ? (
+
+
+ {metrics.map((metric, metricIndex) => {
+ const result = results?.[metricIndex]
+ const isFirstMetric = metricIndex === 0
+
+ return (
+
+
+
+ )
+ })}
+
+
+ ) : (
+
+
+
+
+ Add up to {MAX_PRIMARY_METRICS} {isSecondary ? 'secondary' : 'primary'} metrics.
+
+ {isSecondary ?
:
}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx b/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
new file mode 100644
index 0000000000000..e773e7d8d4494
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
@@ -0,0 +1,68 @@
+import { IconCheck, IconX } from '@posthog/icons'
+
+export function NoResultEmptyState({ error }: { error: any }): JSX.Element {
+ if (!error) {
+ return <>>
+ }
+
+ type ErrorCode = 'no-events' | 'no-flag-info' | 'no-control-variant' | 'no-test-variant'
+
+ const { statusCode, hasDiagnostics } = error
+
+ function ChecklistItem({ errorCode, value }: { errorCode: ErrorCode; value: boolean }): JSX.Element {
+ const failureText = {
+ 'no-events': 'Metric events not received',
+ 'no-flag-info': 'Feature flag information not present on the events',
+ 'no-control-variant': 'Events with the control variant not received',
+ 'no-test-variant': 'Events with at least one test variant not received',
+ }
+
+ const successText = {
+ 'no-events': 'Experiment events have been received',
+ 'no-flag-info': 'Feature flag information is present on the events',
+ 'no-control-variant': 'Events with the control variant received',
+ 'no-test-variant': 'Events with at least one test variant received',
+ }
+
+ return (
+
+ {value === false ? (
+
+
+ {successText[errorCode]}
+
+ ) : (
+
+
+ {failureText[errorCode]}
+
+ )}
+
+ )
+ }
+
+ if (hasDiagnostics) {
+ const checklistItems = []
+ for (const [errorCode, value] of Object.entries(error.detail as Record
)) {
+ checklistItems.push( )
+ }
+
+ return {checklistItems}
+ }
+
+ if (statusCode === 504) {
+ return (
+ <>
+ Experiment results timed out
+
+ This may occur when the experiment has a large amount of data or is particularly complex. We are
+ actively working on fixing this. In the meantime, please try refreshing the experiment to retrieve
+ the results.
+
+ >
+ )
+ }
+
+ // Other unexpected errors
+ return {error.detail}
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/const.tsx b/frontend/src/scenes/experiments/MetricsView/const.tsx
new file mode 100644
index 0000000000000..7df3e2c0aef17
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/const.tsx
@@ -0,0 +1,2 @@
+export const MAX_PRIMARY_METRICS = 10
+export const MAX_SECONDARY_METRICS = 10
diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx
new file mode 100644
index 0000000000000..6a69c279f7286
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/SavedFunnelsMetricForm.tsx
@@ -0,0 +1,203 @@
+import { LemonBanner } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
+import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
+import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
+import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
+import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
+import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
+import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
+import { Query } from '~/queries/Query/Query'
+import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
+import { BreakdownAttributionType, FilterType } from '~/types'
+
+import {
+ commonActionFilterProps,
+ FunnelAggregationSelect,
+ FunnelAttributionSelect,
+ FunnelConversionWindowFilter,
+} from '../Metrics/Selectors'
+import { savedMetricLogic } from './savedMetricLogic'
+
+export function SavedFunnelsMetricForm(): JSX.Element {
+ const { savedMetric } = useValues(savedMetricLogic)
+ const { setSavedMetric } = useActions(savedMetricLogic)
+
+ const { currentTeam } = useValues(teamLogic)
+ const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+
+ const actionFilterProps = {
+ ...commonActionFilterProps,
+ actionsTaxonomicGroupTypes: [TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions],
+ }
+
+ if (!savedMetric?.query) {
+ return <>>
+ }
+
+ const savedMetricQuery = savedMetric.query as ExperimentFunnelsQuery
+
+ return (
+ <>
+ ): void => {
+ if (!savedMetric?.query) {
+ return
+ }
+
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.None
+ )
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ series,
+ },
+ },
+ })
+ }}
+ typeKey="experiment-metric"
+ mathAvailability={MathAvailability.None}
+ buttonCopy="Add funnel step"
+ showSeriesIndicator={true}
+ seriesIndicatorType="numeric"
+ sortable={true}
+ showNestedArrow={true}
+ {...actionFilterProps}
+ />
+
+ {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ aggregation_group_type_index: value,
+ },
+ },
+ })
+ }}
+ />
+ {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ // funnelWindowInterval: funnelWindowInterval,
+ funnelsFilter: {
+ ...savedMetricQuery.funnels_query.funnelsFilter,
+ funnelWindowInterval: funnelWindowInterval,
+ },
+ },
+ },
+ })
+ }}
+ onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ funnelsFilter: {
+ ...savedMetricQuery.funnels_query.funnelsFilter,
+ funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
+ },
+ },
+ },
+ })
+ }}
+ />
+ {
+ const breakdownAttributionType =
+ savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionType
+ const breakdownAttributionValue =
+ savedMetricQuery.funnels_query?.funnelsFilter?.breakdownAttributionValue
+
+ const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
+ !breakdownAttributionType
+ ? BreakdownAttributionType.FirstTouch
+ : breakdownAttributionType === BreakdownAttributionType.Step
+ ? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
+ : breakdownAttributionType
+
+ return currentValue
+ })()}
+ onChange={(value) => {
+ const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ funnelsFilter: {
+ ...savedMetricQuery.funnels_query.funnelsFilter,
+ breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
+ breakdownAttributionValue: breakdownAttributionValue
+ ? parseInt(breakdownAttributionValue)
+ : undefined,
+ },
+ },
+ },
+ })
+ }}
+ stepsLength={savedMetricQuery.funnels_query?.series?.length}
+ />
+ {
+ const val = savedMetricQuery.funnels_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ funnels_query: {
+ ...savedMetricQuery.funnels_query,
+ filterTestAccounts: checked,
+ },
+ },
+ })
+ }}
+ fullWidth
+ />
+
+
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
+ mismatch between the preview and the actual results.
+
+
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx
new file mode 100644
index 0000000000000..c56d9a94c616c
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetric.tsx
@@ -0,0 +1,154 @@
+import { IconCheckCircle } from '@posthog/icons'
+import { LemonButton, LemonDialog, LemonInput, LemonLabel, Spinner } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { SceneExport } from 'scenes/sceneTypes'
+
+import { themeLogic } from '~/layout/navigation-3000/themeLogic'
+import { NodeKind } from '~/queries/schema'
+
+import { getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
+import { SavedFunnelsMetricForm } from './SavedFunnelsMetricForm'
+import { savedMetricLogic } from './savedMetricLogic'
+import { SavedTrendsMetricForm } from './SavedTrendsMetricForm'
+
+export const scene: SceneExport = {
+ component: SavedMetric,
+ logic: savedMetricLogic,
+ paramsToProps: ({ params: { id } }) => ({
+ savedMetricId: id === 'new' ? 'new' : parseInt(id),
+ }),
+}
+
+export function SavedMetric(): JSX.Element {
+ const { savedMetricId, savedMetric } = useValues(savedMetricLogic)
+ const { setSavedMetric, createSavedMetric, updateSavedMetric, deleteSavedMetric } = useActions(savedMetricLogic)
+ const { isDarkModeOn } = useValues(themeLogic)
+
+ if (!savedMetric || !savedMetric.query) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
{
+ setSavedMetric({
+ query: getDefaultTrendsMetric(),
+ })
+ }}
+ >
+
+ Trend
+ {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery && (
+
+ )}
+
+
+ Track a single event, action or a property value.
+
+
+
{
+ setSavedMetric({
+ query: getDefaultFunnelsMetric(),
+ })
+ }}
+ >
+
+ Funnel
+ {savedMetric.query.kind === NodeKind.ExperimentFunnelsQuery && (
+
+ )}
+
+
+ Analyze conversion rates between sequential steps.
+
+
+
+
+
+ Name
+ {
+ setSavedMetric({
+ name: newName,
+ })
+ }}
+ />
+
+
+ Description (optional)
+ {
+ setSavedMetric({
+ description: newDescription,
+ })
+ }}
+ />
+
+ {savedMetric.query.kind === NodeKind.ExperimentTrendsQuery ? (
+
+ ) : (
+
+ )}
+
+
+
{
+ LemonDialog.open({
+ title: 'Delete this metric?',
+ content: This action cannot be undone.
,
+ primaryButton: {
+ children: 'Delete',
+ type: 'primary',
+ onClick: () => deleteSavedMetric(),
+ size: 'small',
+ },
+ secondaryButton: {
+ children: 'Cancel',
+ type: 'tertiary',
+ size: 'small',
+ },
+ })
+ }}
+ >
+ Delete
+
+
{
+ if (savedMetricId === 'new') {
+ createSavedMetric()
+ } else {
+ updateSavedMetric()
+ }
+ }}
+ >
+ Save
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx
new file mode 100644
index 0000000000000..c75588b77688e
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/SavedMetrics.tsx
@@ -0,0 +1,84 @@
+import { IconArrowLeft, IconPencil } from '@posthog/icons'
+import { LemonBanner, LemonButton, LemonTable, LemonTableColumn, LemonTableColumns } from '@posthog/lemon-ui'
+import { useValues } from 'kea'
+import { router } from 'kea-router'
+import { createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
+import { createdAtColumn } from 'lib/lemon-ui/LemonTable/columnUtils'
+import { SceneExport } from 'scenes/sceneTypes'
+import { urls } from 'scenes/urls'
+
+import { SavedMetric } from './savedMetricLogic'
+import { savedMetricsLogic } from './savedMetricsLogic'
+
+export const scene: SceneExport = {
+ component: SavedMetrics,
+ logic: savedMetricsLogic,
+}
+
+const columns: LemonTableColumns = [
+ {
+ key: 'name',
+ title: 'Name',
+ render: (_, savedMetric) => {
+ return {savedMetric.name}
+ },
+ },
+ {
+ key: 'description',
+ title: 'Description',
+ dataIndex: 'description',
+ },
+ createdByColumn() as LemonTableColumn,
+ createdAtColumn() as LemonTableColumn,
+ {
+ key: 'actions',
+ title: 'Actions',
+ render: (_, savedMetric) => {
+ return (
+ }
+ onClick={() => {
+ router.actions.push(urls.experimentsSavedMetric(savedMetric.id))
+ }}
+ />
+ )
+ },
+ },
+]
+
+export function SavedMetrics(): JSX.Element {
+ const { savedMetrics, savedMetricsLoading } = useValues(savedMetricsLogic)
+
+ return (
+
+
}
+ size="small"
+ >
+ Back to experiments
+
+
+ Saved metrics let you create reusable metrics that you can quickly add to any experiment. They are ideal
+ for tracking key metrics like conversion rates or revenue across different experiments without having to
+ set them up each time.
+
+
+
+ New saved metric
+
+
+
You haven't created any saved metrics yet. }
+ />
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx
new file mode 100644
index 0000000000000..7b8068f945bbd
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/SavedTrendsMetricForm.tsx
@@ -0,0 +1,275 @@
+import { IconCheckCircle } from '@posthog/icons'
+import { LemonBanner, LemonTabs, LemonTag } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
+import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
+import { dayjs } from 'lib/dayjs'
+import { useState } from 'react'
+import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
+import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
+import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
+import { Query } from '~/queries/Query/Query'
+import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema'
+import { BaseMathType, ChartDisplayType, FilterType, PropertyMathType } from '~/types'
+
+import { commonActionFilterProps } from '../Metrics/Selectors'
+import { savedMetricLogic } from './savedMetricLogic'
+
+export function SavedTrendsMetricForm(): JSX.Element {
+ const { savedMetric } = useValues(savedMetricLogic)
+ const { setSavedMetric } = useActions(savedMetricLogic)
+ const { currentTeam } = useValues(teamLogic)
+ const hasFilters = (currentTeam?.test_account_filters || []).length > 0
+ const [activeTab, setActiveTab] = useState('main')
+
+ if (!savedMetric?.query) {
+ return <>>
+ }
+
+ const savedMetricQuery = savedMetric.query as ExperimentTrendsQuery
+
+ return (
+ <>
+
setActiveTab(newKey)}
+ tabs={[
+ {
+ key: 'main',
+ label: 'Main metric',
+ content: (
+ <>
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ count_query: {
+ ...savedMetricQuery.count_query,
+ series,
+ },
+ },
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ onlyPropertyMathDefinitions={[PropertyMathType.Average]}
+ {...commonActionFilterProps}
+ />
+
+ {
+ const val = savedMetricQuery.count_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ count_query: {
+ ...savedMetricQuery.count_query,
+ filterTestAccounts: checked,
+ },
+ },
+ })
+ }}
+ fullWidth
+ />
+
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data.
+ This can cause a mismatch between the preview and the actual results.
+
+
+
+
+ >
+ ),
+ },
+ {
+ key: 'exposure',
+ label: 'Exposure',
+ content: (
+ <>
+
+
{
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ exposure_query: undefined,
+ },
+ })
+ }}
+ >
+
+ Default
+ {!savedMetricQuery.exposure_query && (
+
+ )}
+
+
+ Uses the number of unique users who trigger the{' '}
+ $feature_flag_called event as your exposure count. This
+ is the recommended setting for most experiments, as it accurately tracks
+ variant exposure.
+
+
+
{
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ exposure_query: {
+ kind: NodeKind.TrendsQuery,
+ series: [
+ {
+ kind: NodeKind.EventsNode,
+ name: '$feature_flag_called',
+ event: '$feature_flag_called',
+ math: BaseMathType.UniqueUsers,
+ },
+ ],
+ interval: 'day',
+ dateRange: {
+ date_from: dayjs()
+ .subtract(EXPERIMENT_DEFAULT_DURATION, 'day')
+ .format('YYYY-MM-DDTHH:mm'),
+ date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
+ explicitDate: true,
+ },
+ trendsFilter: {
+ display: ChartDisplayType.ActionsLineGraph,
+ },
+ filterTestAccounts: true,
+ },
+ },
+ })
+ }}
+ >
+
+ Custom
+ {savedMetricQuery.exposure_query && (
+
+ )}
+
+
+ Define your own exposure metric for specific use cases, such as counting by
+ sessions instead of users. This gives you full control but requires careful
+ configuration.
+
+
+
+ {savedMetricQuery.exposure_query && (
+ <>
+ ): void => {
+ const series = actionsAndEventsToSeries(
+ { actions, events, data_warehouse } as any,
+ true,
+ MathAvailability.All
+ )
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ exposure_query: {
+ ...savedMetricQuery.exposure_query,
+ series,
+ },
+ },
+ })
+ }}
+ typeKey="experiment-metric"
+ buttonCopy="Add graph series"
+ showSeriesIndicator={true}
+ entitiesLimit={1}
+ showNumericalPropsOnly={true}
+ {...commonActionFilterProps}
+ />
+
+ {
+ const val = savedMetricQuery.exposure_query?.filterTestAccounts
+ return hasFilters ? !!val : false
+ })()}
+ onChange={(checked: boolean) => {
+ setSavedMetric({
+ query: {
+ ...savedMetricQuery,
+ exposure_query: {
+ ...savedMetricQuery.exposure_query,
+ filterTestAccounts: checked,
+ },
+ },
+ })
+ }}
+ fullWidth
+ />
+
+
+ Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days
+ of data. This can cause a mismatch between the preview and the actual
+ results.
+
+
+
+
+ >
+ )}
+ >
+ ),
+ },
+ ]}
+ />
+ >
+ )
+}
diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx
new file mode 100644
index 0000000000000..38648d7e7ca89
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricLogic.tsx
@@ -0,0 +1,127 @@
+import { lemonToast } from '@posthog/lemon-ui'
+import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { loaders } from 'kea-loaders'
+import { router, urlToAction } from 'kea-router'
+import api from 'lib/api'
+
+import { UserBasicType } from '~/types'
+
+import { getDefaultTrendsMetric } from '../experimentLogic'
+import type { savedMetricLogicType } from './savedMetricLogicType'
+import { savedMetricsLogic } from './savedMetricsLogic'
+
+export interface SavedMetricLogicProps {
+ savedMetricId?: string | number
+}
+
+export interface SavedMetric {
+ id: number
+ name: string
+ description?: string
+ query: Record
+ created_by: UserBasicType | null
+ created_at: string | null
+ updated_at: string | null
+}
+
+export const NEW_SAVED_METRIC: Partial = {
+ name: '',
+ description: '',
+ query: getDefaultTrendsMetric(),
+}
+
+export const savedMetricLogic = kea([
+ props({} as SavedMetricLogicProps),
+ path((key) => ['scenes', 'experiments', 'savedMetricLogic', key]),
+ key((props) => props.savedMetricId || 'new'),
+ connect(() => ({
+ actions: [savedMetricsLogic, ['loadSavedMetrics']],
+ })),
+ actions({
+ setSavedMetric: (metric: Partial) => ({ metric }),
+ createSavedMetric: true,
+ updateSavedMetric: true,
+ deleteSavedMetric: true,
+ }),
+
+ loaders(({ props }) => ({
+ savedMetric: {
+ loadSavedMetric: async () => {
+ if (props.savedMetricId && props.savedMetricId !== 'new') {
+ const response = await api.get(
+ `api/projects/@current/experiment_saved_metrics/${props.savedMetricId}`
+ )
+ return response as SavedMetric
+ }
+ return { ...NEW_SAVED_METRIC }
+ },
+ },
+ })),
+
+ listeners(({ actions, values }) => ({
+ createSavedMetric: async () => {
+ const response = await api.create(`api/projects/@current/experiment_saved_metrics/`, values.savedMetric)
+ if (response.id) {
+ lemonToast.success('Saved metric created successfully')
+ actions.loadSavedMetrics()
+ router.actions.push('/experiments/saved-metrics')
+ }
+ },
+ updateSavedMetric: async () => {
+ const response = await api.update(
+ `api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`,
+ values.savedMetric
+ )
+ if (response.id) {
+ lemonToast.success('Saved metric updated successfully')
+ actions.loadSavedMetrics()
+ router.actions.push('/experiments/saved-metrics')
+ }
+ },
+ deleteSavedMetric: async () => {
+ try {
+ await api.delete(`api/projects/@current/experiment_saved_metrics/${values.savedMetricId}`)
+ lemonToast.success('Saved metric deleted successfully')
+ actions.loadSavedMetrics()
+ router.actions.push('/experiments/saved-metrics')
+ } catch (error) {
+ lemonToast.error('Failed to delete saved metric')
+ console.error(error)
+ }
+ },
+ })),
+
+ reducers({
+ savedMetric: [
+ { ...NEW_SAVED_METRIC } as Partial,
+ {
+ setSavedMetric: (state, { metric }) => ({ ...state, ...metric }),
+ },
+ ],
+ }),
+
+ selectors({
+ savedMetricId: [
+ () => [(_, props) => props.savedMetricId ?? 'new'],
+ (savedMetricId): string | number => savedMetricId,
+ ],
+ isNew: [(s) => [s.savedMetricId], (savedMetricId) => savedMetricId === 'new'],
+ }),
+
+ urlToAction(({ actions, values }) => ({
+ '/experiments/saved-metrics/:id': ({ id }, _, __, currentLocation, previousLocation) => {
+ const didPathChange = currentLocation.initial || currentLocation.pathname !== previousLocation?.pathname
+
+ if (id && didPathChange) {
+ const parsedId = id === 'new' ? 'new' : parseInt(id)
+ if (parsedId === 'new') {
+ actions.setSavedMetric({ ...NEW_SAVED_METRIC })
+ }
+
+ if (parsedId !== 'new' && parsedId === values.savedMetricId) {
+ actions.loadSavedMetric()
+ }
+ }
+ },
+ })),
+])
diff --git a/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx
new file mode 100644
index 0000000000000..f9044a4eb181c
--- /dev/null
+++ b/frontend/src/scenes/experiments/SavedMetrics/savedMetricsLogic.tsx
@@ -0,0 +1,48 @@
+import { actions, events, kea, listeners, path, reducers } from 'kea'
+import { loaders } from 'kea-loaders'
+import { router } from 'kea-router'
+import api from 'lib/api'
+
+import { SavedMetric } from './savedMetricLogic'
+import type { savedMetricsLogicType } from './savedMetricsLogicType'
+
+export enum SavedMetricsTabs {
+ All = 'all',
+ Yours = 'yours',
+ Archived = 'archived',
+}
+
+export const savedMetricsLogic = kea([
+ path(['scenes', 'experiments', 'savedMetricsLogic']),
+ actions({
+ setSavedMetricsTab: (tabKey: SavedMetricsTabs) => ({ tabKey }),
+ }),
+
+ loaders({
+ savedMetrics: {
+ loadSavedMetrics: async () => {
+ const response = await api.get('api/projects/@current/experiment_saved_metrics')
+ return response.results as SavedMetric[]
+ },
+ },
+ }),
+
+ reducers({
+ tab: [
+ SavedMetricsTabs.All as SavedMetricsTabs,
+ {
+ setSavedMetricsTab: (_, { tabKey }) => tabKey,
+ },
+ ],
+ }),
+ listeners(() => ({
+ setSavedMetricsTab: () => {
+ router.actions.push('/experiments/saved-metrics')
+ },
+ })),
+ events(({ actions }) => ({
+ afterMount: () => {
+ actions.loadSavedMetrics()
+ },
+ })),
+])
diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx
index 88d57b134e0d3..338237d903ad7 100644
--- a/frontend/src/scenes/experiments/experimentLogic.tsx
+++ b/frontend/src/scenes/experiments/experimentLogic.tsx
@@ -1,4 +1,3 @@
-import { IconInfo } from '@posthog/icons'
import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { forms } from 'kea-forms'
import { loaders } from 'kea-loaders'
@@ -8,12 +7,15 @@ import { EXPERIMENT_DEFAULT_DURATION, FunnelLayout } from 'lib/constants'
import { FEATURE_FLAGS } from 'lib/constants'
import { dayjs } from 'lib/dayjs'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
-import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { hasFormErrors, toParams } from 'lib/utils'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
-import { ReactElement } from 'react'
+import {
+ indexToVariantKeyFeatureFlagPayloads,
+ variantKeyToIndexFeatureFlagPayloads,
+} from 'scenes/feature-flags/featureFlagLogic'
import { validateFeatureFlagKey } from 'scenes/feature-flags/featureFlagLogic'
+import { featureFlagsLogic } from 'scenes/feature-flags/featureFlagsLogic'
import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilters'
@@ -31,6 +33,7 @@ import {
CachedExperimentFunnelsQueryResponse,
CachedExperimentTrendsQueryResponse,
ExperimentFunnelsQuery,
+ ExperimentSignificanceCode,
ExperimentTrendsQuery,
NodeKind,
} from '~/queries/schema'
@@ -54,8 +57,6 @@ import {
MultivariateFlagVariant,
ProductKey,
PropertyMathType,
- SecondaryMetricResults,
- SignificanceCode,
TrendExperimentVariant,
TrendResult,
TrendsFilterType,
@@ -65,6 +66,8 @@ import { MetricInsightId } from './constants'
import type { experimentLogicType } from './experimentLogicType'
import { experimentsLogic } from './experimentsLogic'
import { holdoutsLogic } from './holdoutsLogic'
+import { SavedMetric } from './SavedMetrics/savedMetricLogic'
+import { savedMetricsLogic } from './SavedMetrics/savedMetricsLogic'
import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils'
const NEW_EXPERIMENT: Experiment = {
@@ -75,6 +78,8 @@ const NEW_EXPERIMENT: Experiment = {
filters: {},
metrics: [],
metrics_secondary: [],
+ saved_metrics_ids: [],
+ saved_metrics: [],
parameters: {
feature_flag_variants: [
{ key: 'control', rollout_percentage: 50 },
@@ -147,6 +152,8 @@ export const experimentLogic = kea([
['insightDataLoading as trendMetricInsightLoading'],
insightDataLogic({ dashboardItemId: MetricInsightId.Funnels }),
['insightDataLoading as funnelMetricInsightLoading'],
+ savedMetricsLogic,
+ ['savedMetrics'],
],
actions: [
experimentsLogic,
@@ -168,6 +175,8 @@ export const experimentLogic = kea([
],
teamLogic,
['addProductIntent'],
+ featureFlagsLogic,
+ ['updateFlag'],
],
})),
actions({
@@ -177,12 +186,10 @@ export const experimentLogic = kea([
setExperimentType: (type?: string) => ({ type }),
removeExperimentGroup: (idx: number) => ({ idx }),
setEditExperiment: (editing: boolean) => ({ editing }),
- setExperimentResultCalculationError: (error: ExperimentResultCalculationError) => ({ error }),
setFlagImplementationWarning: (warning: boolean) => ({ warning }),
setExposureAndSampleSize: (exposure: number, sampleSize: number) => ({ exposure, sampleSize }),
- updateExperimentGoal: (filters: Partial) => ({ filters }),
+ updateExperimentGoal: true,
updateExperimentCollectionGoal: true,
- updateExperimentExposure: (filters: Partial | null) => ({ filters }),
changeExperimentStartDate: (startDate: string) => ({ startDate }),
launchExperiment: true,
endExperiment: true,
@@ -219,12 +226,14 @@ export const experimentLogic = kea([
name,
series,
filterTestAccounts,
+ isSecondary = false,
}: {
metricIdx: number
name?: string
series?: any[]
filterTestAccounts?: boolean
- }) => ({ metricIdx, name, series, filterTestAccounts }),
+ isSecondary?: boolean
+ }) => ({ metricIdx, name, series, filterTestAccounts, isSecondary }),
setFunnelsMetric: ({
metricIdx,
name,
@@ -263,6 +272,29 @@ export const experimentLogic = kea([
isSecondary,
}),
setTabKey: (tabKey: string) => ({ tabKey }),
+ openPrimaryMetricModal: (index: number) => ({ index }),
+ closePrimaryMetricModal: true,
+ setPrimaryMetricsResultErrors: (errors: any[]) => ({ errors }),
+ updateDistributionModal: (featureFlag: FeatureFlagType) => ({ featureFlag }),
+ openSecondaryMetricModal: (index: number) => ({ index }),
+ closeSecondaryMetricModal: true,
+ setSecondaryMetricsResultErrors: (errors: any[]) => ({ errors }),
+ openPrimaryMetricSourceModal: true,
+ closePrimaryMetricSourceModal: true,
+ openSecondaryMetricSourceModal: true,
+ closeSecondaryMetricSourceModal: true,
+ openPrimarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }),
+ closePrimarySavedMetricModal: true,
+ openSecondarySavedMetricModal: (savedMetricId: SavedMetric['id'] | null) => ({ savedMetricId }),
+ closeSecondarySavedMetricModal: true,
+ addSavedMetricToExperiment: (
+ savedMetricId: SavedMetric['id'],
+ metadata: { type: 'primary' | 'secondary' }
+ ) => ({
+ savedMetricId,
+ metadata,
+ }),
+ removeSavedMetricFromExperiment: (savedMetricId: SavedMetric['id']) => ({ savedMetricId }),
}),
reducers({
experiment: [
@@ -340,8 +372,9 @@ export const experimentLogic = kea([
[metricsKey]: metrics,
}
},
- setTrendsExposureMetric: (state, { metricIdx, name, series, filterTestAccounts }) => {
- const metrics = [...(state?.metrics || [])]
+ setTrendsExposureMetric: (state, { metricIdx, name, series, filterTestAccounts, isSecondary }) => {
+ const metricsKey = isSecondary ? 'metrics_secondary' : 'metrics'
+ const metrics = [...(state?.[metricsKey] || [])]
const metric = metrics[metricIdx]
metrics[metricIdx] = {
@@ -356,7 +389,7 @@ export const experimentLogic = kea([
return {
...state,
- metrics,
+ [metricsKey]: metrics,
}
},
setFunnelsMetric: (
@@ -417,12 +450,6 @@ export const experimentLogic = kea([
setEditExperiment: (_, { editing }) => editing,
},
],
- experimentResultCalculationError: [
- null as ExperimentResultCalculationError | null,
- {
- setExperimentResultCalculationError: (_, { error }) => error,
- },
- ],
flagImplementationWarning: [
false as boolean,
{
@@ -471,6 +498,90 @@ export const experimentLogic = kea([
setTabKey: (_, { tabKey }) => tabKey,
},
],
+ isPrimaryMetricModalOpen: [
+ false,
+ {
+ openPrimaryMetricModal: () => true,
+ closePrimaryMetricModal: () => false,
+ },
+ ],
+ editingPrimaryMetricIndex: [
+ null as number | null,
+ {
+ openPrimaryMetricModal: (_, { index }) => index,
+ closePrimaryMetricModal: () => null,
+ updateExperimentGoal: () => null,
+ },
+ ],
+ primaryMetricsResultErrors: [
+ [] as any[],
+ {
+ setPrimaryMetricsResultErrors: (_, { errors }) => errors,
+ loadMetricResults: () => [],
+ loadExperiment: () => [],
+ },
+ ],
+ isSecondaryMetricModalOpen: [
+ false,
+ {
+ openSecondaryMetricModal: () => true,
+ closeSecondaryMetricModal: () => false,
+ },
+ ],
+ editingSecondaryMetricIndex: [
+ null as number | null,
+ {
+ openSecondaryMetricModal: (_, { index }) => index,
+ closeSecondaryMetricModal: () => null,
+ updateExperimentGoal: () => null,
+ },
+ ],
+ editingSavedMetricId: [
+ null as SavedMetric['id'] | null,
+ {
+ openPrimarySavedMetricModal: (_, { savedMetricId }) => savedMetricId,
+ openSecondarySavedMetricModal: (_, { savedMetricId }) => savedMetricId,
+ closePrimarySavedMetricModal: () => null,
+ closeSecondarySavedMetricModal: () => null,
+ updateExperimentGoal: () => null,
+ },
+ ],
+ secondaryMetricsResultErrors: [
+ [] as any[],
+ {
+ setSecondaryMetricsResultErrors: (_, { errors }) => errors,
+ loadSecondaryMetricResults: () => [],
+ loadExperiment: () => [],
+ },
+ ],
+ isPrimaryMetricSourceModalOpen: [
+ false,
+ {
+ openPrimaryMetricSourceModal: () => true,
+ closePrimaryMetricSourceModal: () => false,
+ },
+ ],
+ isSecondaryMetricSourceModalOpen: [
+ false,
+ {
+ openSecondaryMetricSourceModal: () => true,
+ closeSecondaryMetricSourceModal: () => false,
+ },
+ ],
+ isPrimarySavedMetricModalOpen: [
+ false,
+ {
+ openPrimarySavedMetricModal: () => true,
+ closePrimarySavedMetricModal: () => false,
+ },
+ ],
+ isSecondarySavedMetricModalOpen: [
+ false,
+ {
+ openSecondarySavedMetricModal: () => true,
+ closeSecondarySavedMetricModal: () => false,
+ },
+ ],
}),
listeners(({ values, actions }) => ({
createExperiment: async ({ draft }) => {
@@ -569,10 +680,7 @@ export const experimentLogic = kea([
experiment && actions.reportExperimentViewed(experiment)
if (experiment?.start_date) {
- actions.loadExperimentResults()
- if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS]) {
- actions.loadMetricResults()
- }
+ actions.loadMetricResults()
actions.loadSecondaryMetricResults()
}
},
@@ -590,13 +698,18 @@ export const experimentLogic = kea([
actions.updateExperiment({ end_date: endDate.toISOString() })
const duration = endDate.diff(values.experiment?.start_date, 'second')
values.experiment &&
- actions.reportExperimentCompleted(values.experiment, endDate, duration, values.areResultsSignificant)
+ actions.reportExperimentCompleted(
+ values.experiment,
+ endDate,
+ duration,
+ values.isPrimaryMetricSignificant(0)
+ )
},
archiveExperiment: async () => {
actions.updateExperiment({ archived: true })
values.experiment && actions.reportExperimentArchived(values.experiment)
},
- updateExperimentGoal: async ({ filters }) => {
+ updateExperimentGoal: async () => {
// Reset MDE to the recommended setting
actions.setExperiment({
parameters: {
@@ -607,12 +720,9 @@ export const experimentLogic = kea([
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
- const filtersToUpdate = { ...filters }
- delete filtersToUpdate.properties
-
actions.updateExperiment({
- filters: filtersToUpdate,
metrics: values.experiment.metrics,
+ metrics_secondary: values.experiment.metrics_secondary,
parameters: {
...values.experiment?.parameters,
recommended_running_time: recommendedRunningTime,
@@ -620,6 +730,8 @@ export const experimentLogic = kea([
minimum_detectable_effect: minimumDetectableEffect,
},
})
+ actions.closePrimaryMetricModal()
+ actions.closeSecondaryMetricModal()
},
updateExperimentCollectionGoal: async () => {
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
@@ -634,30 +746,31 @@ export const experimentLogic = kea([
})
actions.closeExperimentCollectionGoalModal()
},
- updateExperimentExposure: async ({ filters }) => {
- actions.updateExperiment({
- metrics: values.experiment.metrics,
- parameters: {
- custom_exposure_filter: filters ?? undefined,
- feature_flag_variants: values.experiment?.parameters?.feature_flag_variants,
- },
- })
- },
closeExperimentCollectionGoalModal: () => {
if (values.experimentValuesChangedLocally) {
actions.loadExperiment()
}
},
+ closePrimaryMetricModal: () => {
+ actions.loadExperiment()
+ },
+ closeSecondaryMetricModal: () => {
+ actions.loadExperiment()
+ },
+ closePrimarySavedMetricModal: () => {
+ actions.loadExperiment()
+ },
+ closeSecondarySavedMetricModal: () => {
+ actions.loadExperiment()
+ },
resetRunningExperiment: async () => {
actions.updateExperiment({ start_date: null, end_date: null, archived: false })
values.experiment && actions.reportExperimentReset(values.experiment)
-
- actions.loadExperimentResultsSuccess(null)
actions.loadSecondaryMetricResultsSuccess([])
},
updateExperimentSuccess: async ({ experiment }) => {
actions.updateExperiments(experiment)
- actions.loadExperimentResults()
+ actions.loadMetricResults()
actions.loadSecondaryMetricResults()
},
setExperiment: async ({ experiment }) => {
@@ -778,6 +891,53 @@ export const experimentLogic = kea([
lemonToast.error('Failed to update experiment variant images')
}
},
+ updateDistributionModal: async ({ featureFlag }) => {
+ const { created_at, id, ...flag } = featureFlag
+
+ const preparedFlag = indexToVariantKeyFeatureFlagPayloads(flag)
+
+ const savedFlag = await api.update(
+ `api/projects/${values.currentProjectId}/feature_flags/${id}`,
+ preparedFlag
+ )
+
+ const updatedFlag = variantKeyToIndexFeatureFlagPayloads(savedFlag)
+ actions.updateFlag(updatedFlag)
+
+ actions.updateExperiment({
+ holdout_id: values.experiment.holdout_id,
+ })
+ },
+ addSavedMetricToExperiment: async ({ savedMetricId, metadata }) => {
+ const savedMetricsIds = values.experiment.saved_metrics.map((savedMetric) => ({
+ id: savedMetric.saved_metric,
+ metadata,
+ }))
+ savedMetricsIds.push({ id: savedMetricId, metadata })
+
+ await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, {
+ saved_metrics_ids: savedMetricsIds,
+ })
+
+ actions.closePrimarySavedMetricModal()
+ actions.closeSecondarySavedMetricModal()
+ actions.loadExperiment()
+ },
+ removeSavedMetricFromExperiment: async ({ savedMetricId }) => {
+ const savedMetricsIds = values.experiment.saved_metrics
+ .filter((savedMetric) => savedMetric.saved_metric !== savedMetricId)
+ .map((savedMetric) => ({
+ id: savedMetric.saved_metric,
+ metadata: savedMetric.metadata,
+ }))
+ await api.update(`api/projects/${values.currentProjectId}/experiments/${values.experimentId}`, {
+ saved_metrics_ids: savedMetricsIds,
+ })
+
+ actions.closePrimarySavedMetricModal()
+ actions.closeSecondarySavedMetricModal()
+ actions.loadExperiment()
+ },
})),
loaders(({ actions, props, values }) => ({
experiment: {
@@ -806,79 +966,23 @@ export const experimentLogic = kea([
return response
},
},
- experimentResults: [
- null as
- | ExperimentResults['result']
- | CachedExperimentTrendsQueryResponse
- | CachedExperimentFunnelsQueryResponse
- | null,
- {
- loadExperimentResults: async (
- refresh?: boolean
- ): Promise<
- | ExperimentResults['result']
- | CachedExperimentTrendsQueryResponse
- | CachedExperimentFunnelsQueryResponse
- | null
- > => {
- try {
- // :FLAG: CLEAN UP AFTER MIGRATION
- if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
- const queryWithExperimentId = {
- ...values.experiment.metrics[0],
- experiment_id: values.experimentId,
- }
- if (
- queryWithExperimentId.kind === NodeKind.ExperimentTrendsQuery &&
- values.featureFlags[FEATURE_FLAGS.EXPERIMENT_STATS_V2]
- ) {
- queryWithExperimentId.stats_version = 2
- }
-
- const response = await performQuery(queryWithExperimentId, undefined, refresh)
-
- return {
- ...response,
- fakeInsightId: Math.random().toString(36).substring(2, 15),
- } as unknown as CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse
- }
-
- const refreshParam = refresh ? '?refresh=true' : ''
- const response: ExperimentResults = await api.get(
- `api/projects/${values.currentProjectId}/experiments/${values.experimentId}/results${refreshParam}`
- )
- return {
- ...response.result,
- fakeInsightId: Math.random().toString(36).substring(2, 15),
- last_refresh: response.last_refresh,
- }
- } catch (error: any) {
- let errorDetail = error.detail
- // :HANDLE FLAG: CLEAN UP AFTER MIGRATION
- if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const errorDetailMatch = error.detail.match(/\{.*\}/)
- errorDetail = errorDetailMatch[0]
- }
- actions.setExperimentResultCalculationError({ detail: errorDetail, statusCode: error.status })
- if (error.status === 504) {
- actions.reportExperimentResultsLoadingTimeout(values.experimentId)
- }
- return null
- }
- },
- },
- ],
metricResults: [
- null as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[] | null,
+ null as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[] | null,
{
loadMetricResults: async (
refresh?: boolean
- ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[] | null> => {
+ ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => {
+ let metrics = values.experiment?.metrics
+ const savedMetrics = values.experiment?.saved_metrics
+ .filter((savedMetric) => savedMetric.metadata.type === 'primary')
+ .map((savedMetric) => savedMetric.query)
+ if (savedMetrics) {
+ metrics = [...metrics, ...savedMetrics]
+ }
+
return (await Promise.all(
- values.experiment?.metrics.map(async (metric) => {
+ metrics.map(async (metric, index) => {
try {
- // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
const queryWithExperimentId = {
...metric,
experiment_id: values.experimentId,
@@ -889,80 +993,66 @@ export const experimentLogic = kea([
...response,
fakeInsightId: Math.random().toString(36).substring(2, 15),
}
- } catch (error) {
- return {}
+ } catch (error: any) {
+ const errorDetailMatch = error.detail.match(/\{.*\}/)
+ const errorDetail = errorDetailMatch ? JSON.parse(errorDetailMatch[0]) : error.detail
+
+ const currentErrors = [...(values.primaryMetricsResultErrors || [])]
+ currentErrors[index] = {
+ detail: errorDetail,
+ statusCode: error.status,
+ hasDiagnostics: !!errorDetailMatch,
+ }
+ actions.setPrimaryMetricsResultErrors(currentErrors)
+ return null
}
})
- )) as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[]
+ )) as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]
},
},
],
secondaryMetricResults: [
- null as
- | SecondaryMetricResults[]
- | (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[]
- | null,
+ null as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[] | null,
{
loadSecondaryMetricResults: async (
refresh?: boolean
- ): Promise<
- | SecondaryMetricResults[]
- | (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[]
- | null
- > => {
- if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- return (await Promise.all(
- values.experiment?.metrics_secondary.map(async (metric) => {
- try {
- // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
- const queryWithExperimentId = {
- ...metric,
- experiment_id: values.experimentId,
- }
- const response: ExperimentResults = await api.create(
- `api/projects/${values.currentProjectId}/query`,
- { query: queryWithExperimentId, refresh: 'lazy_async' }
- )
-
- return {
- ...response,
- fakeInsightId: Math.random().toString(36).substring(2, 15),
- last_refresh: response.last_refresh || '',
- }
- } catch (error) {
- return {}
- }
- })
- )) as unknown as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[]
+ ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => {
+ let metrics = values.experiment?.metrics_secondary
+ const savedMetrics = values.experiment?.saved_metrics
+ .filter((savedMetric) => savedMetric.metadata.type === 'secondary')
+ .map((savedMetric) => savedMetric.query)
+ if (savedMetrics) {
+ metrics = [...metrics, ...savedMetrics]
}
- const refreshParam = refresh ? '&refresh=true' : ''
-
- return await Promise.all(
- (values.experiment?.secondary_metrics || []).map(async (_, index) => {
+ return (await Promise.all(
+ metrics.map(async (metric, index) => {
try {
- const secResults = await api.get(
- `api/projects/${values.currentProjectId}/experiments/${values.experimentId}/secondary_results?id=${index}${refreshParam}`
- )
- // :TRICKY: Maintain backwards compatibility for cached responses, remove after cache period has expired
- if (secResults && secResults.result && !secResults.result.hasOwnProperty('result')) {
- return {
- result: { ...secResults.result },
- fakeInsightId: Math.random().toString(36).substring(2, 15),
- last_refresh: secResults.last_refresh,
- }
+ const queryWithExperimentId = {
+ ...metric,
+ experiment_id: values.experimentId,
}
+ const response = await performQuery(queryWithExperimentId, undefined, refresh)
return {
- ...secResults.result,
+ ...response,
fakeInsightId: Math.random().toString(36).substring(2, 15),
- last_refresh: secResults.last_refresh,
}
- } catch (error) {
- return {}
+ } catch (error: any) {
+ const errorDetailMatch = error.detail.match(/\{.*\}/)
+ const errorDetail = errorDetailMatch ? JSON.parse(errorDetailMatch[0]) : error.detail
+
+ const currentErrors = [...(values.secondaryMetricsResultErrors || [])]
+ currentErrors[index] = {
+ detail: errorDetail,
+ statusCode: error.status,
+ hasDiagnostics: !!errorDetailMatch,
+ }
+ actions.setSecondaryMetricsResultErrors(currentErrors)
+ return null
}
})
- )
+ )) as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]
},
},
],
@@ -1014,28 +1104,11 @@ export const experimentLogic = kea([
() => [(_, props) => props.experimentId ?? 'new'],
(experimentId): Experiment['id'] => experimentId,
],
- getMetricType: [
- (s) => [s.experiment, s.featureFlags],
- (experiment, featureFlags) =>
- (metricIdx: number = 0) => {
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const query = experiment?.metrics?.[metricIdx]
- return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
- }
-
- return experiment?.filters?.insight || InsightType.FUNNELS
- },
- ],
- getSecondaryMetricType: [
- (s) => [s.experiment, s.featureFlags],
- (experiment, featureFlags) =>
- (metricIdx: number = 0) => {
- if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const query = experiment?.metrics_secondary?.[metricIdx]
- return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
- }
-
- return experiment?.secondary_metrics?.[metricIdx]?.filters?.insight || InsightType.FUNNELS
+ _getMetricType: [
+ () => [],
+ () =>
+ (metric: ExperimentTrendsQuery | ExperimentFunnelsQuery): InsightType => {
+ return metric?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
},
],
isExperimentRunning: [
@@ -1120,12 +1193,16 @@ export const experimentLogic = kea([
},
],
minimumDetectableEffect: [
- (s) => [s.experiment, s.getMetricType, s.conversionMetrics, s.trendResults],
- (newExperiment, getMetricType, conversionMetrics, trendResults): number => {
+ (s) => [s.experiment, s._getMetricType, s.conversionMetrics, s.trendResults],
+ (newExperiment, _getMetricType, conversionMetrics, trendResults): number => {
return (
newExperiment?.parameters?.minimum_detectable_effect ||
// :KLUDGE: extracted the method due to difficulties with logic tests
- getMinimumDetectableEffect(getMetricType(0), conversionMetrics, trendResults) ||
+ getMinimumDetectableEffect(
+ _getMetricType(newExperiment?.metrics[0]),
+ conversionMetrics,
+ trendResults
+ ) ||
0
)
},
@@ -1144,84 +1221,54 @@ export const experimentLogic = kea([
return Math.ceil((1600 * conversionRate * (1 - conversionRate / 100)) / (mde * mde))
},
],
- areResultsSignificant: [
- (s) => [s.experimentResults],
- (experimentResults): boolean => {
- return experimentResults?.significant || false
- },
+ isPrimaryMetricSignificant: [
+ (s) => [s.metricResults],
+ (metricResults: (CachedExperimentFunnelsQueryResponse | CachedExperimentTrendsQueryResponse | null)[]) =>
+ (metricIndex: number = 0): boolean => {
+ return metricResults?.[metricIndex]?.significant || false
+ },
],
- // TODO: remove with the old UI
- significanceBannerDetails: [
- (s) => [s.experimentResults],
- (experimentResults): string | ReactElement => {
- if (experimentResults?.significance_code === SignificanceCode.HighLoss) {
- return (
- <>
- This is because the expected loss in conversion is greater than 1%
- Current value is {((experimentResults?.expected_loss || 0) * 100)?.toFixed(2)}%>
- }
- >
-
-
- .
- >
- )
- }
-
- if (experimentResults?.significance_code === SignificanceCode.HighPValue) {
- return (
- <>
- This is because the p value is greater than 0.05
- Current value is {experimentResults?.p_value?.toFixed(3) || 1}.>}
- >
-
-
- .
- >
- )
- }
-
- if (experimentResults?.significance_code === SignificanceCode.LowWinProbability) {
- return 'This is because the win probability of all test variants combined is less than 90%.'
- }
-
- if (experimentResults?.significance_code === SignificanceCode.NotEnoughExposure) {
- return 'This is because we need at least 100 people per variant to declare significance.'
- }
-
- return ''
- },
+ isSecondaryMetricSignificant: [
+ (s) => [s.secondaryMetricResults],
+ (
+ secondaryMetricResults: (
+ | CachedExperimentFunnelsQueryResponse
+ | CachedExperimentTrendsQueryResponse
+ | null
+ )[]
+ ) =>
+ (metricIndex: number = 0): boolean => {
+ return secondaryMetricResults?.[metricIndex]?.significant || false
+ },
],
significanceDetails: [
- (s) => [s.experimentResults],
- (experimentResults): string => {
- if (experimentResults?.significance_code === SignificanceCode.HighLoss) {
- return `This is because the expected loss in conversion is greater than 1% (current value is ${(
- (experimentResults?.expected_loss || 0) * 100
- )?.toFixed(2)}%).`
- }
+ (s) => [s.metricResults],
+ (metricResults: (CachedExperimentFunnelsQueryResponse | CachedExperimentTrendsQueryResponse | null)[]) =>
+ (metricIndex: number = 0): string => {
+ const results = metricResults?.[metricIndex]
+
+ if (results?.significance_code === ExperimentSignificanceCode.HighLoss) {
+ return `This is because the expected loss in conversion is greater than 1% (current value is ${(
+ (results as CachedExperimentFunnelsQueryResponse)?.expected_loss || 0
+ )?.toFixed(2)}%).`
+ }
- if (experimentResults?.significance_code === SignificanceCode.HighPValue) {
- return `This is because the p value is greater than 0.05 (current value is ${
- experimentResults?.p_value?.toFixed(3) || 1
- }).`
- }
+ if (results?.significance_code === ExperimentSignificanceCode.HighPValue) {
+ return `This is because the p value is greater than 0.05 (current value is ${
+ (results as CachedExperimentTrendsQueryResponse)?.p_value?.toFixed(3) || 1
+ }).`
+ }
- if (experimentResults?.significance_code === SignificanceCode.LowWinProbability) {
- return 'This is because the win probability of all test variants combined is less than 90%.'
- }
+ if (results?.significance_code === ExperimentSignificanceCode.LowWinProbability) {
+ return 'This is because the win probability of all test variants combined is less than 90%.'
+ }
- if (experimentResults?.significance_code === SignificanceCode.NotEnoughExposure) {
- return 'This is because we need at least 100 people per variant to declare significance.'
- }
+ if (results?.significance_code === ExperimentSignificanceCode.NotEnoughExposure) {
+ return 'This is because we need at least 100 people per variant to declare significance.'
+ }
- return ''
- },
+ return ''
+ },
],
recommendedSampleSize: [
(s) => [s.conversionMetrics, s.minimumSampleSizePerVariant, s.variants],
@@ -1236,7 +1283,7 @@ export const experimentLogic = kea([
(s) => [
s.experiment,
s.variants,
- s.getMetricType,
+ s._getMetricType,
s.funnelResults,
s.conversionMetrics,
s.expectedRunningTime,
@@ -1247,7 +1294,7 @@ export const experimentLogic = kea([
(
experiment,
variants,
- getMetricType,
+ _getMetricType,
funnelResults,
conversionMetrics,
expectedRunningTime,
@@ -1255,7 +1302,7 @@ export const experimentLogic = kea([
minimumSampleSizePerVariant,
recommendedExposureForCountData
): number => {
- if (getMetricType(0) === InsightType.FUNNELS) {
+ if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) {
const currentDuration = dayjs().diff(dayjs(experiment?.start_date), 'hour')
const funnelEntrants = funnelResults?.[0]?.count
@@ -1311,17 +1358,17 @@ export const experimentLogic = kea([
() => [],
() =>
(
- experimentResults:
+ metricResult:
| Partial
| CachedExperimentFunnelsQueryResponse
| CachedExperimentTrendsQueryResponse
| null,
variantKey: string
): number | null => {
- if (!experimentResults || !experimentResults.insight) {
+ if (!metricResult || !metricResult.insight) {
return null
}
- const variantResults = (experimentResults.insight as FunnelStep[][]).find(
+ const variantResults = (metricResult.insight as FunnelStep[][]).find(
(variantFunnel: FunnelStep[]) => {
const breakdownValue = variantFunnel[0]?.breakdown_value
return Array.isArray(breakdownValue) && breakdownValue[0] === variantKey
@@ -1338,7 +1385,7 @@ export const experimentLogic = kea([
() => [],
() =>
(
- experimentResults:
+ metricResult:
| Partial
| CachedSecondaryMetricExperimentFunnelsQueryResponse
| CachedSecondaryMetricExperimentTrendsQueryResponse
@@ -1346,13 +1393,13 @@ export const experimentLogic = kea([
variantKey: string,
metricType: InsightType
): [number, number] | null => {
- const credibleInterval = experimentResults?.credible_intervals?.[variantKey]
+ const credibleInterval = metricResult?.credible_intervals?.[variantKey]
if (!credibleInterval) {
return null
}
if (metricType === InsightType.FUNNELS) {
- const controlVariant = (experimentResults.variants as FunnelExperimentVariant[]).find(
+ const controlVariant = (metricResult.variants as FunnelExperimentVariant[]).find(
({ key }) => key === 'control'
) as FunnelExperimentVariant
const controlConversionRate =
@@ -1369,7 +1416,7 @@ export const experimentLogic = kea([
return [lowerBound, upperBound]
}
- const controlVariant = (experimentResults.variants as TrendExperimentVariant[]).find(
+ const controlVariant = (metricResult.variants as TrendExperimentVariant[]).find(
({ key }) => key === 'control'
) as TrendExperimentVariant
@@ -1383,10 +1430,10 @@ export const experimentLogic = kea([
},
],
getIndexForVariant: [
- (s) => [s.getMetricType],
- (getMetricType) =>
+ (s) => [s.experiment, s._getMetricType],
+ (experiment, _getMetricType) =>
(
- experimentResults:
+ metricResult:
| Partial
| CachedExperimentTrendsQueryResponse
| CachedExperimentFunnelsQueryResponse
@@ -1395,14 +1442,14 @@ export const experimentLogic = kea([
): number | null => {
// Ensures we get the right index from results, so the UI can
// display the right colour for the variant
- if (!experimentResults || !experimentResults.insight) {
+ if (!metricResult || !metricResult.insight) {
return null
}
let index = -1
- if (getMetricType(0) === InsightType.FUNNELS) {
+ if (_getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) {
// Funnel Insight is displayed in order of decreasing count
- index = (Array.isArray(experimentResults.insight) ? [...experimentResults.insight] : [])
+ index = (Array.isArray(metricResult.insight) ? [...metricResult.insight] : [])
.sort((a, b) => {
const aCount = (a && Array.isArray(a) && a[0]?.count) || 0
const bCount = (b && Array.isArray(b) && b[0]?.count) || 0
@@ -1416,13 +1463,13 @@ export const experimentLogic = kea([
return Array.isArray(breakdownValue) && breakdownValue[0] === variant
})
} else {
- index = (experimentResults.insight as TrendResult[]).findIndex(
+ index = (metricResult.insight as TrendResult[]).findIndex(
(variantTrend: TrendResult) => variantTrend.breakdown_value === variant
)
}
const result = index === -1 ? null : index
- if (result !== null && getMetricType(0) === InsightType.FUNNELS) {
+ if (result !== null && _getMetricType(experiment.metrics[0]) === InsightType.FUNNELS) {
return result + 1
}
return result
@@ -1432,7 +1479,7 @@ export const experimentLogic = kea([
(s) => [s.experimentMathAggregationForTrends],
(experimentMathAggregationForTrends) =>
(
- experimentResults:
+ metricResult:
| Partial
| CachedExperimentTrendsQueryResponse
| CachedExperimentFunnelsQueryResponse
@@ -1441,10 +1488,10 @@ export const experimentLogic = kea([
type: 'primary' | 'secondary' = 'primary'
): number | null => {
const usingMathAggregationType = type === 'primary' ? experimentMathAggregationForTrends() : false
- if (!experimentResults || !experimentResults.insight) {
+ if (!metricResult || !metricResult.insight) {
return null
}
- const variantResults = (experimentResults.insight as TrendResult[]).find(
+ const variantResults = (metricResult.insight as TrendResult[]).find(
(variantTrend: TrendResult) => variantTrend.breakdown_value === variant
)
if (!variantResults) {
@@ -1482,17 +1529,17 @@ export const experimentLogic = kea([
() => [],
() =>
(
- experimentResults:
+ metricResult:
| Partial
| CachedExperimentTrendsQueryResponse
| CachedExperimentFunnelsQueryResponse
| null,
variant: string
): number | null => {
- if (!experimentResults || !experimentResults.variants) {
+ if (!metricResult || !metricResult.variants) {
return null
}
- const variantResults = (experimentResults.variants as TrendExperimentVariant[]).find(
+ const variantResults = (metricResult.variants as TrendExperimentVariant[]).find(
(variantTrend: TrendExperimentVariant) => variantTrend.key === variant
)
if (!variantResults || !variantResults.absolute_exposure) {
@@ -1522,58 +1569,50 @@ export const experimentLogic = kea([
}
},
],
- sortedExperimentResultVariants: [
- (s) => [s.experimentResults, s.experiment],
- (experimentResults, experiment): string[] => {
- if (experimentResults) {
- const sortedResults = Object.keys(experimentResults.probability).sort(
- (a, b) => experimentResults.probability[b] - experimentResults.probability[a]
- )
-
- experiment?.parameters?.feature_flag_variants?.forEach((variant) => {
- if (!sortedResults.includes(variant.key)) {
- sortedResults.push(variant.key)
- }
- })
- return sortedResults
- }
- return []
- },
- ],
tabularExperimentResults: [
- (s) => [s.experiment, s.experimentResults, s.getMetricType],
- (experiment, experimentResults, getMetricType): any => {
- const tabularResults = []
- const metricType = getMetricType(0)
-
- if (experimentResults) {
- for (const variantObj of experimentResults.variants) {
- if (metricType === InsightType.FUNNELS) {
- const { key, success_count, failure_count } = variantObj as FunnelExperimentVariant
- tabularResults.push({ key, success_count, failure_count })
- } else if (metricType === InsightType.TRENDS) {
- const { key, count, exposure, absolute_exposure } = variantObj as TrendExperimentVariant
- tabularResults.push({ key, count, exposure, absolute_exposure })
+ (s) => [s.experiment, s.metricResults, s._getMetricType],
+ (
+ experiment,
+ metricResults: (
+ | CachedExperimentFunnelsQueryResponse
+ | CachedExperimentTrendsQueryResponse
+ | null
+ )[],
+ _getMetricType
+ ) =>
+ (metricIndex: number = 0): any[] => {
+ const tabularResults = []
+ const metricType = _getMetricType(experiment.metrics[metricIndex])
+ const result = metricResults?.[metricIndex]
+
+ if (result) {
+ for (const variantObj of result.variants) {
+ if (metricType === InsightType.FUNNELS) {
+ const { key, success_count, failure_count } = variantObj as FunnelExperimentVariant
+ tabularResults.push({ key, success_count, failure_count })
+ } else if (metricType === InsightType.TRENDS) {
+ const { key, count, exposure, absolute_exposure } = variantObj as TrendExperimentVariant
+ tabularResults.push({ key, count, exposure, absolute_exposure })
+ }
}
}
- }
- if (experiment.feature_flag?.filters.multivariate?.variants) {
- for (const { key } of experiment.feature_flag.filters.multivariate.variants) {
- if (tabularResults.find((variantObj) => variantObj.key === key)) {
- continue
- }
+ if (experiment.feature_flag?.filters.multivariate?.variants) {
+ for (const { key } of experiment.feature_flag.filters.multivariate.variants) {
+ if (tabularResults.find((variantObj) => variantObj.key === key)) {
+ continue
+ }
- if (metricType === InsightType.FUNNELS) {
- tabularResults.push({ key, success_count: null, failure_count: null })
- } else if (metricType === InsightType.TRENDS) {
- tabularResults.push({ key, count: null, exposure: null, absolute_exposure: null })
+ if (metricType === InsightType.FUNNELS) {
+ tabularResults.push({ key, success_count: null, failure_count: null })
+ } else if (metricType === InsightType.TRENDS) {
+ tabularResults.push({ key, count: null, exposure: null, absolute_exposure: null })
+ }
}
}
- }
- return tabularResults
- },
+ return tabularResults
+ },
],
tabularSecondaryMetricResults: [
(s) => [s.experiment, s.secondaryMetricResults, s.conversionRateForVariant, s.countDataForVariant],
@@ -1588,7 +1627,7 @@ export const experimentLogic = kea([
}
const variantsWithResults: TabularSecondaryMetricResults[] = []
- experiment?.parameters?.feature_flag_variants?.forEach((variant) => {
+ experiment?.feature_flag?.filters?.multivariate?.variants?.forEach((variant) => {
const metricResults: SecondaryMetricResult[] = []
experiment?.secondary_metrics?.forEach((metric, idx) => {
let result
@@ -1613,39 +1652,57 @@ export const experimentLogic = kea([
},
],
sortedWinProbabilities: [
- (s) => [s.experimentResults, s.conversionRateForVariant],
+ (s) => [s.metricResults, s.conversionRateForVariant],
(
- experimentResults,
- conversionRateForVariant
- ): { key: string; winProbability: number; conversionRate: number | null }[] => {
- if (!experimentResults) {
- return []
- }
+ metricResults: (
+ | CachedExperimentFunnelsQueryResponse
+ | CachedExperimentTrendsQueryResponse
+ | null
+ )[],
+ conversionRateForVariant
+ ) =>
+ (metricIndex: number = 0) => {
+ const result = metricResults?.[metricIndex]
+
+ if (!result || !result.probability) {
+ return []
+ }
- return Object.keys(experimentResults.probability)
- .map((key) => ({
- key,
- winProbability: experimentResults.probability[key],
- conversionRate: conversionRateForVariant(experimentResults, key),
- }))
- .sort((a, b) => b.winProbability - a.winProbability)
- },
+ return Object.keys(result.probability)
+ .map((key) => ({
+ key,
+ winProbability: result.probability[key],
+ conversionRate: conversionRateForVariant(result, key),
+ }))
+ .sort((a, b) => b.winProbability - a.winProbability)
+ },
],
funnelResultsPersonsTotal: [
- (s) => [s.experimentResults, s.getMetricType],
- (experimentResults, getMetricType): number => {
- if (getMetricType(0) !== InsightType.FUNNELS || !experimentResults?.insight) {
- return 0
- }
+ (s) => [s.experiment, s.metricResults, s._getMetricType],
+ (
+ experiment,
+ metricResults: (
+ | CachedExperimentFunnelsQueryResponse
+ | CachedExperimentTrendsQueryResponse
+ | null
+ )[],
+ _getMetricType
+ ) =>
+ (metricIndex: number = 0): number => {
+ const result = metricResults?.[metricIndex]
- let sum = 0
- experimentResults.insight.forEach((variantResult) => {
- if (variantResult[0]?.count) {
- sum += variantResult[0].count
+ if (_getMetricType(experiment.metrics[metricIndex]) !== InsightType.FUNNELS || !result?.insight) {
+ return 0
}
- })
- return sum
- },
+
+ let sum = 0
+ result.insight.forEach((variantResult) => {
+ if (variantResult[0]?.count) {
+ sum += variantResult[0].count
+ }
+ })
+ return sum
+ },
],
actualRunningTime: [
(s) => [s.experiment],
@@ -1722,7 +1779,6 @@ export const experimentLogic = kea([
if (parsedId === 'new') {
actions.resetExperiment()
}
-
if (parsedId !== 'new' && parsedId === values.experimentId) {
actions.loadExperiment()
}
diff --git a/frontend/src/scenes/experiments/experimentsLogic.ts b/frontend/src/scenes/experiments/experimentsLogic.ts
index 317b353070773..b9558daf5c07e 100644
--- a/frontend/src/scenes/experiments/experimentsLogic.ts
+++ b/frontend/src/scenes/experiments/experimentsLogic.ts
@@ -1,7 +1,8 @@
import { LemonTagType } from '@posthog/lemon-ui'
import Fuse from 'fuse.js'
-import { actions, connect, events, kea, path, reducers, selectors } from 'kea'
+import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
+import { router } from 'kea-router'
import api from 'lib/api'
import { FEATURE_FLAGS } from 'lib/constants'
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
@@ -43,6 +44,8 @@ export const experimentsLogic = kea([
['user', 'hasAvailableFeature'],
featureFlagLogic,
['featureFlags'],
+ router,
+ ['location'],
],
}),
actions({
@@ -67,10 +70,21 @@ export const experimentsLogic = kea([
tab: [
ExperimentsTabs.All as ExperimentsTabs,
{
- setExperimentsTab: (_, { tabKey }) => tabKey,
+ setExperimentsTab: (state, { tabKey }) => tabKey ?? state,
},
],
}),
+ listeners(({ actions }) => ({
+ setExperimentsTab: ({ tabKey }) => {
+ if (tabKey === ExperimentsTabs.SavedMetrics) {
+ // Saved Metrics is a fake tab that we use to redirect to the saved metrics page
+ actions.setExperimentsTab(ExperimentsTabs.All)
+ router.actions.push('/experiments/saved-metrics')
+ } else {
+ router.actions.push('/experiments')
+ }
+ },
+ })),
loaders(({ values }) => ({
experiments: [
[] as Experiment[],
diff --git a/frontend/src/scenes/experiments/utils.test.ts b/frontend/src/scenes/experiments/utils.test.ts
index 22d03cad8829a..906841aaec363 100644
--- a/frontend/src/scenes/experiments/utils.test.ts
+++ b/frontend/src/scenes/experiments/utils.test.ts
@@ -1,6 +1,6 @@
import { EntityType, FeatureFlagFilters, InsightType } from '~/types'
-import { getNiceTickValues } from './ExperimentView/DeltaViz'
+import { getNiceTickValues } from './MetricsView/MetricsView'
import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils'
describe('utils', () => {
diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx
index d7e2ad01c9133..c9d7b50b6ab78 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx
@@ -10,7 +10,6 @@ import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { NotFound } from 'lib/components/NotFound'
import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
import { PageHeader } from 'lib/components/PageHeader'
-import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { FEATURE_FLAGS } from 'lib/constants'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
@@ -34,9 +33,9 @@ import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { EmptyDashboardComponent } from 'scenes/dashboard/EmptyDashboardComponent'
import { UTM_TAGS } from 'scenes/feature-flags/FeatureFlagSnippets'
import { JSONEditorInput } from 'scenes/feature-flags/JSONEditorInput'
+import { FeatureFlagPermissions } from 'scenes/FeatureFlagPermissions'
import { concatWithPunctuation } from 'scenes/insights/utils'
import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton'
-import { ResourcePermission } from 'scenes/ResourcePermissionModal'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
@@ -58,14 +57,12 @@ import {
PropertyOperator,
QueryBasedInsightModel,
ReplayTabs,
- Resource,
} from '~/types'
import { AnalysisTab } from './FeatureFlagAnalysisTab'
import { FeatureFlagAutoRollback } from './FeatureFlagAutoRollout'
import { FeatureFlagCodeExample } from './FeatureFlagCodeExample'
import { featureFlagLogic, getRecordingFilterForFlagVariant } from './featureFlagLogic'
-import { featureFlagPermissionsLogic } from './featureFlagPermissionsLogic'
import FeatureFlagProjects from './FeatureFlagProjects'
import { FeatureFlagReleaseConditions } from './FeatureFlagReleaseConditions'
import FeatureFlagSchedule from './FeatureFlagSchedule'
@@ -103,13 +100,6 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
setActiveTab,
} = useActions(featureFlagLogic)
- const { addableRoles, unfilteredAddableRolesLoading, rolesToAdd, derivedRoles } = useValues(
- featureFlagPermissionsLogic({ flagId: featureFlag.id })
- )
- const { setRolesToAdd, addAssociatedRoles, deleteAssociatedRole } = useActions(
- featureFlagPermissionsLogic({ flagId: featureFlag.id })
- )
-
const { tags } = useValues(tagsModel)
const { hasAvailableFeature } = useValues(userLogic)
@@ -221,21 +211,7 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
tabs.push({
label: 'Permissions',
key: FeatureFlagsTab.PERMISSIONS,
- content: (
-
- setRolesToAdd(roleIds)}
- rolesToAdd={rolesToAdd}
- addableRoles={addableRoles}
- addableRolesLoading={unfilteredAddableRolesLoading}
- onAdd={() => addAssociatedRoles()}
- roles={derivedRoles}
- deleteAssociatedRole={(id) => deleteAssociatedRole({ roleId: id })}
- canEdit={featureFlag.can_edit}
- />
-
- ),
+ content: ,
})
}
@@ -433,21 +409,7 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
Permissions
-
- setRolesToAdd(roleIds)}
- rolesToAdd={rolesToAdd}
- addableRoles={addableRoles}
- addableRolesLoading={unfilteredAddableRolesLoading}
- onAdd={() => addAssociatedRoles()}
- roles={derivedRoles}
- deleteAssociatedRole={(id) =>
- deleteAssociatedRole({ roleId: id })
- }
- canEdit={featureFlag.can_edit}
- />
-
+
>
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
index 978348e795149..41eac1ddc740b 100644
--- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
@@ -23,9 +23,11 @@ import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { groupsModel } from '~/models/groupsModel'
import { getQueryBasedInsightModel } from '~/queries/nodes/InsightViz/utils'
import {
+ ActivityScope,
AvailableFeature,
Breadcrumb,
CohortType,
@@ -152,7 +154,7 @@ export const variantKeyToIndexFeatureFlagPayloads = (flag: FeatureFlagType): Fea
}
}
-const indexToVariantKeyFeatureFlagPayloads = (flag: Partial
): Partial => {
+export const indexToVariantKeyFeatureFlagPayloads = (flag: Partial): Partial => {
if (flag.filters?.multivariate) {
const newPayloads: Record = {}
flag.filters.multivariate.variants.forEach(({ key }, index) => {
@@ -973,6 +975,19 @@ export const featureFlagLogic = kea([
{ key: [Scene.FeatureFlag, featureFlag.id || 'unknown'], name: featureFlag.key || 'Unnamed' },
],
],
+ [SIDE_PANEL_CONTEXT_KEY]: [
+ (s) => [s.featureFlag],
+ (featureFlag): SidePanelSceneContext | null => {
+ return featureFlag?.id
+ ? {
+ activity_scope: ActivityScope.FEATURE_FLAG,
+ activity_item_id: `${featureFlag.id}`,
+ access_control_resource: 'feature_flag',
+ access_control_resource_id: `${featureFlag.id}`,
+ }
+ : null
+ },
+ ],
filteredDashboards: [
(s) => [s.dashboards, s.featureFlag],
(dashboards, featureFlag) => {
diff --git a/frontend/src/scenes/funnels/FunnelBarHorizontal/Bar.tsx b/frontend/src/scenes/funnels/FunnelBarHorizontal/Bar.tsx
index 2a723efa32d81..e31a03e42b771 100644
--- a/frontend/src/scenes/funnels/FunnelBarHorizontal/Bar.tsx
+++ b/frontend/src/scenes/funnels/FunnelBarHorizontal/Bar.tsx
@@ -1,12 +1,14 @@
import { LemonDropdown } from '@posthog/lemon-ui'
-import { getSeriesColor } from 'lib/colors'
+import { useValues } from 'kea'
import { capitalizeFirstLetter, percentage } from 'lib/utils'
import { useEffect, useRef, useState } from 'react'
+import { insightLogic } from 'scenes/insights/insightLogic'
import { Noun } from '~/models/groupsModel'
import { BreakdownFilter } from '~/queries/schema'
import { FunnelStepWithConversionMetrics } from '~/types'
+import { funnelDataLogic } from '../funnelDataLogic'
import { FunnelTooltip } from '../FunnelTooltip'
import { getSeriesPositionName } from '../funnelUtils'
@@ -43,6 +45,9 @@ export function Bar({
aggregationTargetLabel,
wrapperWidth,
}: BarProps): JSX.Element | null {
+ const { insightProps } = useValues(insightLogic)
+ const { getFunnelsColor } = useValues(funnelDataLogic(insightProps))
+
const barRef = useRef(null)
const labelRef = useRef(null)
const [labelPosition, setLabelPosition] = useState('inside')
@@ -111,7 +116,7 @@ export function Bar({
style={{
flex: `${conversionPercentage} 1 0`,
cursor: cursorType,
- backgroundColor: getSeriesColor(breakdownIndex ?? 0),
+ backgroundColor: getFunnelsColor(step),
}}
onClick={() => {
if (!disabled && onBarClick) {
diff --git a/frontend/src/scenes/funnels/FunnelBarVertical/StepBar.tsx b/frontend/src/scenes/funnels/FunnelBarVertical/StepBar.tsx
index 8c4f301a87c9f..f5224fcf54318 100644
--- a/frontend/src/scenes/funnels/FunnelBarVertical/StepBar.tsx
+++ b/frontend/src/scenes/funnels/FunnelBarVertical/StepBar.tsx
@@ -1,6 +1,5 @@
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
-import { getSeriesColor } from 'lib/colors'
import { percentage } from 'lib/utils'
import { useRef } from 'react'
import { insightLogic } from 'scenes/insights/insightLogic'
@@ -23,21 +22,19 @@ interface StepBarCSSProperties extends React.CSSProperties {
}
export function StepBar({ step, stepIndex, series, showPersonsModal }: StepBarProps): JSX.Element {
const { insightProps } = useValues(insightLogic)
- const { disableFunnelBreakdownBaseline } = useValues(funnelDataLogic(insightProps))
+ const { getFunnelsColor } = useValues(funnelDataLogic(insightProps))
const { showTooltip, hideTooltip } = useActions(funnelTooltipLogic(insightProps))
const { openPersonsModalForSeries } = useActions(funnelPersonsModalLogic(insightProps))
const ref = useRef(null)
- const seriesOrderForColor = disableFunnelBreakdownBaseline ? (series.order ?? 0) + 1 : series.order ?? 0
-
return (
{
- const query: InsightVizNode = {
- kind: NodeKind.InsightVizNode,
- source: {
- kind: NodeKind.PathsQuery,
- funnelPathsFilter: {
- funnelStep: dropOff ? stepNumber * -1 : stepNumber,
- funnelSource: querySource!,
- funnelPathType,
- },
- pathsFilter: {
- includeEventTypes: [PathType.PageView, PathType.CustomEvent],
+ const getPathUrl = useCallback(
+ (funnelPathType: FunnelPathType, dropOff = false): string => {
+ const query: InsightVizNode = {
+ kind: NodeKind.InsightVizNode,
+ source: {
+ kind: NodeKind.PathsQuery,
+ funnelPathsFilter: {
+ funnelStep: dropOff ? stepNumber * -1 : stepNumber,
+ funnelSource: querySource!,
+ funnelPathType,
+ },
+ pathsFilter: {
+ includeEventTypes: [PathType.PageView, PathType.CustomEvent],
+ },
+ dateRange: {
+ date_from: querySource?.dateRange?.date_from,
+ },
},
- dateRange: {
- date_from: querySource?.dateRange?.date_from,
- },
- },
- }
+ }
+
+ return urls.insightNew(undefined, undefined, query)
+ },
+ [querySource, stepNumber]
+ )
- return urls.insightNew(undefined, undefined, query)
+ // Don't show paths modal if aggregating by groups - paths is user-based!
+ if (querySource?.aggregation_group_type_index != undefined) {
+ return null
}
return (
diff --git a/frontend/src/scenes/funnels/funnelDataLogic.ts b/frontend/src/scenes/funnels/funnelDataLogic.ts
index 54ed637e4d2bd..d0bb40293a33c 100644
--- a/frontend/src/scenes/funnels/funnelDataLogic.ts
+++ b/frontend/src/scenes/funnels/funnelDataLogic.ts
@@ -5,6 +5,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { average, percentage, sum } from 'lib/utils'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
+import { getFunnelResultCustomizationColorToken } from 'scenes/insights/utils'
import { groupsModel, Noun } from '~/models/groupsModel'
import { NodeKind } from '~/queries/schema'
@@ -60,6 +61,7 @@ export const funnelDataLogic = kea([
'interval',
'insightData',
'insightDataError',
+ 'theme',
],
groupsModel,
['aggregationLabel'],
@@ -84,7 +86,7 @@ export const funnelDataLogic = kea([
],
}),
- selectors(() => ({
+ selectors(({ props }) => ({
querySource: [
(s) => [s.vizQuerySource],
(vizQuerySource) => (isFunnelsQuery(vizQuerySource) ? vizQuerySource : null),
@@ -209,6 +211,7 @@ export const funnelDataLogic = kea([
},
],
hiddenLegendBreakdowns: [(s) => [s.funnelsFilter], (funnelsFilter) => funnelsFilter?.hiddenLegendBreakdowns],
+ resultCustomizations: [(s) => [s.funnelsFilter], (funnelsFilter) => funnelsFilter?.resultCustomizations],
visibleStepsWithConversionMetrics: [
(s) => [s.stepsWithConversionMetrics, s.funnelsFilter, s.flattenedBreakdowns],
(steps, funnelsFilter, flattenedBreakdowns): FunnelStepWithConversionMetrics[] => {
@@ -408,6 +411,33 @@ export const funnelDataLogic = kea([
(steps) =>
Array.isArray(steps) ? steps.map((step, index) => ({ ...step, seriesIndex: index, id: index })) : [],
],
+ getFunnelsColorToken: [
+ (s) => [s.resultCustomizations, s.theme],
+ (resultCustomizations, theme) => {
+ return (dataset) => {
+ if (theme == null) {
+ return null
+ }
+ return getFunnelResultCustomizationColorToken(
+ resultCustomizations,
+ theme,
+ dataset,
+ props?.cachedInsight?.disable_baseline
+ )
+ }
+ },
+ ],
+ getFunnelsColor: [
+ (s) => [s.theme, s.getFunnelsColorToken],
+ (theme, getFunnelsColorToken) => {
+ return (dataset) => {
+ if (theme == null) {
+ return '#000000' // fallback while loading
+ }
+ return theme[getFunnelsColorToken(dataset)!]
+ }
+ },
+ ],
})),
listeners(({ actions, values }) => ({
diff --git a/frontend/src/scenes/insights/EditorFilters/ResultCustomizationByPicker.tsx b/frontend/src/scenes/insights/EditorFilters/ResultCustomizationByPicker.tsx
new file mode 100644
index 0000000000000..4e81afb843ec1
--- /dev/null
+++ b/frontend/src/scenes/insights/EditorFilters/ResultCustomizationByPicker.tsx
@@ -0,0 +1,29 @@
+import { LemonSegmentedButton } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { insightLogic } from 'scenes/insights/insightLogic'
+
+import { ResultCustomizationBy } from '~/queries/schema'
+
+import { insightVizDataLogic } from '../insightVizDataLogic'
+
+export const RESULT_CUSTOMIZATION_DEFAULT = ResultCustomizationBy.Value
+
+export function ResultCustomizationByPicker(): JSX.Element | null {
+ const { insightProps } = useValues(insightLogic)
+ const { resultCustomizationBy } = useValues(insightVizDataLogic(insightProps))
+ const { updateInsightFilter } = useActions(insightVizDataLogic(insightProps))
+
+ return (
+ updateInsightFilter({ resultCustomizationBy: value as ResultCustomizationBy })}
+ value={resultCustomizationBy || RESULT_CUSTOMIZATION_DEFAULT}
+ options={[
+ { value: ResultCustomizationBy.Value, label: 'By name' },
+ { value: ResultCustomizationBy.Position, label: 'By rank' },
+ ]}
+ size="small"
+ fullWidth
+ />
+ )
+}
diff --git a/frontend/src/scenes/insights/Insight.tsx b/frontend/src/scenes/insights/Insight.tsx
index a0edff02deab5..573321196a64f 100644
--- a/frontend/src/scenes/insights/Insight.tsx
+++ b/frontend/src/scenes/insights/Insight.tsx
@@ -4,6 +4,7 @@ import { DebugCHQueries } from 'lib/components/CommandPalette/DebugCHQueries'
import { isObject } from 'lib/utils'
import { InsightPageHeader } from 'scenes/insights/InsightPageHeader'
import { insightSceneLogic } from 'scenes/insights/insightSceneLogic'
+import { ReloadInsight } from 'scenes/saved-insights/ReloadInsight'
import { urls } from 'scenes/urls'
import { Query } from '~/queries/Query/Query'
@@ -21,7 +22,7 @@ export interface InsightSceneProps {
export function Insight({ insightId }: InsightSceneProps): JSX.Element {
// insightSceneLogic
- const { insightMode, insight, filtersOverride, variablesOverride } = useValues(insightSceneLogic)
+ const { insightMode, insight, filtersOverride, variablesOverride, freshQuery } = useValues(insightSceneLogic)
// insightLogic
const logic = insightLogic({
@@ -79,6 +80,8 @@ export function Insight({ insightId }: InsightSceneProps): JSX.Element {
)}
+ {freshQuery ? : null}
+
= Omit
+
export interface CommonInsightFilter
- extends Partial,
- Partial,
+ extends Partial>,
+ Partial>,
Partial,
Partial,
Partial,
@@ -276,9 +278,11 @@ const cachePropertiesFromQuery = (query: InsightQueryNode, cache: QueryPropertyC
newCache.series = cache?.series
}
- // store the insight specific filter in commonFilter
+ /** store the insight specific filter in commonFilter */
const filterKey = filterKeyForQuery(query)
- newCache.commonFilter = { ...cache?.commonFilter, ...query[filterKey] }
+ // exclude properties that shouldn't be shared
+ const { resultCustomizations, ...commonProperties } = query[filterKey] || {}
+ newCache.commonFilter = { ...cache?.commonFilter, ...commonProperties }
return newCache
}
diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx
index 91d3c1fcf260d..28c1e19d8419c 100644
--- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx
+++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilter.tsx
@@ -86,6 +86,8 @@ export interface ActionFilterProps {
deleteButton,
orLabel,
}: Record) => JSX.Element
+ /** Only show these property math definitions */
+ onlyPropertyMathDefinitions?: Array
}
export const ActionFilter = React.forwardRef(function ActionFilter(
@@ -116,6 +118,7 @@ export const ActionFilter = React.forwardRef(
buttonType = 'tertiary',
readOnly = false,
bordered = false,
+ onlyPropertyMathDefinitions,
},
ref
): JSX.Element {
@@ -174,6 +177,7 @@ export const ActionFilter = React.forwardRef(
onRenameClick: showModal,
sortable,
showNumericalPropsOnly,
+ onlyPropertyMathDefinitions,
}
const reachedLimit: boolean = Boolean(entitiesLimit && localFilters.length >= entitiesLimit)
diff --git a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx
index 15586e766a310..026f8bdda9de7 100644
--- a/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx
+++ b/frontend/src/scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow.tsx
@@ -126,6 +126,8 @@ export interface ActionFilterRowProps {
trendsDisplayCategory: ChartDisplayCategory | null
/** Whether properties shown should be limited to just numerical types */
showNumericalPropsOnly?: boolean
+ /** Only show these property math definitions */
+ onlyPropertyMathDefinitions?: Array
}
export function ActionFilterRow({
@@ -155,6 +157,7 @@ export function ActionFilterRow({
renderRow,
trendsDisplayCategory,
showNumericalPropsOnly,
+ onlyPropertyMathDefinitions,
}: ActionFilterRowProps): JSX.Element {
const { entityFilterVisible } = useValues(logic)
const {
@@ -425,6 +428,7 @@ export function ActionFilterRow({
style={{ maxWidth: '100%', width: 'initial' }}
mathAvailability={mathAvailability}
trendsDisplayCategory={trendsDisplayCategory}
+ onlyPropertyMathDefinitions={onlyPropertyMathDefinitions}
/>
{mathDefinitions[math || BaseMathType.TotalCount]?.category ===
MathCategory.PropertyValue && (
@@ -642,6 +646,8 @@ interface MathSelectorProps {
onMathSelect: (index: number, value: any) => any
trendsDisplayCategory: ChartDisplayCategory | null
style?: React.CSSProperties
+ /** Only show these property math definitions */
+ onlyPropertyMathDefinitions?: Array
}
function isPropertyValueMath(math: string | undefined): math is PropertyMathType {
@@ -660,6 +666,7 @@ function useMathSelectorOptions({
mathAvailability,
onMathSelect,
trendsDisplayCategory,
+ onlyPropertyMathDefinitions,
}: MathSelectorProps): LemonSelectOptions {
const mountedInsightDataLogic = insightDataLogic.findMounted()
const query = mountedInsightDataLogic?.values?.query
@@ -758,12 +765,19 @@ function useMathSelectorOptions({
setPropertyMathTypeShown(value as PropertyMathType)
onMathSelect(index, value)
}}
- options={Object.entries(PROPERTY_MATH_DEFINITIONS).map(([key, definition]) => ({
- value: key,
- label: definition.shortName,
- tooltip: definition.description,
- 'data-attr': `math-${key}-${index}`,
- }))}
+ options={Object.entries(PROPERTY_MATH_DEFINITIONS)
+ .filter(([key]) => {
+ if (undefined === onlyPropertyMathDefinitions) {
+ return true
+ }
+ return onlyPropertyMathDefinitions.includes(key)
+ })
+ .map(([key, definition]) => ({
+ value: key,
+ label: definition.shortName,
+ tooltip: definition.description,
+ 'data-attr': `math-${key}-${index}`,
+ }))}
onClick={(e) => e.stopPropagation()}
size="small"
dropdownMatchSelectWidth={false}
diff --git a/frontend/src/scenes/insights/insightDataLogic.tsx b/frontend/src/scenes/insights/insightDataLogic.tsx
index 168a9160bb966..b5036267488b0 100644
--- a/frontend/src/scenes/insights/insightDataLogic.tsx
+++ b/frontend/src/scenes/insights/insightDataLogic.tsx
@@ -1,7 +1,9 @@
import { actions, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea'
+import { actionToUrl, router } from 'kea-router'
import { objectsEqual } from 'lib/utils'
import { DATAWAREHOUSE_EDITOR_ITEM_ID } from 'scenes/data-warehouse/external/dataWarehouseExternalSceneLogic'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
+import { Scene } from 'scenes/sceneTypes'
import { filterTestAccountsDefaultsLogic } from 'scenes/settings/environment/filterTestAccountDefaultsLogic'
import { examples } from '~/queries/examples'
@@ -14,10 +16,13 @@ import { DataVisualizationNode, InsightVizNode, Node, NodeKind } from '~/queries
import { isDataTableNode, isDataVisualizationNode, isHogQuery, isInsightVizNode } from '~/queries/utils'
import { ExportContext, InsightLogicProps, InsightType } from '~/types'
+import { teamLogic } from '../teamLogic'
import type { insightDataLogicType } from './insightDataLogicType'
import { insightDataTimingLogic } from './insightDataTimingLogic'
import { insightLogic } from './insightLogic'
+import { insightSceneLogic } from './insightSceneLogic'
import { insightUsageLogic } from './insightUsageLogic'
+import { crushDraftQueryForLocalStorage, crushDraftQueryForURL, isQueryTooLarge } from './utils'
import { compareQuery } from './utils/queryUtils'
export const insightDataLogic = kea([
@@ -29,6 +34,10 @@ export const insightDataLogic = kea([
values: [
insightLogic,
['insight', 'savedInsight'],
+ insightSceneLogic,
+ ['insightId', 'insightMode', 'activeScene'],
+ teamLogic,
+ ['currentTeamId'],
dataNodeLogic({
key: insightVizDataNodeKey(props),
loadPriority: props.loadPriority,
@@ -49,7 +58,7 @@ export const insightDataLogic = kea([
],
actions: [
insightLogic,
- ['setInsight', 'loadInsightSuccess'],
+ ['setInsight'],
dataNodeLogic({ key: insightVizDataNodeKey(props) } as DataNodeLogicProps),
['loadData', 'loadDataSuccess', 'loadDataFailure', 'setResponse as setInsightData'],
],
@@ -187,21 +196,60 @@ export const insightDataLogic = kea([
actions.setInsightData({ ...values.insightData, result })
}
},
- loadInsightSuccess: ({ insight }) => {
- if (insight.query) {
- actions.setQuery(insight.query)
- }
- },
cancelChanges: () => {
const savedQuery = values.savedInsight.query
const savedResult = values.savedInsight.result
actions.setQuery(savedQuery || null)
actions.setInsightData({ ...values.insightData, result: savedResult ? savedResult : null })
},
+ setQuery: ({ query }) => {
+ // if the query is not changed, don't save it
+ if (!query || !values.queryChanged) {
+ return
+ }
+ // only run on insight scene
+ if (insightSceneLogic.values.activeScene !== Scene.Insight) {
+ return
+ }
+ // don't save for saved insights
+ if (insightSceneLogic.values.insightId !== 'new') {
+ return
+ }
+
+ if (isQueryTooLarge(query)) {
+ localStorage.removeItem(`draft-query-${values.currentTeamId}`)
+ }
+
+ localStorage.setItem(
+ `draft-query-${values.currentTeamId}`,
+ crushDraftQueryForLocalStorage(query, Date.now())
+ )
+ },
})),
propsChanged(({ actions, props, values }) => {
if (props.cachedInsight?.query && !objectsEqual(props.cachedInsight.query, values.query)) {
actions.setQuery(props.cachedInsight.query)
}
}),
+ actionToUrl(({ values }) => ({
+ setQuery: ({ query }) => {
+ if (
+ values.queryChanged &&
+ insightSceneLogic.values.activeScene === Scene.Insight &&
+ insightSceneLogic.values.insightId === 'new'
+ ) {
+ // query is changed and we are in edit mode
+ return [
+ router.values.currentLocation.pathname,
+ {
+ ...router.values.currentLocation.searchParams,
+ },
+ {
+ ...router.values.currentLocation.hashParams,
+ q: crushDraftQueryForURL(query),
+ },
+ ]
+ }
+ },
+ })),
])
diff --git a/frontend/src/scenes/insights/insightLogic.tsx b/frontend/src/scenes/insights/insightLogic.tsx
index a3c9905180538..1ca1548f30047 100644
--- a/frontend/src/scenes/insights/insightLogic.tsx
+++ b/frontend/src/scenes/insights/insightLogic.tsx
@@ -336,6 +336,8 @@ export const insightLogic: LogicWrapper = kea {
})
})
- it('redirects when opening /insight/new with insight type in theurl', async () => {
+ it('redirects maintaining url params when opening /insight/new with insight type in theurl', async () => {
router.actions.push(urls.insightNew(InsightType.FUNNELS))
await expectLogic(logic).toFinishAllListeners()
- await expectLogic(router)
- .delay(1)
- .toMatchValues({
- location: partial({
- pathname: addProjectIdIfMissing(urls.insightNew(), MOCK_TEAM_ID),
- search: '',
- hash: '',
- }),
- })
expect((logic.values.insightLogicRef?.logic.values.insight.query as InsightVizNode).source?.kind).toEqual(
'FunnelsQuery'
)
})
- it('redirects when opening /insight/new with query in the url', async () => {
+ it('redirects maintaining url params when opening /insight/new with query in the url', async () => {
router.actions.push(
urls.insightNew(undefined, undefined, {
kind: NodeKind.InsightVizNode,
@@ -70,15 +61,6 @@ describe('insightSceneLogic', () => {
} as InsightVizNode)
)
await expectLogic(logic).toFinishAllListeners()
- await expectLogic(router)
- .delay(1)
- .toMatchValues({
- location: partial({
- pathname: addProjectIdIfMissing(urls.insightNew(), MOCK_TEAM_ID),
- search: '',
- hash: '',
- }),
- })
expect((logic.values.insightLogicRef?.logic.values.insight.query as InsightVizNode).source?.kind).toEqual(
'PathsQuery'
diff --git a/frontend/src/scenes/insights/insightSceneLogic.tsx b/frontend/src/scenes/insights/insightSceneLogic.tsx
index 3f79ace2432d9..d9b89d13ff25c 100644
--- a/frontend/src/scenes/insights/insightSceneLogic.tsx
+++ b/frontend/src/scenes/insights/insightSceneLogic.tsx
@@ -15,7 +15,7 @@ import { teamLogic } from 'scenes/teamLogic'
import { mathsLogic } from 'scenes/trends/mathsLogic'
import { urls } from 'scenes/urls'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { cohortsModel } from '~/models/cohortsModel'
import { groupsModel } from '~/models/groupsModel'
import { getDefaultQuery } from '~/queries/nodes/InsightViz/utils'
@@ -26,6 +26,10 @@ import { insightDataLogic } from './insightDataLogic'
import { insightDataLogicType } from './insightDataLogicType'
import type { insightSceneLogicType } from './insightSceneLogicType'
import { summarizeInsight } from './summarizeInsight'
+import { parseDraftQueryFromLocalStorage, parseDraftQueryFromURL } from './utils'
+
+const NEW_INSIGHT = 'new' as const
+export type InsightId = InsightShortId | typeof NEW_INSIGHT | null
export const insightSceneLogic = kea([
path(['scenes', 'insights', 'insightSceneLogic']),
@@ -33,7 +37,7 @@ export const insightSceneLogic = kea([
logic: [eventUsageLogic],
values: [
teamLogic,
- ['currentTeam'],
+ ['currentTeam', 'currentTeamId'],
sceneLogic,
['activeScene'],
preflightLogic,
@@ -73,10 +77,11 @@ export const insightSceneLogic = kea([
unmount,
}),
setOpenedWithQuery: (query: Node | null) => ({ query }),
+ setFreshQuery: (freshQuery: boolean) => ({ freshQuery }),
}),
reducers({
insightId: [
- null as null | 'new' | InsightShortId,
+ null as null | InsightId,
{
setSceneState: (_, { insightId }) => insightId,
},
@@ -150,6 +155,7 @@ export const insightSceneLogic = kea([
},
],
openedWithQuery: [null as Node | null, { setOpenedWithQuery: (_, { query }) => query }],
+ freshQuery: [false, { setFreshQuery: (_, { freshQuery }) => freshQuery }],
}),
selectors(() => ({
insightSelector: [(s) => [s.insightLogicRef], (insightLogicRef) => insightLogicRef?.logic.selectors.insight],
@@ -210,13 +216,15 @@ export const insightSceneLogic = kea([
]
},
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
(s) => [s.insight],
- (insight): ActivityFilters | null => {
- return insight
+ (insight): SidePanelSceneContext | null => {
+ return insight?.id
? {
- scope: ActivityScope.INSIGHT,
- item_id: `${insight.id}`,
+ activity_scope: ActivityScope.INSIGHT,
+ activity_item_id: `${insight.id}`,
+ access_control_resource: 'insight',
+ access_control_resource_id: `${insight.id}`,
}
: null
},
@@ -330,24 +338,20 @@ export const insightSceneLogic = kea([
let queryFromUrl: Node | null = null
if (q) {
- queryFromUrl = JSON.parse(q)
+ const validQuery = parseDraftQueryFromURL(q)
+ if (validQuery) {
+ queryFromUrl = validQuery
+ } else {
+ console.error('Invalid query', q)
+ }
} else if (insightType && Object.values(InsightType).includes(insightType)) {
queryFromUrl = getDefaultQuery(insightType, values.filterTestAccountsDefault)
}
- // Redirect to a simple URL if we had a query in the URL
- if (q || insightType) {
- router.actions.replace(
- insightId === 'new'
- ? urls.insightNew(undefined, dashboard)
- : insightMode === ItemMode.Edit
- ? urls.insightEdit(insightId)
- : urls.insightView(insightId)
- )
- }
+ actions.setFreshQuery(false)
// reset the insight's state if we have to
- if (initial || method === 'PUSH' || queryFromUrl) {
+ if (initial || queryFromUrl || method === 'PUSH') {
if (insightId === 'new') {
const query = queryFromUrl || getDefaultQuery(InsightType.TRENDS, values.filterTestAccountsDefault)
values.insightLogicRef?.logic.actions.setInsight(
@@ -362,6 +366,10 @@ export const insightSceneLogic = kea([
}
)
+ if (!queryFromUrl) {
+ actions.setFreshQuery(true)
+ }
+
actions.setOpenedWithQuery(query)
eventUsageLogic.actions.reportInsightCreated(query)
@@ -414,6 +422,22 @@ export const insightSceneLogic = kea([
const metadataChanged = !!values.insightLogicRef?.logic.values.insightChanged
const queryChanged = !!values.insightDataLogicRef?.logic.values.queryChanged
+ const draftQueryFromLocalStorage = localStorage.getItem(`draft-query-${values.currentTeamId}`)
+ let draftQuery: { query: Node; timestamp: number } | null = null
+ if (draftQueryFromLocalStorage) {
+ const parsedQuery = parseDraftQueryFromLocalStorage(draftQueryFromLocalStorage)
+ if (parsedQuery) {
+ draftQuery = parsedQuery
+ } else {
+ // If the draft query is invalid, remove it
+ localStorage.removeItem(`draft-query-${values.currentTeamId}`)
+ }
+ }
+ const query = values.insightDataLogicRef?.logic.values.query
+
+ if (draftQuery && query && objectsEqual(draftQuery.query, query)) {
+ return false
+ }
return metadataChanged || queryChanged
},
diff --git a/frontend/src/scenes/insights/insightVizDataLogic.ts b/frontend/src/scenes/insights/insightVizDataLogic.ts
index 14b0b4cbd393d..dfaa7f8135694 100644
--- a/frontend/src/scenes/insights/insightVizDataLogic.ts
+++ b/frontend/src/scenes/insights/insightVizDataLogic.ts
@@ -11,6 +11,7 @@ import { dayjs } from 'lib/dayjs'
import { dateMapping, is12HoursOrLess, isLessThan2Days } from 'lib/utils'
import posthog from 'posthog-js'
import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
+import { dataThemeLogic } from 'scenes/dataThemeLogic'
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
import { sceneLogic } from 'scenes/sceneLogic'
@@ -43,6 +44,7 @@ import {
getDisplay,
getFormula,
getInterval,
+ getResultCustomizationBy,
getSeries,
getShowAlertThresholdLines,
getShowLabelsOnSeries,
@@ -86,6 +88,8 @@ export const insightVizDataLogic = kea([
['filterTestAccountsDefault'],
databaseTableListLogic,
['dataWarehouseTablesMap'],
+ dataThemeLogic,
+ ['getTheme'],
],
actions: [insightDataLogic, ['setQuery', 'setInsightData', 'loadData', 'loadDataSuccess', 'loadDataFailure']],
})),
@@ -162,6 +166,11 @@ export const insightVizDataLogic = kea([
return false
},
],
+ supportsResultCustomizationBy: [
+ (s) => [s.isTrends, s.display],
+ (isTrends, display) =>
+ isTrends && [ChartDisplayType.ActionsLineGraph].includes(display || ChartDisplayType.ActionsLineGraph),
+ ],
dateRange: [(s) => [s.querySource], (q) => (q ? q.dateRange : null)],
breakdownFilter: [(s) => [s.querySource], (q) => (q ? getBreakdown(q) : null)],
@@ -178,6 +187,7 @@ export const insightVizDataLogic = kea([
showLabelOnSeries: [(s) => [s.querySource], (q) => (q ? getShowLabelsOnSeries(q) : null)],
showPercentStackView: [(s) => [s.querySource], (q) => (q ? getShowPercentStackView(q) : null)],
yAxisScaleType: [(s) => [s.querySource], (q) => (q ? getYAxisScaleType(q) : null)],
+ resultCustomizationBy: [(s) => [s.querySource], (q) => (q ? getResultCustomizationBy(q) : null)],
vizSpecificOptions: [(s) => [s.query], (q: Node) => (isInsightVizNode(q) ? q.vizSpecificOptions : null)],
insightFilter: [(s) => [s.querySource], (q) => (q ? filterForQuery(q) : null)],
trendsFilter: [(s) => [s.querySource], (q) => (isTrendsQuery(q) ? q.trendsFilter : null)],
@@ -387,6 +397,8 @@ export const insightVizDataLogic = kea([
(s) => [s.querySource, actionsModel.selectors.actions],
(querySource, actions) => (querySource ? getAllEventNames(querySource, actions) : []),
],
+
+ theme: [(s) => [s.getTheme, s.querySource], (getTheme, querySource) => getTheme(querySource?.dataColorTheme)],
}),
listeners(({ actions, values, props }) => ({
diff --git a/frontend/src/scenes/insights/utils.test.ts b/frontend/src/scenes/insights/utils.test.ts
index d23a499e5fb0a..87ae768a32226 100644
--- a/frontend/src/scenes/insights/utils.test.ts
+++ b/frontend/src/scenes/insights/utils.test.ts
@@ -6,11 +6,13 @@ import {
formatBreakdownType,
getDisplayNameFromEntityFilter,
getDisplayNameFromEntityNode,
+ getTrendDatasetKey,
} from 'scenes/insights/utils'
+import { IndexedTrendResult } from 'scenes/trends/types'
import { ActionsNode, BreakdownFilter, EventsNode, NodeKind } from '~/queries/schema'
import { isEventsNode } from '~/queries/utils'
-import { Entity, EntityFilter, FilterType, InsightType } from '~/types'
+import { CompareLabelType, Entity, EntityFilter, FilterType, InsightType } from '~/types'
const createFilter = (id?: Entity['id'], name?: string, custom_name?: string): EntityFilter => {
return {
@@ -471,3 +473,58 @@ describe('formatBreakdownType()', () => {
expect(formatBreakdownType(breakdownFilter)).toEqual('Cohort')
})
})
+
+describe('getTrendDatasetKey()', () => {
+ it('handles a simple insight', () => {
+ const dataset: Partial = {
+ label: '$pageview',
+ action: {
+ id: '$pageview',
+ type: 'events',
+ order: 0,
+ },
+ }
+
+ expect(getTrendDatasetKey(dataset as IndexedTrendResult)).toEqual('{"series":0}')
+ })
+
+ it('handles insights with breakdowns', () => {
+ const dataset: Partial = {
+ label: 'Opera::US',
+ action: {
+ id: '$pageview',
+ type: 'events',
+ order: 0,
+ },
+ breakdown_value: ['Opera', 'US'],
+ }
+
+ expect(getTrendDatasetKey(dataset as IndexedTrendResult)).toEqual(
+ '{"series":0,"breakdown_value":["Opera","US"]}'
+ )
+ })
+
+ it('handles insights with compare against previous', () => {
+ const dataset: Partial = {
+ label: '$pageview',
+ action: {
+ id: '$pageview',
+ type: 'events',
+ order: 0,
+ },
+ compare: true,
+ compare_label: CompareLabelType.Current,
+ }
+
+ expect(getTrendDatasetKey(dataset as IndexedTrendResult)).toEqual('{"series":0,"compare_label":"current"}')
+ })
+
+ it('handles insights with formulas', () => {
+ const dataset: Partial = {
+ label: 'Formula (A+B)',
+ action: undefined,
+ }
+
+ expect(getTrendDatasetKey(dataset as IndexedTrendResult)).toEqual('{"series":"formula"}')
+ })
+})
diff --git a/frontend/src/scenes/insights/utils.tsx b/frontend/src/scenes/insights/utils.tsx
index 96d3129e47fa6..c8e95fa6c6fa5 100644
--- a/frontend/src/scenes/insights/utils.tsx
+++ b/frontend/src/scenes/insights/utils.tsx
@@ -1,9 +1,11 @@
import api from 'lib/api'
+import { DataColorTheme, DataColorToken } from 'lib/colors'
import { dayjs } from 'lib/dayjs'
import { CORE_FILTER_DEFINITIONS_BY_GROUP } from 'lib/taxonomy'
import { ensureStringIsNotBlank, humanFriendlyNumber, objectsEqual } from 'lib/utils'
import { getCurrentTeamId } from 'lib/utils/getAppContext'
import { ReactNode } from 'react'
+import { IndexedTrendResult } from 'scenes/trends/types'
import { urls } from 'scenes/urls'
import { propertyFilterTypeToPropertyDefinitionType } from '~/lib/components/PropertyFilters/utils'
@@ -15,8 +17,13 @@ import {
DataWarehouseNode,
EventsNode,
InsightVizNode,
+ Node,
NodeKind,
PathsFilter,
+ ResultCustomization,
+ ResultCustomizationBy,
+ ResultCustomizationByPosition,
+ ResultCustomizationByValue,
} from '~/queries/schema'
import { isDataWarehouseNode, isEventsNode } from '~/queries/utils'
import {
@@ -28,6 +35,8 @@ import {
EntityFilter,
EntityTypes,
EventType,
+ FlattenedFunnelStepByBreakdown,
+ FunnelStepWithConversionMetrics,
GroupTypeIndex,
InsightShortId,
InsightType,
@@ -36,6 +45,7 @@ import {
PropertyOperator,
} from '~/types'
+import { RESULT_CUSTOMIZATION_DEFAULT } from './EditorFilters/ResultCustomizationByPicker'
import { insightLogic } from './insightLogic'
export const isAllEventsEntityFilter = (filter: EntityFilter | ActionFilter | null): boolean => {
@@ -433,3 +443,153 @@ export function insightUrlForEvent(event: Pick
+ | Record
+ | null
+ | undefined
+): ResultCustomization | undefined {
+ const resultCustomizationKey = getTrendResultCustomizationKey(resultCustomizationBy, dataset)
+ return resultCustomizations && Object.keys(resultCustomizations).includes(resultCustomizationKey)
+ ? resultCustomizations[resultCustomizationKey]
+ : undefined
+}
+
+export function getFunnelResultCustomization(
+ dataset: FlattenedFunnelStepByBreakdown | FunnelStepWithConversionMetrics,
+ resultCustomizations: Record | null | undefined
+): ResultCustomization | undefined {
+ const resultCustomizationKey = getFunnelDatasetKey(dataset)
+ return resultCustomizations && Object.keys(resultCustomizations).includes(resultCustomizationKey)
+ ? resultCustomizations[resultCustomizationKey]
+ : undefined
+}
+
+export function getTrendResultCustomizationColorToken(
+ resultCustomizationBy: ResultCustomizationBy | null | undefined,
+ resultCustomizations:
+ | Record
+ | Record
+ | null
+ | undefined,
+ theme: DataColorTheme,
+ dataset: IndexedTrendResult
+): DataColorToken {
+ const resultCustomization = getTrendResultCustomization(resultCustomizationBy, dataset, resultCustomizations)
+
+ // for result customizations without a configuration, the color is determined
+ // by the position in the dataset. colors repeat after all options
+ // have been exhausted.
+ const datasetPosition = getTrendDatasetPosition(dataset)
+ const tokenIndex = (datasetPosition % Object.keys(theme).length) + 1
+
+ return resultCustomization && resultCustomization.color
+ ? resultCustomization.color
+ : (`preset-${tokenIndex}` as DataColorToken)
+}
+
+export function getFunnelResultCustomizationColorToken(
+ resultCustomizations: Record | null | undefined,
+ theme: DataColorTheme,
+ dataset: FlattenedFunnelStepByBreakdown | FunnelStepWithConversionMetrics,
+ disableFunnelBreakdownBaseline?: boolean
+): DataColorToken {
+ const resultCustomization = getFunnelResultCustomization(dataset, resultCustomizations)
+
+ const datasetPosition = getFunnelDatasetPosition(dataset, disableFunnelBreakdownBaseline)
+ const tokenIndex = (datasetPosition % Object.keys(theme).length) + 1
+
+ return resultCustomization && resultCustomization.color
+ ? resultCustomization.color
+ : (`preset-${tokenIndex}` as DataColorToken)
+}
+
+export function isQueryTooLarge(query: Node>): boolean {
+ // Chrome has a 2MB limit for the HASH params, limit ours at 1MB
+ const queryLength = encodeURI(JSON.stringify(query)).split(/%..|./).length - 1
+ return queryLength > 1024 * 1024
+}
+
+export function parseDraftQueryFromLocalStorage(
+ query: string
+): { query: Node>; timestamp: number } | null {
+ try {
+ return JSON.parse(query)
+ } catch (e) {
+ console.error('Error parsing query', e)
+ return null
+ }
+}
+
+export function crushDraftQueryForLocalStorage(query: Node>, timestamp: number): string {
+ return JSON.stringify({ query, timestamp })
+}
+
+export function parseDraftQueryFromURL(query: string): Node> | null {
+ try {
+ return JSON.parse(query)
+ } catch (e) {
+ console.error('Error parsing query', e)
+ return null
+ }
+}
+
+export function crushDraftQueryForURL(query: Node>): string {
+ return JSON.stringify(query)
+}
diff --git a/frontend/src/scenes/insights/utils/queryUtils.ts b/frontend/src/scenes/insights/utils/queryUtils.ts
index 5a8659a276c2b..b6fd803010e5a 100644
--- a/frontend/src/scenes/insights/utils/queryUtils.ts
+++ b/frontend/src/scenes/insights/utils/queryUtils.ts
@@ -146,8 +146,12 @@ const cleanInsightQuery = (query: InsightQueryNode, opts?: CompareQueryOpts): In
yAxisScaleType: undefined,
hiddenLegendIndexes: undefined,
hiddenLegendBreakdowns: undefined,
+ resultCustomizations: undefined,
+ resultCustomizationBy: undefined,
}
+ cleanedQuery.dataColorTheme = undefined
+
if (isInsightQueryWithSeries(cleanedQuery)) {
cleanedQuery.series = cleanedQuery.series.map((entity) => {
const { custom_name, ...cleanedEntity } = entity
diff --git a/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx b/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx
index aa6703deebf86..2907f1f3cfa0c 100644
--- a/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx
+++ b/frontend/src/scenes/insights/views/Funnels/FunnelStepsTable.tsx
@@ -1,12 +1,13 @@
import { IconFlag } from '@posthog/icons'
+import clsx from 'clsx'
import { useActions, useValues } from 'kea'
-import { getSeriesColor } from 'lib/colors'
import { EntityFilterInfo } from 'lib/components/EntityFilterInfo'
import { LemonCheckbox } from 'lib/lemon-ui/LemonCheckbox'
import { LemonRow } from 'lib/lemon-ui/LemonRow'
import { LemonTable, LemonTableColumn, LemonTableColumnGroup } from 'lib/lemon-ui/LemonTable'
import { Lettermark, LettermarkColor } from 'lib/lemon-ui/Lettermark'
import { humanFriendlyDuration, humanFriendlyNumber, percentage } from 'lib/utils'
+import { useState } from 'react'
import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
import { funnelPersonsModalLogic } from 'scenes/funnels/funnelPersonsModalLogic'
import { getVisibilityKey } from 'scenes/funnels/funnelUtils'
@@ -19,15 +20,21 @@ import { cohortsModel } from '~/models/cohortsModel'
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
import { FlattenedFunnelStepByBreakdown } from '~/types'
+import { resultCustomizationsModalLogic } from '../../../../queries/nodes/InsightViz/resultCustomizationsModalLogic'
+import { CustomizationIcon } from '../InsightsTable/columns/SeriesColumn'
import { getActionFilterFromFunnelStep, getSignificanceFromBreakdownStep } from './funnelStepTableUtils'
export function FunnelStepsTable(): JSX.Element | null {
const { insightProps, insightLoading } = useValues(insightLogic)
const { breakdownFilter } = useValues(insightVizDataLogic(insightProps))
- const { steps, flattenedBreakdowns, hiddenLegendBreakdowns } = useValues(funnelDataLogic(insightProps))
+ const { steps, flattenedBreakdowns, hiddenLegendBreakdowns, getFunnelsColor } = useValues(
+ funnelDataLogic(insightProps)
+ )
const { setHiddenLegendBreakdowns, toggleLegendBreakdownVisibility } = useActions(funnelDataLogic(insightProps))
const { canOpenPersonModal } = useValues(funnelPersonsModalLogic(insightProps))
const { openPersonsModalForSeries } = useActions(funnelPersonsModalLogic(insightProps))
+ const { hasInsightColors } = useValues(resultCustomizationsModalLogic(insightProps))
+ const { openModal } = useActions(resultCustomizationsModalLogic(insightProps))
const isOnlySeries = flattenedBreakdowns.length <= 1
@@ -41,6 +48,13 @@ export function FunnelStepsTable(): JSX.Element | null {
(b) => !hiddenLegendBreakdowns?.includes(getVisibilityKey(b.breakdown_value))
)
+ /** :HACKY: We don't want to allow changing of colors in experiments (they can't be
+ saved there). Therefore we use the `disable_baseline` prop on the cached insight passed
+ in by experiments as a measure of detecting wether we are in an experiment context.
+ Likely this can be done in a better way once experiments are re-written to use their own
+ queries. */
+ const showCustomizationIcon = hasInsightColors && !insightProps.cachedInsight?.disable_baseline
+
const columnsGrouped = [
{
children: [
@@ -67,17 +81,25 @@ export function FunnelStepsTable(): JSX.Element | null {
_: void,
breakdown: FlattenedFunnelStepByBreakdown
): JSX.Element {
+ const [isHovering, setIsHovering] = useState(false)
// :KLUDGE: `BreakdownStepValues` is always wrapped into an array, which doesn't work for the
// formatBreakdownLabel logic. Instead, we unwrap speculatively
const value =
breakdown.breakdown_value?.length == 1
? breakdown.breakdown_value[0]
: breakdown.breakdown_value
- const label = formatBreakdownLabel(
- value,
- breakdownFilter,
- cohorts,
- formatPropertyValueForDisplay
+ const label = (
+ openModal(breakdown) : undefined}
+ onMouseEnter={() => setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ >
+ {formatBreakdownLabel(value, breakdownFilter, cohorts, formatPropertyValueForDisplay)}
+ {showCustomizationIcon && }
+
)
return isOnlySeries ? (
{label}
@@ -290,7 +312,7 @@ export function FunnelStepsTable(): JSX.Element | null {
loading={insightLoading}
rowKey="breakdownIndex"
rowStatus={(record) => (record.significant ? 'highlighted' : null)}
- rowRibbonColor={(series) => getSeriesColor(series?.breakdownIndex ?? 0)}
+ rowRibbonColor={getFunnelsColor}
firstColumnSticky
/>
)
diff --git a/frontend/src/scenes/insights/views/InsightsTable/InsightsTable.tsx b/frontend/src/scenes/insights/views/InsightsTable/InsightsTable.tsx
index ffec9c6ff6ae5..b9ad89d921679 100644
--- a/frontend/src/scenes/insights/views/InsightsTable/InsightsTable.tsx
+++ b/frontend/src/scenes/insights/views/InsightsTable/InsightsTable.tsx
@@ -1,7 +1,6 @@
import './InsightsTable.scss'
import { useActions, useValues } from 'kea'
-import { getTrendLikeSeriesColor } from 'lib/colors'
import { LemonTable, LemonTableColumn } from 'lib/lemon-ui/LemonTable'
import { compare as compareFn } from 'natural-orderby'
import { insightLogic } from 'scenes/insights/insightLogic'
@@ -12,6 +11,7 @@ import { IndexedTrendResult } from 'scenes/trends/types'
import { cohortsModel } from '~/models/cohortsModel'
import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel'
+import { isValidBreakdown } from '~/queries/utils'
import { ChartDisplayType, ItemMode } from '~/types'
import { entityFilterLogic } from '../../filters/ActionFilter/entityFilterLogic'
@@ -75,6 +75,7 @@ export function InsightsTable({
trendsFilter,
isSingleSeries,
hiddenLegendIndexes,
+ getTrendsColor,
} = useValues(trendsDataLogic(insightProps))
const { toggleHiddenLegendIndex, updateHiddenLegendIndexes } = useActions(trendsDataLogic(insightProps))
const { aggregation, allowAggregation } = useValues(insightsTableDataLogic(insightProps))
@@ -120,6 +121,7 @@ export function InsightsTable({
canEditSeriesNameInline={canEditSeriesNameInline}
handleEditClick={handleSeriesEditClick}
hasMultipleSeries={!isSingleSeries}
+ hasBreakdown={isValidBreakdown(breakdownFilter)}
/>
)
return hasCheckboxes ? (
@@ -148,15 +150,9 @@ export function InsightsTable({
columns.push({
title: ,
- render: (_, item) => (
-
- ),
+ render: (_, item) => {
+ return
+ },
key: 'breakdown',
sorter: (a, b) => {
if (typeof a.breakdown_value === 'number' && typeof b.breakdown_value === 'number') {
@@ -193,15 +189,9 @@ export function InsightsTable({
columns.push({
title: {breakdown.property?.toString()} ,
- render: (_, item) => (
-
- ),
+ render: (_, item) => {
+ return
+ },
key: `breakdown-${breakdown.property?.toString() || index}`,
sorter: (a, b) => {
const leftValue = Array.isArray(a.breakdown_value) ? a.breakdown_value[index] : a.breakdown_value
@@ -285,8 +275,14 @@ export function InsightsTable({
useURLForSorting={insightMode !== ItemMode.Edit}
rowRibbonColor={
isLegend
- ? (item) =>
- getTrendLikeSeriesColor(item.colorIndex, !!item.compare && item.compare_label === 'previous')
+ ? (item) => {
+ const isPrevious = !!item.compare && item.compare_label === 'previous'
+
+ const themeColor = getTrendsColor(item)
+ const mainColor = isPrevious ? `${themeColor}80` : themeColor
+
+ return mainColor
+ }
: undefined
}
firstColumnSticky
diff --git a/frontend/src/scenes/insights/views/InsightsTable/columns/BreakdownColumn.tsx b/frontend/src/scenes/insights/views/InsightsTable/columns/BreakdownColumn.tsx
index bbbad80b1392d..d84c1fd44f9f6 100644
--- a/frontend/src/scenes/insights/views/InsightsTable/columns/BreakdownColumn.tsx
+++ b/frontend/src/scenes/insights/views/InsightsTable/columns/BreakdownColumn.tsx
@@ -1,12 +1,19 @@
import { Link } from '@posthog/lemon-ui'
+import clsx from 'clsx'
+import { useActions, useValues } from 'kea'
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
import { isURL } from 'lib/utils'
import stringWithWBR from 'lib/utils/stringWithWBR'
+import { useState } from 'react'
+import { insightLogic } from 'scenes/insights/insightLogic'
import { formatBreakdownType } from 'scenes/insights/utils'
import { IndexedTrendResult } from 'scenes/trends/types'
import { BreakdownFilter } from '~/queries/schema'
+import { resultCustomizationsModalLogic } from '../../../../../queries/nodes/InsightViz/resultCustomizationsModalLogic'
+import { CustomizationIcon } from './SeriesColumn'
+
interface BreakdownColumnTitleProps {
breakdownFilter: BreakdownFilter
}
@@ -25,36 +32,38 @@ export function MultipleBreakdownColumnTitle({ children }: MultipleBreakdownColu
type BreakdownColumnItemProps = {
item: IndexedTrendResult
- canCheckUncheckSeries: boolean
- isMainInsightView: boolean
- toggleHiddenLegendIndex: (index: number) => void
formatItemBreakdownLabel: (item: IndexedTrendResult) => string
}
-export function BreakdownColumnItem({
- item,
- canCheckUncheckSeries,
- isMainInsightView,
- toggleHiddenLegendIndex,
- formatItemBreakdownLabel,
-}: BreakdownColumnItemProps): JSX.Element {
+export function BreakdownColumnItem({ item, formatItemBreakdownLabel }: BreakdownColumnItemProps): JSX.Element {
+ const [isHovering, setIsHovering] = useState(false)
+ const { insightProps } = useValues(insightLogic)
+ const { hasInsightColors } = useValues(resultCustomizationsModalLogic(insightProps))
+ const { openModal } = useActions(resultCustomizationsModalLogic(insightProps))
+
const breakdownLabel = formatItemBreakdownLabel(item)
const formattedLabel = stringWithWBR(breakdownLabel, 20)
- const multiEntityAndToggleable = !isMainInsightView && canCheckUncheckSeries
+
return (
toggleHiddenLegendIndex(item.id) : undefined}
+ className={clsx('flex justify-between items-center', { 'cursor-pointer': hasInsightColors })}
+ onClick={hasInsightColors ? () => openModal(item) : undefined}
+ onMouseEnter={() => setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
>
{breakdownLabel && (
<>
{isURL(breakdownLabel) ? (
-
+
{formattedLabel}
) : (
-
{formattedLabel}
+
+ {formattedLabel}
+
)}
+
+
>
)}
diff --git a/frontend/src/scenes/insights/views/InsightsTable/columns/SeriesColumn.tsx b/frontend/src/scenes/insights/views/InsightsTable/columns/SeriesColumn.tsx
index a77df94c46270..e3c77b5f8c12d 100644
--- a/frontend/src/scenes/insights/views/InsightsTable/columns/SeriesColumn.tsx
+++ b/frontend/src/scenes/insights/views/InsightsTable/columns/SeriesColumn.tsx
@@ -1,19 +1,39 @@
import { IconPencil } from '@posthog/icons'
import clsx from 'clsx'
-import { getTrendLikeSeriesColor } from 'lib/colors'
+import { useActions, useValues } from 'kea'
import { InsightLabel } from 'lib/components/InsightLabel'
-import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { capitalizeFirstLetter } from 'lib/utils'
+import { useState } from 'react'
+import { insightLogic } from 'scenes/insights/insightLogic'
import { IndexedTrendResult } from 'scenes/trends/types'
import { TrendResult } from '~/types'
+import { resultCustomizationsModalLogic } from '../../../../../queries/nodes/InsightViz/resultCustomizationsModalLogic'
+
+type CustomizationIconProps = {
+ isVisible: boolean
+}
+
+export const CustomizationIcon = ({ isVisible }: CustomizationIconProps): JSX.Element | null => {
+ const { insightProps } = useValues(insightLogic)
+ const { hasInsightColors } = useValues(resultCustomizationsModalLogic(insightProps))
+
+ if (!hasInsightColors) {
+ return null
+ }
+
+ // we always render a spacer so that hovering doesn't result in layout shifts
+ return {isVisible && }
+}
+
type SeriesColumnItemProps = {
item: IndexedTrendResult
indexedResults: IndexedTrendResult[]
canEditSeriesNameInline: boolean
handleEditClick: (item: IndexedTrendResult) => void
- hasMultipleSeries?: boolean
+ hasMultipleSeries: boolean
+ hasBreakdown: boolean
}
export function SeriesColumnItem({
@@ -22,37 +42,51 @@ export function SeriesColumnItem({
canEditSeriesNameInline,
handleEditClick,
hasMultipleSeries,
+ hasBreakdown,
}: SeriesColumnItemProps): JSX.Element {
- const showCountedByTag = !!indexedResults.find(({ action }) => action?.math && action.math !== 'total')
+ const [isHovering, setIsHovering] = useState(false)
+ const { insightProps } = useValues(insightLogic)
+ const { hasInsightColors } = useValues(resultCustomizationsModalLogic(insightProps))
+ const { openModal } = useActions(resultCustomizationsModalLogic(insightProps))
- const isPrevious = !!item.compare && item.compare_label === 'previous'
+ const showCountedByTag = !!indexedResults.find(({ action }) => action?.math && action.math !== 'total')
+ const showCustomizationIcon = hasInsightColors && !hasBreakdown
return (
-
+
{
+ e.preventDefault()
+ e.stopPropagation()
+
+ openModal(item)
+ }
+ : undefined
+ }
+ onMouseEnter={() => setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ >
handleEditClick(item) : undefined}
+ onLabelClick={
+ canEditSeriesNameInline && !showCustomizationIcon ? () => handleEditClick(item) : undefined
+ }
/>
- {canEditSeriesNameInline && (
- handleEditClick(item)}
- title="Rename graph series"
- icon={ }
- />
- )}
+ {/* rendering and visibility are separated, so that we can render a placeholder */}
+ {showCustomizationIcon && }
)
}
diff --git a/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx b/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx
index 007ad83541304..f3b909404723a 100644
--- a/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx
+++ b/frontend/src/scenes/insights/views/LineGraph/LineGraph.tsx
@@ -23,7 +23,7 @@ import {
TooltipModel,
TooltipOptions,
} from 'lib/Chart'
-import { getBarColorFromStatus, getGraphColors, getTrendLikeSeriesColor } from 'lib/colors'
+import { getBarColorFromStatus, getGraphColors } from 'lib/colors'
import { AnnotationsOverlay } from 'lib/components/AnnotationsOverlay'
import { SeriesLetter } from 'lib/components/SeriesGlyph'
import { useResizeObserver } from 'lib/hooks/useResizeObserver'
@@ -36,6 +36,7 @@ import { TooltipConfig } from 'scenes/insights/InsightTooltip/insightTooltipUtil
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
import { PieChart } from 'scenes/insights/views/LineGraph/PieChart'
import { createTooltipData } from 'scenes/insights/views/LineGraph/tooltip-data'
+import { trendsDataLogic } from 'scenes/trends/trendsDataLogic'
import { ErrorBoundary } from '~/layout/ErrorBoundary'
import { themeLogic } from '~/layout/navigation-3000/themeLogic'
@@ -288,6 +289,7 @@ export function LineGraph_({
const { insightProps, insight } = useValues(insightLogic)
const { timezone, isTrends, breakdownFilter } = useValues(insightVizDataLogic(insightProps))
+ const { theme, getTrendsColor } = useValues(trendsDataLogic(insightProps))
const canvasRef = useRef
(null)
const [myLineChart, setMyLineChart] = useState>()
@@ -303,7 +305,7 @@ export function LineGraph_({
}
const isBar = [GraphType.Bar, GraphType.HorizontalBar, GraphType.Histogram].includes(type)
- const isBackgroundBasedGraphType = [GraphType.Bar, GraphType.HorizontalBar].includes(type)
+ const isBackgroundBasedGraphType = [GraphType.Bar].includes(type)
const isPercentStackView = !!supportsPercentStackView && !!showPercentStackView
const showAnnotations = isTrends && !isHorizontal && !hideAnnotations
const isLog10 = yAxisScaleType === 'log10' // Currently log10 is the only logarithmic scale supported
@@ -319,21 +321,20 @@ export function LineGraph_({
function processDataset(dataset: ChartDataset): ChartDataset {
const isPrevious = !!dataset.compare && dataset.compare_label === 'previous'
- const mainColor = dataset?.status
+ const themeColor = dataset?.status
? getBarColorFromStatus(dataset.status)
- : getTrendLikeSeriesColor(
- // colorIndex is set for trends, seriesIndex is used for stickiness, index is used for retention
- dataset?.colorIndex ?? dataset.seriesIndex ?? dataset.index,
- isPrevious && !isArea
- )
+ : isHorizontal
+ ? dataset.backgroundColor
+ : getTrendsColor(dataset)
+ const mainColor = isPrevious ? `${themeColor}80` : themeColor
+
const hoverColor = dataset?.status ? getBarColorFromStatus(dataset.status, true) : mainColor
- const areaBackgroundColor = hexToRGBA(mainColor, 0.5)
- const areaIncompletePattern = createPinstripePattern(areaBackgroundColor, isDarkModeOn)
+
let backgroundColor: string | undefined = undefined
if (isBackgroundBasedGraphType) {
backgroundColor = mainColor
} else if (isArea) {
- backgroundColor = areaBackgroundColor
+ backgroundColor = hexToRGBA(mainColor, 0.5)
}
let adjustedData = dataset.data
@@ -370,6 +371,8 @@ export function LineGraph_({
const isIncomplete = ctx.p1DataIndex >= dataset.data.length + incompletenessOffsetFromEnd
const isActive = !dataset.compare || dataset.compare_label != 'previous'
// if last date is still active show dotted line
+ const areaBackgroundColor = hexToRGBA(mainColor, 0.5)
+ const areaIncompletePattern = createPinstripePattern(areaBackgroundColor, isDarkModeOn)
return isIncomplete && isActive ? areaIncompletePattern : undefined
},
},
@@ -809,6 +812,7 @@ export function LineGraph_({
showValuesOnSeries,
showPercentStackView,
alertLines,
+ theme,
])
return (
diff --git a/frontend/src/scenes/insights/views/WorldMap/WorldMap.tsx b/frontend/src/scenes/insights/views/WorldMap/WorldMap.tsx
index 73499d609400a..ad3b04f941335 100644
--- a/frontend/src/scenes/insights/views/WorldMap/WorldMap.tsx
+++ b/frontend/src/scenes/insights/views/WorldMap/WorldMap.tsx
@@ -1,7 +1,7 @@
import './WorldMap.scss'
import { useActions, useValues } from 'kea'
-import { BRAND_BLUE_HSL, gradateColor } from 'lib/colors'
+import { BRAND_BLUE_HSL, gradateColor } from 'lib/utils'
import React, { HTMLProps, useEffect, useRef } from 'react'
import { formatAggregationAxisValue } from 'scenes/insights/aggregationAxisFormat'
import { insightLogic } from 'scenes/insights/insightLogic'
diff --git a/frontend/src/scenes/instance/SystemStatus/KafkaInspectorTab.tsx b/frontend/src/scenes/instance/SystemStatus/KafkaInspectorTab.tsx
deleted file mode 100644
index 4c13d01457827..0000000000000
--- a/frontend/src/scenes/instance/SystemStatus/KafkaInspectorTab.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { LemonButton, LemonDivider } from '@posthog/lemon-ui'
-import { useValues } from 'kea'
-import { Field, Form } from 'kea-forms'
-import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
-import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
-
-import { kafkaInspectorLogic } from './kafkaInspectorLogic'
-
-export function KafkaInspectorTab(): JSX.Element {
- const { kafkaMessage } = useValues(kafkaInspectorLogic)
-
- return (
-
-
Kafka Inspector
-
Debug Kafka messages using the inspector tool.
-
-
-
- {kafkaMessage ? JSON.stringify(kafkaMessage, null, 4) : '\n'}
-
-
- )
-}
diff --git a/frontend/src/scenes/instance/SystemStatus/index.tsx b/frontend/src/scenes/instance/SystemStatus/index.tsx
index a9bba3c522dbd..56e8302485fa8 100644
--- a/frontend/src/scenes/instance/SystemStatus/index.tsx
+++ b/frontend/src/scenes/instance/SystemStatus/index.tsx
@@ -4,11 +4,9 @@ import { IconInfo } from '@posthog/icons'
import { LemonBanner, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { PageHeader } from 'lib/components/PageHeader'
-import { FEATURE_FLAGS } from 'lib/constants'
import { LemonTab, LemonTabs } from 'lib/lemon-ui/LemonTabs'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
-import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { InternalMetricsTab } from 'scenes/instance/SystemStatus/InternalMetricsTab'
import { OverviewTab } from 'scenes/instance/SystemStatus/OverviewTab'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
@@ -16,7 +14,6 @@ import { SceneExport } from 'scenes/sceneTypes'
import { userLogic } from 'scenes/userLogic'
import { InstanceConfigTab } from './InstanceConfigTab'
-import { KafkaInspectorTab } from './KafkaInspectorTab'
import { StaffUsersTab } from './StaffUsersTab'
import { InstanceStatusTabName, systemStatusLogic } from './systemStatusLogic'
@@ -30,7 +27,6 @@ export function SystemStatus(): JSX.Element {
const { setTab } = useActions(systemStatusLogic)
const { preflight, siteUrlMisconfigured } = useValues(preflightLogic)
const { user } = useValues(userLogic)
- const { featureFlags } = useValues(featureFlagLogic)
let tabs = [
{
@@ -58,7 +54,7 @@ export function SystemStatus(): JSX.Element {
label: (
<>
Settings{' '}
-
+
Beta
>
@@ -71,21 +67,6 @@ export function SystemStatus(): JSX.Element {
content: ,
},
])
-
- if (featureFlags[FEATURE_FLAGS.KAFKA_INSPECTOR]) {
- tabs.push({
- key: 'kafka_inspector',
- label: (
- <>
- Kafka Inspector{' '}
-
- Beta
-
- >
- ),
- content: ,
- })
- }
}
return (
diff --git a/frontend/src/scenes/instance/SystemStatus/kafkaInspectorLogic.ts b/frontend/src/scenes/instance/SystemStatus/kafkaInspectorLogic.ts
deleted file mode 100644
index 3fcb9422d906c..0000000000000
--- a/frontend/src/scenes/instance/SystemStatus/kafkaInspectorLogic.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { actions, kea, path } from 'kea'
-import { forms } from 'kea-forms'
-import { loaders } from 'kea-loaders'
-import api from 'lib/api'
-
-import type { kafkaInspectorLogicType } from './kafkaInspectorLogicType'
-export interface KafkaMessage {
- topic: string
- partition: number
- offset: number
- timestamp: number
- key: string
- value: Record | string
-}
-
-export const kafkaInspectorLogic = kea([
- path(['scenes', 'instance', 'SystemStatus', 'kafkaInspectorLogic']),
- actions({
- fetchKafkaMessage: (topic: string, partition: number, offset: number) => ({ topic, partition, offset }),
- }),
- loaders({
- kafkaMessage: [
- null as KafkaMessage | null,
- {
- fetchKafkaMessage: async ({ topic, partition, offset }) => {
- return await api.create('api/kafka_inspector/fetch_message', { topic, partition, offset })
- },
- },
- ],
- }),
- forms(({ actions }) => ({
- fetchKafkaMessage: {
- defaults: { topic: 'clickhouse_events_json', partition: 0, offset: 0 },
- submit: ({ topic, partition, offset }: { topic: string; partition: number; offset: number }) => {
- actions.fetchKafkaMessage(topic, Number(partition), Number(offset))
- },
- },
- })),
-])
diff --git a/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts b/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts
index 5ee7e0b6e3c97..7bfaa40093c80 100644
--- a/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts
+++ b/frontend/src/scenes/instance/SystemStatus/systemStatusLogic.ts
@@ -18,7 +18,7 @@ export enum ConfigMode {
Saving = 'saving',
}
-export type InstanceStatusTabName = 'overview' | 'metrics' | 'settings' | 'staff_users' | 'kafka_inspector'
+export type InstanceStatusTabName = 'overview' | 'metrics' | 'settings' | 'staff_users'
/**
* We allow the specific instance settings that can be edited via the /instance/status page.
@@ -196,8 +196,7 @@ export const systemStatusLogic = kea([
})),
urlToAction(({ actions, values }) => ({
'/instance(/:tab)': ({ tab }: { tab?: InstanceStatusTabName }) => {
- const currentTab =
- tab && ['metrics', 'settings', 'staff_users', 'kafka_inspector'].includes(tab) ? tab : 'overview'
+ const currentTab = tab && ['metrics', 'settings', 'staff_users'].includes(tab) ? tab : 'overview'
if (currentTab !== values.tab) {
actions.setTab(currentTab)
}
diff --git a/frontend/src/scenes/max/Intro.tsx b/frontend/src/scenes/max/Intro.tsx
index c43cd86b53d2a..97f4f9fbfdc56 100644
--- a/frontend/src/scenes/max/Intro.tsx
+++ b/frontend/src/scenes/max/Intro.tsx
@@ -3,6 +3,7 @@ import { LemonButton, Popover } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { HedgehogBuddy } from 'lib/components/HedgehogBuddy/HedgehogBuddy'
import { hedgehogBuddyLogic } from 'lib/components/HedgehogBuddy/hedgehogBuddyLogic'
+import { uuid } from 'lib/utils'
import { useMemo, useState } from 'react'
import { maxGlobalLogic } from './maxGlobalLogic'
@@ -19,13 +20,13 @@ export function Intro(): JSX.Element {
const { hedgehogConfig } = useValues(hedgehogBuddyLogic)
const { acceptDataProcessing } = useActions(maxGlobalLogic)
const { dataProcessingAccepted } = useValues(maxGlobalLogic)
- const { sessionId } = useValues(maxLogic)
+ const { conversation } = useValues(maxLogic)
const [hedgehogDirection, setHedgehogDirection] = useState<'left' | 'right'>('right')
const headline = useMemo(() => {
- return HEADLINES[parseInt(sessionId.split('-').at(-1) as string, 16) % HEADLINES.length]
- }, [])
+ return HEADLINES[parseInt((conversation?.id || uuid()).split('-').at(-1) as string, 16) % HEADLINES.length]
+ }, [conversation?.id])
return (
<>
diff --git a/frontend/src/scenes/max/Max.stories.tsx b/frontend/src/scenes/max/Max.stories.tsx
index bec5a519de8e0..51dc03ab0cb5c 100644
--- a/frontend/src/scenes/max/Max.stories.tsx
+++ b/frontend/src/scenes/max/Max.stories.tsx
@@ -6,7 +6,13 @@ import { projectLogic } from 'scenes/projectLogic'
import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
-import { chatResponseChunk, failureChunk, generationFailureChunk } from './__mocks__/chatResponse.mocks'
+import {
+ chatResponseChunk,
+ CONVERSATION_ID,
+ failureChunk,
+ generationFailureChunk,
+ humanMessage,
+} from './__mocks__/chatResponse.mocks'
import { MaxInstance } from './Max'
import { maxGlobalLogic } from './maxGlobalLogic'
import { maxLogic } from './maxLogic'
@@ -16,7 +22,7 @@ const meta: Meta = {
decorators: [
mswDecorator({
post: {
- '/api/environments/:team_id/query/chat/': (_, res, ctx) => res(ctx.text(chatResponseChunk)),
+ '/api/environments/:team_id/conversations/': (_, res, ctx) => res(ctx.text(chatResponseChunk)),
},
}),
],
@@ -28,10 +34,7 @@ const meta: Meta = {
}
export default meta
-// The session ID is hard-coded here, as it's used for randomizing the welcome headline
-const SESSION_ID = 'b1b4b3b4-1b3b-4b3b-1b3b4b3b4b3b'
-
-const Template = ({ sessionId: SESSION_ID }: { sessionId: string }): JSX.Element => {
+const Template = ({ conversationId: CONVERSATION_ID }: { conversationId: string }): JSX.Element => {
const { acceptDataProcessing } = useActions(maxGlobalLogic)
useEffect(() => {
@@ -40,7 +43,7 @@ const Template = ({ sessionId: SESSION_ID }: { sessionId: string }): JSX.Element
return (
-
+
@@ -69,7 +72,7 @@ export const Welcome: StoryFn = () => {
acceptDataProcessing(false)
}, [])
- return
+ return
}
export const WelcomeSuggestionsAvailable: StoryFn = () => {
@@ -95,7 +98,7 @@ export const WelcomeLoadingSuggestions: StoryFn = () => {
loadCurrentProjectSuccess({ ...MOCK_DEFAULT_PROJECT, product_description: 'A Storybook test.' })
}, [])
- return
+ return
}
WelcomeLoadingSuggestions.parameters = {
testOptions: {
@@ -104,29 +107,29 @@ WelcomeLoadingSuggestions.parameters = {
}
export const Thread: StoryFn = () => {
- const { askMax } = useActions(maxLogic({ sessionId: SESSION_ID }))
+ const { askMax } = useActions(maxLogic({ conversationId: CONVERSATION_ID }))
useEffect(() => {
- askMax('What are my most popular pages?')
+ askMax(humanMessage.content)
}, [])
- return
+ return
}
export const EmptyThreadLoading: StoryFn = () => {
useStorybookMocks({
post: {
- '/api/environments/:team_id/query/chat/': (_req, _res, ctx) => [ctx.delay('infinite')],
+ '/api/environments/:team_id/conversations/': (_req, _res, ctx) => [ctx.delay('infinite')],
},
})
- const { askMax } = useActions(maxLogic({ sessionId: SESSION_ID }))
+ const { askMax } = useActions(maxLogic({ conversationId: CONVERSATION_ID }))
useEffect(() => {
- askMax('What are my most popular pages?')
+ askMax(humanMessage.content)
}, [])
- return
+ return
}
EmptyThreadLoading.parameters = {
testOptions: {
@@ -137,15 +140,15 @@ EmptyThreadLoading.parameters = {
export const GenerationFailureThread: StoryFn = () => {
useStorybookMocks({
post: {
- '/api/environments/:team_id/query/chat/': (_, res, ctx) => res(ctx.text(generationFailureChunk)),
+ '/api/environments/:team_id/conversations/': (_, res, ctx) => res(ctx.text(generationFailureChunk)),
},
})
- const { askMax, setMessageStatus } = useActions(maxLogic({ sessionId: SESSION_ID }))
- const { threadRaw, threadLoading } = useValues(maxLogic({ sessionId: SESSION_ID }))
+ const { askMax, setMessageStatus } = useActions(maxLogic({ conversationId: CONVERSATION_ID }))
+ const { threadRaw, threadLoading } = useValues(maxLogic({ conversationId: CONVERSATION_ID }))
useEffect(() => {
- askMax('What are my most popular pages?')
+ askMax(humanMessage.content)
}, [])
useEffect(() => {
@@ -154,38 +157,38 @@ export const GenerationFailureThread: StoryFn = () => {
}
}, [threadRaw.length, threadLoading])
- return
+ return
}
export const ThreadWithFailedGeneration: StoryFn = () => {
useStorybookMocks({
post: {
- '/api/environments/:team_id/query/chat/': (_, res, ctx) => res(ctx.text(failureChunk)),
+ '/api/environments/:team_id/conversations/': (_, res, ctx) => res(ctx.text(failureChunk)),
},
})
- const { askMax } = useActions(maxLogic({ sessionId: SESSION_ID }))
+ const { askMax } = useActions(maxLogic({ conversationId: CONVERSATION_ID }))
useEffect(() => {
- askMax('What are my most popular pages?')
+ askMax(humanMessage.content)
}, [])
- return
+ return
}
export const ThreadWithRateLimit: StoryFn = () => {
useStorybookMocks({
post: {
- '/api/environments/:team_id/query/chat/': (_, res, ctx) =>
+ '/api/environments/:team_id/conversations/': (_, res, ctx) =>
res(ctx.text(chatResponseChunk), ctx.status(429)),
},
})
- const { askMax } = useActions(maxLogic({ sessionId: SESSION_ID }))
+ const { askMax } = useActions(maxLogic({ conversationId: CONVERSATION_ID }))
useEffect(() => {
askMax('Is Bielefeld real?')
}, [])
- return
+ return
}
diff --git a/frontend/src/scenes/max/Max.tsx b/frontend/src/scenes/max/Max.tsx
index d7025cb06eafd..cf5892f497f35 100644
--- a/frontend/src/scenes/max/Max.tsx
+++ b/frontend/src/scenes/max/Max.tsx
@@ -2,8 +2,6 @@ import { BindLogic, useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
-import { uuid } from 'lib/utils'
-import { useMemo } from 'react'
import { SceneExport } from 'scenes/sceneTypes'
import { Intro } from './Intro'
@@ -19,14 +17,12 @@ export const scene: SceneExport = {
export function Max(): JSX.Element {
const { featureFlags } = useValues(featureFlagLogic)
- const sessionId = useMemo(() => uuid(), [])
-
if (!featureFlags[FEATURE_FLAGS.ARTIFICIAL_HOG]) {
return
}
return (
-
+
)
diff --git a/frontend/src/scenes/max/Thread.tsx b/frontend/src/scenes/max/Thread.tsx
index 5014d8a752509..f8a5d56d0650b 100644
--- a/frontend/src/scenes/max/Thread.tsx
+++ b/frontend/src/scenes/max/Thread.tsx
@@ -83,10 +83,12 @@ function MessageGroup({ messages, isFinal: isGroupFinal, index: messageGroupInde
)}
>
{messages.map((message, messageIndex) => {
+ const key = message.id || messageIndex
+
if (isHumanMessage(message)) {
return (
@@ -96,7 +98,7 @@ function MessageGroup({ messages, isFinal: isGroupFinal, index: messageGroupInde
} else if (isAssistantMessage(message) || isFailureMessage(message)) {
return (
} else if (isReasoningMessage(message)) {
return (
-
+
{message.content}ā¦
diff --git a/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts b/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
index ce9930b986211..6acfc72bd5914 100644
--- a/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
+++ b/frontend/src/scenes/max/__mocks__/chatResponse.mocks.ts
@@ -2,6 +2,7 @@ import {
AssistantGenerationStatusEvent,
AssistantGenerationStatusType,
AssistantMessageType,
+ HumanMessage,
ReasoningMessage,
} from '~/queries/schema'
@@ -9,16 +10,25 @@ import failureMessage from './failureMessage.json'
import summaryMessage from './summaryMessage.json'
import visualizationMessage from './visualizationMessage.json'
+// The session ID is hard-coded here, as it's used for randomizing the welcome headline
+export const CONVERSATION_ID = 'b1b4b3b4-1b3b-4b3b-1b3b4b3b4b3b'
+
+export const humanMessage: HumanMessage = {
+ type: AssistantMessageType.Human,
+ content: 'What are my most popular pages?',
+ id: 'human-1',
+}
+
const reasoningMessage1: ReasoningMessage = {
type: AssistantMessageType.Reasoning,
content: 'Picking relevant events and properties',
- done: true,
+ id: 'reasoning-1',
}
const reasoningMessage2: ReasoningMessage = {
type: AssistantMessageType.Reasoning,
content: 'Generating trends',
- done: true,
+ id: 'reasoning-2',
}
function generateChunk(events: string[]): string {
@@ -26,6 +36,10 @@ function generateChunk(events: string[]): string {
}
export const chatResponseChunk = generateChunk([
+ 'event: conversation',
+ `data: ${JSON.stringify({ id: CONVERSATION_ID })}`,
+ 'event: message',
+ `data: ${JSON.stringify(humanMessage)}`,
'event: message',
`data: ${JSON.stringify(reasoningMessage1)}`,
'event: message',
diff --git a/frontend/src/scenes/max/__mocks__/failureMessage.json b/frontend/src/scenes/max/__mocks__/failureMessage.json
index 66ab38b6c49ca..5667c02bd65a4 100644
--- a/frontend/src/scenes/max/__mocks__/failureMessage.json
+++ b/frontend/src/scenes/max/__mocks__/failureMessage.json
@@ -1,5 +1,5 @@
{
"type": "ai/failure",
"content": "Oops! It looks like Iām having trouble generating this trends insight. Could you please try again?",
- "done": true
+ "id": "test-failure-message"
}
diff --git a/frontend/src/scenes/max/__mocks__/summaryMessage.json b/frontend/src/scenes/max/__mocks__/summaryMessage.json
index 011565dc126e2..c7ff5d635f0ec 100644
--- a/frontend/src/scenes/max/__mocks__/summaryMessage.json
+++ b/frontend/src/scenes/max/__mocks__/summaryMessage.json
@@ -1,5 +1,5 @@
{
"type": "ai",
"content": "Looks like no pageviews have occured. Get some damn users.",
- "done": true
+ "id": "test-summary-message"
}
diff --git a/frontend/src/scenes/max/__mocks__/visualizationMessage.json b/frontend/src/scenes/max/__mocks__/visualizationMessage.json
index 63f21b458ef48..a9a0e1c44d6c9 100644
--- a/frontend/src/scenes/max/__mocks__/visualizationMessage.json
+++ b/frontend/src/scenes/max/__mocks__/visualizationMessage.json
@@ -56,5 +56,5 @@
"yAxisScaleType": null
}
},
- "done": true
+ "id": "test-visualization-message"
}
diff --git a/frontend/src/scenes/max/maxLogic.ts b/frontend/src/scenes/max/maxLogic.ts
index f1f45f1d8ef69..74316cc3ed439 100644
--- a/frontend/src/scenes/max/maxLogic.ts
+++ b/frontend/src/scenes/max/maxLogic.ts
@@ -4,6 +4,7 @@ import { createParser } from 'eventsource-parser'
import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api, { ApiError } from 'lib/api'
+import { uuid } from 'lib/utils'
import { isHumanMessage, isReasoningMessage, isVisualizationMessage } from 'scenes/max/utils'
import { projectLogic } from 'scenes/projectLogic'
@@ -20,11 +21,12 @@ import {
RootAssistantMessage,
SuggestedQuestionsQuery,
} from '~/queries/schema'
+import { Conversation } from '~/types'
import type { maxLogicType } from './maxLogicType'
export interface MaxLogicProps {
- sessionId: string
+ conversationId?: string
}
export type MessageStatus = 'loading' | 'completed' | 'error'
@@ -37,13 +39,12 @@ const FAILURE_MESSAGE: FailureMessage & ThreadMessage = {
type: AssistantMessageType.Failure,
content: 'Oops! It looks like Iām having trouble generating this trends insight. Could you please try again?',
status: 'completed',
- done: true,
}
export const maxLogic = kea
([
path(['scenes', 'max', 'maxLogic']),
props({} as MaxLogicProps),
- key(({ sessionId }) => sessionId),
+ key(({ conversationId }) => conversationId || 'new-conversation'),
connect({
values: [projectLogic, ['currentProject']],
}),
@@ -58,6 +59,7 @@ export const maxLogic = kea([
shuffleVisibleSuggestions: true,
retryLastMessage: true,
scrollThreadToBottom: true,
+ setConversation: (conversation: Conversation) => ({ conversation }),
}),
reducers({
question: [
@@ -67,6 +69,12 @@ export const maxLogic = kea([
askMax: () => '',
},
],
+ conversation: [
+ (_, props) => (props.conversationId ? ({ id: props.conversationId } as Conversation) : null),
+ {
+ setConversation: (_, { conversation }) => conversation,
+ },
+ ],
threadRaw: [
[] as ThreadMessage[],
{
@@ -118,7 +126,7 @@ export const maxLogic = kea([
},
],
}),
- listeners(({ actions, values, props }) => ({
+ listeners(({ actions, values }) => ({
[projectLogic.actionTypes.updateCurrentProjectSuccess]: ({ payload }) => {
// Load suggestions anew after product description is changed on the project
// Most important when description is set for the first time, but also when updated,
@@ -156,11 +164,11 @@ export const maxLogic = kea([
)
},
askMax: async ({ prompt }) => {
- actions.addMessage({ type: AssistantMessageType.Human, content: prompt, done: true, status: 'completed' })
+ actions.addMessage({ type: AssistantMessageType.Human, content: prompt, status: 'completed' })
try {
- const response = await api.chat({
- session_id: props.sessionId,
- messages: values.threadRaw.map(({ status, ...message }) => message),
+ const response = await api.conversations.create({
+ content: prompt,
+ conversation: values.conversation?.id,
})
const reader = response.body?.getReader()
@@ -178,15 +186,20 @@ export const maxLogic = kea([
return
}
- if (values.threadRaw[values.threadRaw.length - 1].status === 'completed') {
+ if (isHumanMessage(parsedResponse)) {
+ actions.replaceMessage(values.threadRaw.length - 1, {
+ ...parsedResponse,
+ status: 'completed',
+ })
+ } else if (values.threadRaw[values.threadRaw.length - 1].status === 'completed') {
actions.addMessage({
...parsedResponse,
- status: !parsedResponse.done ? 'loading' : 'completed',
+ status: !parsedResponse.id ? 'loading' : 'completed',
})
} else if (parsedResponse) {
actions.replaceMessage(values.threadRaw.length - 1, {
...parsedResponse,
- status: !parsedResponse.done ? 'loading' : 'completed',
+ status: !parsedResponse.id ? 'loading' : 'completed',
})
}
} else if (event === AssistantEventType.Status) {
@@ -198,6 +211,12 @@ export const maxLogic = kea([
if (parsedResponse.type === AssistantGenerationStatusType.GenerationError) {
actions.setMessageStatus(values.threadRaw.length - 1, 'error')
}
+ } else if (event === AssistantEventType.Conversation) {
+ const parsedResponse = parseResponse(data)
+ if (!parsedResponse) {
+ return
+ }
+ actions.setConversation(parsedResponse)
}
},
})
@@ -210,7 +229,7 @@ export const maxLogic = kea([
}
}
} catch (e) {
- const relevantErrorMessage = { ...FAILURE_MESSAGE } // Generic message by default
+ const relevantErrorMessage = { ...FAILURE_MESSAGE, id: uuid() } // Generic message by default
if (e instanceof ApiError && e.status === 429) {
relevantErrorMessage.content = "You've reached my usage limit for now. Please try again later."
} else {
@@ -254,7 +273,6 @@ export const maxLogic = kea([
},
})),
selectors({
- sessionId: [(_, p) => [p.sessionId], (sessionId) => sessionId],
threadGrouped: [
(s) => [s.threadRaw, s.threadLoading],
(thread, threadLoading): ThreadMessage[][] => {
@@ -264,7 +282,7 @@ export const maxLogic = kea([
const previousMessage: ThreadMessage | undefined = thread[i - 1]
if (currentMessage.type.split('/')[0] === previousMessage?.type.split('/')[0]) {
const lastThreadSoFar = threadGrouped[threadGrouped.length - 1]
- if (currentMessage.done && previousMessage.type === AssistantMessageType.Reasoning) {
+ if (currentMessage.id && previousMessage.type === AssistantMessageType.Reasoning) {
// Only preserve the latest reasoning message, and remove once reasoning is done
lastThreadSoFar[lastThreadSoFar.length - 1] = currentMessage
} else {
@@ -276,14 +294,17 @@ export const maxLogic = kea([
}
if (threadLoading) {
const finalMessageSoFar = threadGrouped.at(-1)?.at(-1)
- if (finalMessageSoFar?.done && finalMessageSoFar.type !== AssistantMessageType.Reasoning) {
+ if (
+ finalMessageSoFar?.type === AssistantMessageType.Human ||
+ (finalMessageSoFar?.id && finalMessageSoFar.type !== AssistantMessageType.Reasoning)
+ ) {
// If now waiting for the current node to start streaming, add "Thinking" message
// so that there's _some_ indication of processing
const thinkingMessage: ReasoningMessage & ThreadMessage = {
type: AssistantMessageType.Reasoning,
content: 'Thinking',
status: 'completed',
- done: true,
+ id: 'loader',
}
if (finalMessageSoFar.type === AssistantMessageType.Human) {
// If the last message was human, we need to add a new "ephemeral" AI group
diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx
index e7bc3a324202c..eb980c068129e 100644
--- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx
+++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeExperiment.tsx
@@ -18,7 +18,7 @@ import { INTEGER_REGEX_MATCH_GROUPS } from './utils'
const Component = ({ attributes }: NotebookNodeProps): JSX.Element => {
const { id } = attributes
- const { experiment, experimentLoading, experimentMissing, isExperimentRunning, experimentResults } = useValues(
+ const { experiment, experimentLoading, experimentMissing, isExperimentRunning, metricResults } = useValues(
experimentLogic({ experimentId: id })
)
const { loadExperiment } = useActions(experimentLogic({ experimentId: id }))
@@ -41,6 +41,10 @@ const Component = ({ attributes }: NotebookNodeProps
}
+ if (!metricResults) {
+ return <>>
+ }
+
return (
@@ -77,8 +81,8 @@ const Component = ({ attributes }: NotebookNodeProps
-
-
+
+
>
)}
diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx
deleted file mode 100644
index 1a9233289616c..0000000000000
--- a/frontend/src/scenes/notebooks/Notebook/NotebookShare.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { IconCopy } from '@posthog/icons'
-import { LemonBanner, LemonButton, LemonDivider } from '@posthog/lemon-ui'
-import { useValues } from 'kea'
-import { LemonDialog } from 'lib/lemon-ui/LemonDialog'
-import { base64Encode } from 'lib/utils'
-import { copyToClipboard } from 'lib/utils/copyToClipboard'
-import posthog from 'posthog-js'
-import { useState } from 'react'
-import { urls } from 'scenes/urls'
-
-import { notebookLogic } from './notebookLogic'
-
-export type NotebookShareProps = {
- shortId: string
-}
-export function NotebookShare({ shortId }: NotebookShareProps): JSX.Element {
- const { content, isLocalOnly } = useValues(notebookLogic({ shortId }))
-
- const notebookUrl = urls.absolute(urls.currentProject(urls.notebook(shortId)))
- const canvasUrl = urls.absolute(urls.canvas()) + `#š¦=${base64Encode(JSON.stringify(content))}`
-
- const [interestTracked, setInterestTracked] = useState(false)
-
- const trackInterest = (): void => {
- posthog.capture('pressed interested in notebook sharing', { url: notebookUrl })
- }
-
- return (
-
-
Internal Link
- {!isLocalOnly ? (
- <>
-
- Click the button below to copy a direct link to this Notebook. Make sure the person you
- share it with has access to this PostHog project.
-
-
}
- onClick={() => void copyToClipboard(notebookUrl, 'notebook link')}
- title={notebookUrl}
- >
-
{notebookUrl}
-
-
-
- >
- ) : (
-
- This Notebook cannot be shared directly with others as it is only visible to you.
-
- )}
-
-
Template Link
-
- The link below will open a Canvas with the contents of this Notebook, allowing the receiver to view it,
- edit it or create their own Notebook without affecting this one.
-
-
}
- onClick={() => void copyToClipboard(canvasUrl, 'canvas link')}
- title={canvasUrl}
- >
-
{canvasUrl}
-
-
-
-
-
External Sharing
-
-
{
- if (!interestTracked) {
- trackInterest()
- setInterestTracked(true)
- }
- },
- }}
- >
- We donāt currently support sharing notebooks externally, but itās on our roadmap!
-
-
- )
-}
-
-export function openNotebookShareDialog({ shortId }: NotebookShareProps): void {
- LemonDialog.open({
- title: 'Share notebook',
- content: ,
- width: 600,
- primaryButton: {
- children: 'Close',
- type: 'secondary',
- },
- })
-}
diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx
new file mode 100644
index 0000000000000..534599664149f
--- /dev/null
+++ b/frontend/src/scenes/notebooks/Notebook/NotebookShareModal.tsx
@@ -0,0 +1,133 @@
+import { IconCopy, IconOpenSidebar } from '@posthog/icons'
+import { LemonBanner, LemonButton, LemonDivider, LemonModal } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { FlaggedFeature } from 'lib/components/FlaggedFeature'
+import { SHARING_MODAL_WIDTH } from 'lib/components/Sharing/SharingModal'
+import { base64Encode } from 'lib/utils'
+import { copyToClipboard } from 'lib/utils/copyToClipboard'
+import posthog from 'posthog-js'
+import { useState } from 'react'
+import { urls } from 'scenes/urls'
+
+import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
+import { SidePanelTab } from '~/types'
+
+import { notebookLogic } from './notebookLogic'
+
+export type NotebookShareModalProps = {
+ shortId: string
+}
+
+export function NotebookShareModal({ shortId }: NotebookShareModalProps): JSX.Element {
+ const { content, isLocalOnly, isShareModalOpen } = useValues(notebookLogic({ shortId }))
+ const { closeShareModal } = useActions(notebookLogic({ shortId }))
+ const { openSidePanel } = useActions(sidePanelStateLogic)
+
+ const notebookUrl = urls.absolute(urls.currentProject(urls.notebook(shortId)))
+ const canvasUrl = urls.absolute(urls.canvas()) + `#š¦=${base64Encode(JSON.stringify(content))}`
+
+ const [interestTracked, setInterestTracked] = useState(false)
+
+ const trackInterest = (): void => {
+ posthog.capture('pressed interested in notebook sharing', { url: notebookUrl })
+ }
+
+ return (
+ closeShareModal()}
+ isOpen={isShareModalOpen}
+ width={SHARING_MODAL_WIDTH}
+ footer={
+
+ Done
+
+ }
+ >
+
+
+ <>
+
+
Access control
+
+ Permissions have moved! We're rolling out our new access control system. Click below to
+ open it.
+
+ }
+ onClick={() => {
+ openSidePanel(SidePanelTab.AccessControl)
+ closeShareModal()
+ }}
+ >
+ Open access control
+
+
+
+ >
+
+
Internal Link
+ {!isLocalOnly ? (
+ <>
+
+ Click the button below to copy a direct link to this Notebook. Make sure the person
+ you share it with has access to this PostHog project.
+
+
}
+ onClick={() => void copyToClipboard(notebookUrl, 'notebook link')}
+ title={notebookUrl}
+ >
+
{notebookUrl}
+
+
+
+ >
+ ) : (
+
+ This Notebook cannot be shared directly with others as it is only visible to you.
+
+ )}
+
+
Template Link
+
+ The link below will open a Canvas with the contents of this Notebook, allowing the receiver to view
+ it, edit it or create their own Notebook without affecting this one.
+
+
}
+ onClick={() => void copyToClipboard(canvasUrl, 'canvas link')}
+ title={canvasUrl}
+ >
+
{canvasUrl}
+
+
+
+
+
External Sharing
+
+
{
+ if (!interestTracked) {
+ trackInterest()
+ setInterestTracked(true)
+ }
+ },
+ }}
+ >
+ We donāt currently support sharing notebooks externally, but itās on our roadmap!
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json
index f2ac6bd3c8d16..4e31800d43919 100644
--- a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json
+++ b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-12345.json
@@ -59,5 +59,6 @@
"first_name": "Paul",
"email": "paul@posthog.com",
"is_email_verified": false
- }
+ },
+ "user_access_level": "editor"
}
diff --git a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
index bc0593c22bff3..68fc4d6e7f0f1 100644
--- a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
+++ b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts
@@ -133,8 +133,17 @@ export const notebookLogic = kea([
setContainerSize: (containerSize: 'small' | 'medium') => ({ containerSize }),
insertComment: (context: Record) => ({ context }),
selectComment: (itemContextId: string) => ({ itemContextId }),
+ openShareModal: true,
+ closeShareModal: true,
}),
reducers(({ props }) => ({
+ isShareModalOpen: [
+ false,
+ {
+ openShareModal: () => true,
+ closeShareModal: () => false,
+ },
+ ],
localContent: [
null as JSONContent | null,
{ persist: props.mode !== 'canvas', prefix: NOTEBOOKS_VERSION },
@@ -348,9 +357,9 @@ export const notebookLogic = kea([
mode: [() => [(_, props) => props], (props): NotebookLogicMode => props.mode ?? 'notebook'],
isTemplate: [(s) => [s.shortId], (shortId): boolean => shortId.startsWith('template-')],
isLocalOnly: [
- () => [(_, props) => props],
- (props): boolean => {
- return props.shortId === 'scratchpad' || props.mode === 'canvas'
+ (s) => [(_, props) => props, s.isTemplate],
+ (props, isTemplate): boolean => {
+ return props.shortId === 'scratchpad' || props.mode === 'canvas' || isTemplate
},
],
notebookMissing: [
@@ -443,8 +452,9 @@ export const notebookLogic = kea([
],
isEditable: [
- (s) => [s.shouldBeEditable, s.previewContent],
- (shouldBeEditable, previewContent) => shouldBeEditable && !previewContent,
+ (s) => [s.shouldBeEditable, s.previewContent, s.notebook],
+ (shouldBeEditable, previewContent, notebook) =>
+ shouldBeEditable && !previewContent && notebook?.user_access_level === 'editor',
],
}),
listeners(({ values, actions, cache }) => ({
@@ -518,6 +528,11 @@ export const notebookLogic = kea([
)
},
setLocalContent: async ({ updateEditor, jsonContent }, breakpoint) => {
+ if (values.notebook?.user_access_level !== 'editor') {
+ actions.clearLocalContent()
+ return
+ }
+
if (values.previewContent) {
// We don't want to modify the content if we are viewing a preview
return
diff --git a/frontend/src/scenes/notebooks/NotebookMenu.tsx b/frontend/src/scenes/notebooks/NotebookMenu.tsx
index 9cea5e74fbe37..aeeebfa35cdfa 100644
--- a/frontend/src/scenes/notebooks/NotebookMenu.tsx
+++ b/frontend/src/scenes/notebooks/NotebookMenu.tsx
@@ -10,10 +10,10 @@ import { urls } from 'scenes/urls'
import { notebooksModel } from '~/models/notebooksModel'
import { notebookLogic, NotebookLogicProps } from './Notebook/notebookLogic'
-import { openNotebookShareDialog } from './Notebook/NotebookShare'
export function NotebookMenu({ shortId }: NotebookLogicProps): JSX.Element {
const { notebook, showHistory, isLocalOnly } = useValues(notebookLogic({ shortId }))
+ const { openShareModal } = useActions(notebookLogic({ shortId }))
const { exportJSON, setShowHistory } = useActions(notebookLogic({ shortId }))
return (
@@ -32,14 +32,17 @@ export function NotebookMenu({ shortId }: NotebookLogicProps): JSX.Element {
{
label: 'Share',
icon: ,
- onClick: () => openNotebookShareDialog({ shortId }),
+ onClick: () => openShareModal(),
},
!isLocalOnly &&
!notebook?.is_template && {
label: 'Delete',
icon: ,
status: 'danger',
-
+ disabledReason:
+ notebook?.user_access_level !== 'editor'
+ ? 'You do not have permission to delete this notebook.'
+ : undefined,
onClick: () => {
notebooksModel.actions.deleteNotebook(shortId, notebook?.title)
router.actions.push(urls.notebooks())
diff --git a/frontend/src/scenes/notebooks/NotebookScene.tsx b/frontend/src/scenes/notebooks/NotebookScene.tsx
index e24c3bdd498c5..a0cc87a441c74 100644
--- a/frontend/src/scenes/notebooks/NotebookScene.tsx
+++ b/frontend/src/scenes/notebooks/NotebookScene.tsx
@@ -14,6 +14,7 @@ import { Notebook } from './Notebook/Notebook'
import { NotebookLoadingState } from './Notebook/NotebookLoadingState'
import { notebookLogic } from './Notebook/notebookLogic'
import { NotebookExpandButton, NotebookSyncInfo } from './Notebook/NotebookMeta'
+import { NotebookShareModal } from './Notebook/NotebookShareModal'
import { NotebookMenu } from './NotebookMenu'
import { notebookPanelLogic } from './NotebookPanel/notebookPanelLogic'
import { notebookSceneLogic, NotebookSceneLogicProps } from './notebookSceneLogic'
@@ -128,6 +129,7 @@ export function NotebookScene(): JSX.Element {
+
)
}
diff --git a/frontend/src/scenes/notebooks/notebookSceneLogic.ts b/frontend/src/scenes/notebooks/notebookSceneLogic.ts
index 592a1b39e09ed..6d987f3a780a4 100644
--- a/frontend/src/scenes/notebooks/notebookSceneLogic.ts
+++ b/frontend/src/scenes/notebooks/notebookSceneLogic.ts
@@ -2,8 +2,9 @@ import { afterMount, connect, kea, key, path, props, selectors } from 'kea'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { notebooksModel } from '~/models/notebooksModel'
-import { Breadcrumb } from '~/types'
+import { ActivityScope, Breadcrumb } from '~/types'
import { notebookLogic } from './Notebook/notebookLogic'
import type { notebookSceneLogicType } from './notebookSceneLogicType'
@@ -16,7 +17,12 @@ export const notebookSceneLogic = kea([
props({} as NotebookSceneLogicProps),
key(({ shortId }) => shortId),
connect((props: NotebookSceneLogicProps) => ({
- values: [notebookLogic(props), ['notebook', 'notebookLoading'], notebooksModel, ['notebooksLoading']],
+ values: [
+ notebookLogic(props),
+ ['notebook', 'notebookLoading', 'isLocalOnly'],
+ notebooksModel,
+ ['notebooksLoading'],
+ ],
actions: [notebookLogic(props), ['loadNotebook'], notebooksModel, ['createNotebook']],
})),
selectors(() => ({
@@ -41,6 +47,20 @@ export const notebookSceneLogic = kea([
},
],
],
+
+ [SIDE_PANEL_CONTEXT_KEY]: [
+ (s) => [s.notebookId, s.isLocalOnly],
+ (notebookId, isLocalOnly): SidePanelSceneContext | null => {
+ return notebookId && !isLocalOnly
+ ? {
+ activity_scope: ActivityScope.NOTEBOOK,
+ activity_item_id: notebookId,
+ access_control_resource: 'notebook',
+ access_control_resource_id: notebookId,
+ }
+ : null
+ },
+ ],
})),
afterMount(({ actions, props }) => {
diff --git a/frontend/src/scenes/persons-management/PersonsManagementScene.tsx b/frontend/src/scenes/persons-management/PersonsManagementScene.tsx
index 3300b21824954..099fef0f60217 100644
--- a/frontend/src/scenes/persons-management/PersonsManagementScene.tsx
+++ b/frontend/src/scenes/persons-management/PersonsManagementScene.tsx
@@ -21,9 +21,11 @@ export function PersonsManagementScene(): JSX.Element {
return (
<>
diff --git a/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx b/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx
index c96a9718057e5..32534ae82c10a 100644
--- a/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx
+++ b/frontend/src/scenes/persons-management/personsManagementSceneLogic.tsx
@@ -52,7 +52,7 @@ export const personsManagementSceneLogic = kea(
{
key: 'persons',
url: urls.persons(),
- label: 'People & groups',
+ label: 'Persons',
content: ,
},
{
diff --git a/frontend/src/scenes/persons/PersonsSearch.tsx b/frontend/src/scenes/persons/PersonsSearch.tsx
deleted file mode 100644
index f532154a9bb2c..0000000000000
--- a/frontend/src/scenes/persons/PersonsSearch.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { IconInfo } from '@posthog/icons'
-import { LemonInput } from '@posthog/lemon-ui'
-import { useActions, useValues } from 'kea'
-import { Tooltip } from 'lib/lemon-ui/Tooltip'
-import { useEffect, useState } from 'react'
-import { useDebouncedCallback } from 'use-debounce'
-
-import { personsLogic } from './personsLogic'
-
-export const PersonsSearch = (): JSX.Element => {
- const { loadPersons, setListFilters } = useActions(personsLogic)
- const { listFilters } = useValues(personsLogic)
- const [searchTerm, setSearchTerm] = useState('')
-
- const loadPersonsDebounced = useDebouncedCallback(loadPersons, 800)
-
- useEffect(() => {
- setSearchTerm(listFilters.search || '')
- }, [])
-
- useEffect(() => {
- setListFilters({ search: searchTerm || undefined })
- loadPersonsDebounced()
- }, [searchTerm])
-
- return (
-
-
-
- Search by email or Distinct ID. Email will match partially, for example: "@gmail.com". Distinct
- ID needs to match exactly.
- >
- }
- >
-
-
-
- )
-}
diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx
index d408ec3a74ed0..fcfb21200a7c4 100644
--- a/frontend/src/scenes/persons/personsLogic.tsx
+++ b/frontend/src/scenes/persons/personsLogic.tsx
@@ -13,7 +13,7 @@ import { Scene } from 'scenes/sceneTypes'
import { teamLogic } from 'scenes/teamLogic'
import { urls } from 'scenes/urls'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { hogqlQuery } from '~/queries/query'
import {
ActivityScope,
@@ -256,13 +256,13 @@ export const personsLogic = kea([
},
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
(s) => [s.person],
- (person): ActivityFilters => {
+ (person): SidePanelSceneContext => {
return {
- scope: ActivityScope.PERSON,
+ activity_scope: ActivityScope.PERSON,
// TODO: Is this correct? It doesn't seem to work...
- item_id: person?.id ? `${person?.id}` : undefined,
+ activity_item_id: person?.id ? `${person?.id}` : undefined,
}
},
],
diff --git a/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx b/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx
index 7ac6e5f9564c8..2cba3e1cf65b5 100644
--- a/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx
+++ b/frontend/src/scenes/pipeline/destinations/NewDestinations.tsx
@@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea'
import { PayGateButton } from 'lib/components/PayGateMini/PayGateButton'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
+import { userLogic } from 'scenes/userLogic'
import { AvailableFeature, HogFunctionTypeType, PipelineStage } from '~/types'
@@ -31,6 +32,7 @@ export function DestinationOptionsTable({ types }: NewDestinationsProps): JSX.El
const { loading, filteredDestinations, hiddenDestinations } = useValues(newDestinationsLogic({ types }))
const { canEnableDestination } = useValues(pipelineAccessLogic)
const { resetFilters } = useActions(destinationsFiltersLogic({ types }))
+ const { user } = useValues(userLogic)
return (
<>
@@ -75,15 +77,22 @@ export function DestinationOptionsTable({ types }: NewDestinationsProps): JSX.El
type="primary"
data-attr={`new-${PipelineStage.Destination}`}
icon={ }
- // Preserve hash params to pass config in
to={target.url}
- fullWidth
>
Create
) : (
-
+
+ {/* Allow staff users to create destinations */}
+ {user?.is_impersonated && (
+ }
+ tooltip="Staff users can create destinations as an override"
+ to={target.url}
+ />
+ )}
)
},
diff --git a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx
index 42f8112f99b6c..ea6bf3346687c 100644
--- a/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx
+++ b/frontend/src/scenes/pipeline/destinations/destinationsLogic.tsx
@@ -338,10 +338,12 @@ export const pipelineDestinationsLogic = kea([
},
})),
- afterMount(({ actions }) => {
+ afterMount(({ actions, props }) => {
actions.loadPlugins()
actions.loadPluginConfigs()
- actions.loadBatchExports()
+ if (props.types.includes('destination')) {
+ actions.loadBatchExports()
+ }
actions.loadHogFunctions()
}),
])
diff --git a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx
index e4b6bd8db6c24..285665e5aef79 100644
--- a/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx
+++ b/frontend/src/scenes/pipeline/destinations/newDestinationsLogic.tsx
@@ -63,7 +63,7 @@ export const newDestinationsLogic = kea([
const destinationTypes = siteDesinationsEnabled
? props.types
: props.types.filter((type) => type !== 'site_destination')
- const templates = await api.hogFunctions.listTemplates(destinationTypes)
+ const templates = await api.hogFunctions.listTemplates({ types: destinationTypes })
return templates.results.reduce((acc, template) => {
acc[template.id] = template
return acc
diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx
index cfa5dc06d463c..c94ac3d6681f5 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionConfiguration.tsx
@@ -6,7 +6,6 @@ import {
LemonDropdown,
LemonInput,
LemonLabel,
- LemonSelect,
LemonSwitch,
LemonTag,
LemonTextArea,
@@ -42,9 +41,23 @@ const EVENT_THRESHOLD_ALERT_LEVEL = 8000
export interface HogFunctionConfigurationProps {
templateId?: string | null
id?: string | null
+
+ displayOptions?: {
+ showFilters?: boolean
+ showExpectedVolume?: boolean
+ showStatus?: boolean
+ showEnabled?: boolean
+ showTesting?: boolean
+ canEditSource?: boolean
+ showPersonsCount?: boolean
+ }
}
-export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigurationProps): JSX.Element {
+export function HogFunctionConfiguration({
+ templateId,
+ id,
+ displayOptions = {},
+}: HogFunctionConfigurationProps): JSX.Element {
const logicProps = { templateId, id }
const logic = hogFunctionConfigurationLogic(logicProps)
const {
@@ -66,9 +79,7 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
personsCountLoading,
personsListQuery,
template,
- subTemplate,
templateHasChanged,
- forcedSubTemplateId,
type,
} = useValues(logic)
const {
@@ -80,7 +91,6 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
duplicateFromTemplate,
setConfigurationValue,
deleteHogFunction,
- setSubTemplateId,
} = useActions(logic)
if (loading && !loaded) {
@@ -152,13 +162,24 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
return
}
- const showFilters = ['destination', 'site_destination', 'broadcast', 'transformation'].includes(type)
- const showExpectedVolume = ['destination', 'site_destination'].includes(type)
- const showStatus = ['destination', 'email', 'transformation'].includes(type)
- const showEnabled = ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type)
- const canEditSource = ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type)
- const showPersonsCount = ['broadcast'].includes(type)
- const showTesting = ['destination', 'transformation', 'broadcast', 'email'].includes(type)
+ const showFilters =
+ displayOptions.showFilters ??
+ ['destination', 'internal_destination', 'site_destination', 'broadcast', 'transformation'].includes(type)
+ const showExpectedVolume = displayOptions.showExpectedVolume ?? ['destination', 'site_destination'].includes(type)
+ const showStatus =
+ displayOptions.showStatus ?? ['destination', 'internal_destination', 'email', 'transformation'].includes(type)
+ const showEnabled =
+ displayOptions.showEnabled ??
+ ['destination', 'internal_destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(
+ type
+ )
+ const canEditSource =
+ displayOptions.canEditSource ??
+ ['destination', 'email', 'site_destination', 'site_app', 'transformation'].includes(type)
+ const showPersonsCount = displayOptions.showPersonsCount ?? ['broadcast'].includes(type)
+ const showTesting =
+ displayOptions.showTesting ??
+ ['destination', 'internal_destination', 'transformation', 'broadcast', 'email'].includes(type)
return (
@@ -359,41 +380,6 @@ export function HogFunctionConfiguration({ templateId, id }: HogFunctionConfigur
- {!forcedSubTemplateId && template?.sub_templates && (
- <>
-
-
-
Choose template
-
({
- value: subTemplate.id,
- label: subTemplate.name,
- labelInMenu: (
-
-
{subTemplate.name}
-
- {subTemplate.description}
-
-
- ),
- })),
- ]}
- value={subTemplate?.id}
- onChange={(value) => {
- setSubTemplateId(value)
- }}
- />
-
-
- >
- )}
-
@@ -75,7 +99,10 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {
>
-
Testing
+
+ Testing
+ {sampleGlobalsLoading ? : null}
+
{!expanded &&
(type === 'email' ? (
Click here to test the provider with a sample e-mail
@@ -87,7 +114,7 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {
{!expanded ? (
-
toggleExpanded()}>
+ toggleExpanded()}>
Start testing
) : (
@@ -97,46 +124,100 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {
type="primary"
onClick={() => setTestResult(null)}
loading={isTestInvocationSubmitting}
+ data-attr="clear-hog-test-result"
>
Clear test result
) : (
<>
-
- Refresh globals
-
-
- {({ value, onChange }) => (
-
- When selected, async functions such as `fetch` will not
- actually be called but instead will be mocked out with
- the fetch content logged instead
- >
- }
+
+
+ {({ value, onChange }) => (
+ onChange(!v)}
+ checked={!value}
+ data-attr="toggle-hog-test-mocking"
+ className="px-2 py-1"
+ label={
+
+ When disabled, async functions such as
+ `fetch` will not be called. Instead they
+ will be mocked out and logged.
+ >
+ }
+ >
+
+ Make real HTTP requests
+
+
+
+ }
+ />
+ )}
+
+
+
+ Fetch new event
+
+
+ {savedGlobals.map(({ name, globals }, index) => (
+
+ setSampleGlobals(globals)}
+ fullWidth
+ className="flex-1"
+ >
+ {name}
+
+ }
+ onClick={() => deleteSavedGlobals(index)}
+ tooltip="Delete saved test data"
+ />
+
+ ))}
+ {testInvocation.globals && (
+ {
+ const name = prompt('Name this test data')
+ if (name) {
+ saveGlobals(name, JSON.parse(testInvocation.globals))
+ }
+ }}
+ disabledReason={(() => {
+ try {
+ JSON.parse(testInvocation.globals)
+ } catch (e) {
+ return 'Invalid globals JSON'
+ }
+ return undefined
+ })()}
>
-
- Mock out HTTP requests
-
-
-
- }
- />
- )}
-
+ Save test data
+
+ )}
+ >
+ }
+ />
@@ -145,7 +226,12 @@ export function HogFunctionTest(props: HogFunctionTestLogicProps): JSX.Element {
>
)}
- } onClick={() => toggleExpanded()} tooltip="Hide testing" />
+ }
+ onClick={() => toggleExpanded()}
+ tooltip="Hide testing"
+ />
>
)}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx
index 2c29f63794a3e..ef1098304e101 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFilters.tsx
@@ -13,6 +13,7 @@ import { NodeKind } from '~/queries/schema'
import { AnyPropertyFilter, EntityTypes, FilterType, HogFunctionFiltersType } from '~/types'
import { hogFunctionConfigurationLogic } from '../hogFunctionConfigurationLogic'
+import { HogFunctionFiltersInternal } from './HogFunctionFiltersInternal'
function sanitizeActionFilters(filters?: FilterType): Partial {
if (!filters) {
@@ -74,6 +75,10 @@ export function HogFunctionFilters(): JSX.Element {
)
}
+ if (type === 'internal_destination') {
+ return
+ }
+
const showMasking = type === 'destination'
const showDropEvents = type === 'transformation'
diff --git a/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx
new file mode 100644
index 0000000000000..7195bc320cf40
--- /dev/null
+++ b/frontend/src/scenes/pipeline/hogfunctions/filters/HogFunctionFiltersInternal.tsx
@@ -0,0 +1,49 @@
+import { LemonSelect } from '@posthog/lemon-ui'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+
+import { HogFunctionFiltersType } from '~/types'
+
+// NOTE: This is all a bit WIP and will be improved upon over time
+// TODO: Make this more advanced with sub type filtering etc.
+// TODO: Make it possible for the renderer to limit the options based on the type
+const FILTER_OPTIONS = [
+ {
+ label: 'Team activity',
+ value: '$activity_log_entry_created',
+ },
+]
+
+const getSimpleFilterValue = (value?: HogFunctionFiltersType): string | undefined => {
+ return value?.events?.[0]?.id
+}
+
+const setSimpleFilterValue = (value: string): HogFunctionFiltersType => {
+ return {
+ events: [
+ {
+ name: FILTER_OPTIONS.find((option) => option.value === value)?.label,
+ id: value,
+ type: 'events',
+ },
+ ],
+ }
+}
+
+export function HogFunctionFiltersInternal(): JSX.Element {
+ return (
+
+
+ {({ value, onChange }) => (
+ <>
+ onChange(setSimpleFilterValue(value))}
+ placeholder="Select a filter"
+ />
+ >
+ )}
+
+
+ )
+}
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts
index 3c9bef43c45d8..0f93034551c59 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts
+++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.test.ts
@@ -23,7 +23,7 @@ import { hogFunctionConfigurationLogic } from './hogFunctionConfigurationLogic'
const HOG_TEMPLATE: HogFunctionTemplateType = {
sub_templates: [
{
- id: 'early_access_feature_enrollment',
+ id: 'early-access-feature-enrollment',
name: 'HTTP Webhook on feature enrollment',
description: null,
filters: {
@@ -38,7 +38,7 @@ const HOG_TEMPLATE: HogFunctionTemplateType = {
inputs: null,
},
{
- id: 'survey_response',
+ id: 'survey-response',
name: 'HTTP Webhook on survey response',
description: null,
filters: {
diff --git a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
index d6f7c98884a7c..f7312f19e8640 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/hogFunctionConfigurationLogic.tsx
@@ -1,6 +1,6 @@
import { lemonToast } from '@posthog/lemon-ui'
import equal from 'fast-deep-equal'
-import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { actions, afterMount, connect, isBreakpoint, kea, key, listeners, path, props, reducers, selectors } from 'kea'
import { forms } from 'kea-forms'
import { loaders } from 'kea-loaders'
import { beforeUnload, router } from 'kea-router'
@@ -33,8 +33,6 @@ import {
HogFunctionInputType,
HogFunctionInvocationGlobals,
HogFunctionMappingType,
- HogFunctionSubTemplateIdType,
- HogFunctionSubTemplateType,
HogFunctionTemplateType,
HogFunctionType,
HogFunctionTypeType,
@@ -49,7 +47,6 @@ import type { hogFunctionConfigurationLogicType } from './hogFunctionConfigurati
export interface HogFunctionConfigurationLogicProps {
templateId?: string | null
- subTemplateId?: string | null
id?: string | null
}
@@ -116,19 +113,11 @@ export function sanitizeConfiguration(data: HogFunctionConfigurationType): HogFu
return payload
}
-const templateToConfiguration = (
- template: HogFunctionTemplateType,
- subTemplate?: HogFunctionSubTemplateType | null
-): HogFunctionConfigurationType => {
- function getInputs(
- inputs_schema?: HogFunctionInputSchemaType[] | null,
- subTemplate?: HogFunctionSubTemplateType | null
- ): Record {
+const templateToConfiguration = (template: HogFunctionTemplateType): HogFunctionConfigurationType => {
+ function getInputs(inputs_schema?: HogFunctionInputSchemaType[] | null): Record {
const inputs: Record = {}
inputs_schema?.forEach((schema) => {
- if (typeof subTemplate?.inputs?.[schema.key] !== 'undefined') {
- inputs[schema.key] = { value: subTemplate.inputs[schema.key] }
- } else if (schema.default !== undefined) {
+ if (schema.default !== undefined) {
inputs[schema.key] = { value: schema.default }
}
})
@@ -149,11 +138,11 @@ const templateToConfiguration = (
return {
type: template.type ?? 'destination',
- name: subTemplate?.name ?? template.name,
- description: subTemplate?.name ?? template.description,
+ name: template.name,
+ description: template.description,
inputs_schema: template.inputs_schema,
- filters: subTemplate?.filters ?? template.filters,
- mappings: (subTemplate?.mappings ?? template.mappings)?.map(
+ filters: template.filters,
+ mappings: template.mappings?.map(
(mapping): HogFunctionMappingType => ({
...mapping,
inputs: getMappingInputs(mapping.inputs_schema),
@@ -161,7 +150,7 @@ const templateToConfiguration = (
),
hog: template.hog,
icon_url: template.icon_url,
- inputs: getInputs(template.inputs_schema, subTemplate),
+ inputs: getInputs(template.inputs_schema),
enabled: template.type !== 'broadcast',
}
}
@@ -226,13 +215,19 @@ export const hogFunctionConfigurationLogic = kea ({ sparklineQuery } as { sparklineQuery: TrendsQuery }),
personsCountQueryChanged: (personsCountQuery: ActorsQuery) =>
({ personsCountQuery } as { personsCountQuery: ActorsQuery }),
- setSubTemplateId: (subTemplateId: HogFunctionSubTemplateIdType | null) => ({ subTemplateId }),
loadSampleGlobals: true,
setUnsavedConfiguration: (configuration: HogFunctionConfigurationType | null) => ({ configuration }),
persistForUnload: true,
setSampleGlobalsError: (error) => ({ error }),
+ setSampleGlobals: (sampleGlobals: HogFunctionInvocationGlobals | null) => ({ sampleGlobals }),
}),
reducers(({ props }) => ({
+ sampleGlobals: [
+ null as HogFunctionInvocationGlobals | null,
+ {
+ setSampleGlobals: (_, { sampleGlobals }) => sampleGlobals,
+ },
+ ],
showSource: [
// Show source by default for blank templates when creating a new function
!!(!props.id && props.templateId?.startsWith('template-blank-')),
@@ -247,12 +242,6 @@ export const hogFunctionConfigurationLogic = kea true,
},
],
- subTemplateId: [
- null as HogFunctionSubTemplateIdType | null,
- {
- setSubTemplateId: (_, { subTemplateId }) => subTemplateId,
- },
- ],
unsavedConfiguration: [
null as { timestamp: number; configuration: HogFunctionConfigurationType } | null,
@@ -440,7 +429,9 @@ export const hogFunctionConfigurationLogic = kea (hogFunction ?? template)?.type === 'site_destination',
],
defaultFormState: [
- (s) => [s.template, s.hogFunction, s.subTemplate],
- (template, hogFunction, subTemplate): HogFunctionConfigurationType | null => {
+ (s) => [s.template, s.hogFunction],
+ (template, hogFunction): HogFunctionConfigurationType | null => {
if (template) {
- return templateToConfiguration(template, subTemplate)
+ return templateToConfiguration(template)
}
return hogFunction ?? null
},
@@ -834,18 +829,6 @@ export const hogFunctionConfigurationLogic = kea [s.template, s.subTemplateId],
- (template, subTemplateId) => {
- if (!template || !subTemplateId) {
- return null
- }
-
- const subTemplate = template.sub_templates?.find((st) => st.id === subTemplateId)
- return subTemplate
- },
- ],
- forcedSubTemplateId: [() => [router.selectors.searchParams], ({ sub_template }) => !!sub_template],
mappingTemplates: [
(s) => [s.hogFunction, s.template],
(hogFunction, template) => template?.mapping_templates ?? hogFunction?.template?.mapping_templates ?? [],
@@ -957,7 +940,7 @@ export const hogFunctionConfigurationLogic = kea {
const template = values.hogFunction?.template ?? values.template
if (template) {
- const config = templateToConfiguration(template, values.subTemplate)
+ const config = templateToConfiguration(template)
const inputs = config.inputs ?? {}
@@ -1005,10 +988,6 @@ export const hogFunctionConfigurationLogic = kea {
- actions.resetToTemplate()
- },
-
persistForUnload: () => {
actions.setUnsavedConfiguration(values.configuration)
},
@@ -1021,9 +1000,6 @@ export const hogFunctionConfigurationLogic = kea([
],
actions: [
hogFunctionConfigurationLogic({ id: props.id }),
- ['touchConfigurationField', 'loadSampleGlobalsSuccess', 'loadSampleGlobals'],
+ ['touchConfigurationField', 'loadSampleGlobalsSuccess', 'loadSampleGlobals', 'setSampleGlobals'],
],
})),
actions({
setTestResult: (result: HogFunctionTestInvocationResult | null) => ({ result }),
toggleExpanded: (expanded?: boolean) => ({ expanded }),
+ saveGlobals: (name: string, globals: HogFunctionInvocationGlobals) => ({ name, globals }),
+ deleteSavedGlobals: (index: number) => ({ index }),
}),
reducers({
expanded: [
false as boolean,
{
- toggleExpanded: (_, { expanded }) => (expanded === undefined ? !_ : expanded),
+ toggleExpanded: (state, { expanded }) => (expanded === undefined ? !state : expanded),
},
],
@@ -66,12 +69,25 @@ export const hogFunctionTestLogic = kea([
setTestResult: (_, { result }) => result,
},
],
+
+ savedGlobals: [
+ [] as { name: string; globals: HogFunctionInvocationGlobals }[],
+ { persist: true, prefix: `${getCurrentTeamId()}__` },
+ {
+ saveGlobals: (state, { name, globals }) => [...state, { name, globals }],
+ deleteSavedGlobals: (state, { index }) => state.filter((_, i) => i !== index),
+ },
+ ],
}),
listeners(({ values, actions }) => ({
loadSampleGlobalsSuccess: () => {
actions.setTestInvocationValue('globals', JSON.stringify(values.sampleGlobals, null, 2))
},
+ setSampleGlobals: ({ sampleGlobals }) => {
+ actions.setTestInvocationValue('globals', JSON.stringify(sampleGlobals, null, 2))
+ },
})),
+
forms(({ props, actions, values }) => ({
testInvocation: {
defaults: {
diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx
index d86cd44b5634e..7f9fd156672ac 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/list/HogFunctionTemplateList.tsx
@@ -20,11 +20,11 @@ export function HogFunctionTemplateList({
)
const { loadHogFunctionTemplates, setFilters, resetFilters } = useActions(hogFunctionTemplateListLogic(props))
- useEffect(() => loadHogFunctionTemplates(), [])
+ useEffect(() => loadHogFunctionTemplates(), [props.type, props.subTemplateId])
return (
<>
-
+
{!props.forceFilters?.search && (
setShowNewDestination(false)}>
diff --git a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx
index c3397c10d4c50..e8f71ecca6454 100644
--- a/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx
+++ b/frontend/src/scenes/pipeline/hogfunctions/list/hogFunctionTemplateListLogic.tsx
@@ -8,7 +8,7 @@ import { objectsEqual } from 'lib/utils'
import { hogFunctionNewUrl } from 'scenes/pipeline/hogfunctions/urls'
import { pipelineAccessLogic } from 'scenes/pipeline/pipelineAccessLogic'
-import { HogFunctionTemplateType, HogFunctionTypeType } from '~/types'
+import { HogFunctionSubTemplateIdType, HogFunctionTemplateType, HogFunctionTypeType } from '~/types'
import type { hogFunctionTemplateListLogicType } from './hogFunctionTemplateListLogicType'
@@ -18,11 +18,11 @@ export interface Fuse extends FuseClass {}
export type HogFunctionTemplateListFilters = {
search?: string
filters?: Record
- subTemplateId?: string
}
export type HogFunctionTemplateListLogicProps = {
type: HogFunctionTypeType
+ subTemplateId?: HogFunctionSubTemplateIdType
defaultFilters?: HogFunctionTemplateListFilters
forceFilters?: HogFunctionTemplateListFilters
syncFiltersWithUrl?: boolean
@@ -30,7 +30,12 @@ export type HogFunctionTemplateListLogicProps = {
export const hogFunctionTemplateListLogic = kea([
props({} as HogFunctionTemplateListLogicProps),
- key((props) => `${props.syncFiltersWithUrl ? 'scene' : 'default'}/${props.type ?? 'destination'}`),
+ key(
+ (props) =>
+ `${props.syncFiltersWithUrl ? 'scene' : 'default'}/${props.type ?? 'destination'}/${
+ props.subTemplateId ?? ''
+ }`
+ ),
path((id) => ['scenes', 'pipeline', 'destinationsLogic', id]),
connect({
values: [pipelineAccessLogic, ['canEnableNewDestinations'], featureFlagLogic, ['featureFlags']],
@@ -55,41 +60,22 @@ export const hogFunctionTemplateListLogic = kea ({
- rawTemplates: [
+ templates: [
[] as HogFunctionTemplateType[],
{
loadHogFunctionTemplates: async () => {
- return (await api.hogFunctions.listTemplates(props.type)).results
+ return (
+ await api.hogFunctions.listTemplates({
+ types: [props.type],
+ sub_template_id: props.subTemplateId,
+ })
+ ).results
},
},
],
})),
selectors({
- loading: [(s) => [s.rawTemplatesLoading], (x) => x],
- templates: [
- (s) => [s.rawTemplates, s.filters],
- (rawTemplates, { subTemplateId }): HogFunctionTemplateType[] => {
- if (!subTemplateId) {
- return rawTemplates
- }
- const templates: HogFunctionTemplateType[] = []
- // We want to pull out the sub templates and return the template but with overrides applied
-
- rawTemplates.forEach((template) => {
- const subTemplate = template.sub_templates?.find((subTemplate) => subTemplate.id === subTemplateId)
-
- if (subTemplate) {
- templates.push({
- ...template,
- name: subTemplate.name,
- description: subTemplate.description ?? template.description,
- })
- }
- })
-
- return templates
- },
- ],
+ loading: [(s) => [s.templatesLoading], (x) => x],
templatesFuse: [
(s) => [s.templates],
(hogFunctionTemplates): Fuse => {
@@ -123,13 +109,9 @@ export const hogFunctionTemplateListLogic = kea string) => {
return (template: HogFunctionTemplateType) => {
// Add the filters to the url and the template id
- const subTemplateId = filters.subTemplateId
-
return combineUrl(
hogFunctionNewUrl(template.type, template.id),
- {
- sub_template: subTemplateId,
- },
+ {},
{
configuration: {
filters: filters.filters,
diff --git a/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx b/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx
index d2db4bc584484..3b1930b176472 100644
--- a/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx
+++ b/frontend/src/scenes/pipeline/pipelineBatchExportConfigurationLogic.tsx
@@ -215,7 +215,7 @@ export const pipelineBatchExportConfigurationLogic = kea) => ({ configuration }),
setSelectedModel: (model: string) => ({ model }),
}),
- loaders(({ props, values, actions }) => ({
+ loaders(({ props, actions }) => ({
batchExportConfig: [
null as BatchExportConfiguration | null,
{
@@ -226,13 +226,6 @@ export const pipelineBatchExportConfigurationLogic = kea {
- if (
- (!values.batchExportConfig || (values.batchExportConfig.paused && formdata.paused !== true)) &&
- !values.canEnableNewDestinations
- ) {
- lemonToast.error('Data pipelines add-on is required for enabling new destinations.')
- return null
- }
const { name, destination, interval, paused, created_at, start_at, end_at, model, ...config } =
formdata
const destinationObj = {
diff --git a/frontend/src/scenes/pipeline/pipelineLogic.tsx b/frontend/src/scenes/pipeline/pipelineLogic.tsx
index 38ea5f1d54b4f..23438fbe86185 100644
--- a/frontend/src/scenes/pipeline/pipelineLogic.tsx
+++ b/frontend/src/scenes/pipeline/pipelineLogic.tsx
@@ -5,7 +5,7 @@ import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { ActivityScope, Breadcrumb, PipelineTab } from '~/types'
import type { pipelineLogicType } from './pipelineLogicType'
@@ -44,11 +44,11 @@ export const pipelineLogic = kea([
},
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
() => [],
- (): ActivityFilters | null => {
+ (): SidePanelSceneContext => {
return {
- scope: ActivityScope.PLUGIN,
+ activity_scope: ActivityScope.PLUGIN,
}
},
],
diff --git a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
index 4faedce085b8a..2d2e7b977aec5 100644
--- a/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
+++ b/frontend/src/scenes/pipeline/pipelineNodeLogic.tsx
@@ -4,7 +4,7 @@ import { capitalizeFirstLetter } from 'lib/utils'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { ActivityScope, Breadcrumb, PipelineNodeTab, PipelineStage } from '~/types'
import type { pipelineNodeLogicType } from './pipelineNodeLogicType'
@@ -78,13 +78,15 @@ export const pipelineNodeLogic = kea([
],
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
(s) => [s.node],
- (node): ActivityFilters | null => {
+ (node): SidePanelSceneContext | null => {
return node.backend === PipelineBackend.Plugin
? {
- scope: ActivityScope.PLUGIN,
- item_id: `${node.id}`,
+ activity_scope: ActivityScope.PLUGIN,
+ activity_item_id: `${node.id}`,
+ // access_control_resource: 'plugin',
+ // access_control_resource_id: `${node.id}`,
}
: null
},
diff --git a/frontend/src/scenes/retention/RetentionModal.tsx b/frontend/src/scenes/retention/RetentionModal.tsx
index bc9488889b5ba..ce427b799c671 100644
--- a/frontend/src/scenes/retention/RetentionModal.tsx
+++ b/frontend/src/scenes/retention/RetentionModal.tsx
@@ -148,7 +148,7 @@ export function RetentionModal(): JSX.Element | null {
diff --git a/frontend/src/scenes/retention/RetentionTable.tsx b/frontend/src/scenes/retention/RetentionTable.tsx
index 4d1aa0b1fb509..278434a1a813e 100644
--- a/frontend/src/scenes/retention/RetentionTable.tsx
+++ b/frontend/src/scenes/retention/RetentionTable.tsx
@@ -3,9 +3,8 @@ import './RetentionTable.scss'
import clsx from 'clsx'
import { mean } from 'd3'
import { useActions, useValues } from 'kea'
-import { BRAND_BLUE_HSL, gradateColor, PURPLE } from 'lib/colors'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
-import { range } from 'lib/utils'
+import { BRAND_BLUE_HSL, gradateColor, PURPLE, range } from 'lib/utils'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
diff --git a/frontend/src/scenes/saved-insights/ReloadInsight.tsx b/frontend/src/scenes/saved-insights/ReloadInsight.tsx
new file mode 100644
index 0000000000000..66a258eabdc3c
--- /dev/null
+++ b/frontend/src/scenes/saved-insights/ReloadInsight.tsx
@@ -0,0 +1,31 @@
+import { Link } from '@posthog/lemon-ui'
+import { useValues } from 'kea'
+import { parseDraftQueryFromLocalStorage } from 'scenes/insights/utils'
+import { teamLogic } from 'scenes/teamLogic'
+import { urls } from 'scenes/urls'
+
+import { Node } from '~/queries/schema'
+
+export function ReloadInsight(): JSX.Element {
+ const { currentTeamId } = useValues(teamLogic)
+ const draftQueryLocalStorage = localStorage.getItem(`draft-query-${currentTeamId}`)
+ let draftQuery: { query: Node>; timestamp: number } | null = null
+ if (draftQueryLocalStorage) {
+ const parsedQuery = parseDraftQueryFromLocalStorage(draftQueryLocalStorage)
+ if (parsedQuery) {
+ draftQuery = parsedQuery
+ } else {
+ localStorage.removeItem(`draft-query-${currentTeamId}`)
+ }
+ }
+
+ if (!draftQuery?.query) {
+ return <> >
+ }
+ return (
+
+ You have an unsaved insight from {new Date(draftQuery.timestamp).toLocaleString()}.{' '}
+ Click here to view it.
+
+ )
+}
diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx
index 05f4c5c131668..bd155048b0490 100644
--- a/frontend/src/scenes/saved-insights/SavedInsights.tsx
+++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx
@@ -57,6 +57,7 @@ import { NodeKind } from '~/queries/schema'
import { isNodeWithSource } from '~/queries/utils'
import { ActivityScope, InsightType, LayoutView, QueryBasedInsightModel, SavedInsightsTabs } from '~/types'
+import { ReloadInsight } from './ReloadInsight'
import { INSIGHTS_PER_PAGE, savedInsightsLogic } from './savedInsightsLogic'
interface NewInsightButtonProps {
@@ -671,6 +672,7 @@ export function SavedInsights(): JSX.Element {
) : (
<>
+
{layoutView === LayoutView.List ? (
= {
defaultDocsPath: '/docs/experiments/creating-an-experiment',
activityScope: ActivityScope.EXPERIMENT,
},
+ [Scene.ExperimentsSavedMetric]: {
+ projectBased: true,
+ name: 'Saved metric',
+ defaultDocsPath: '/docs/experiments/creating-an-experiment',
+ activityScope: ActivityScope.EXPERIMENT,
+ },
+ [Scene.ExperimentsSavedMetrics]: {
+ projectBased: true,
+ name: 'Saved metrics',
+ defaultDocsPath: '/docs/experiments/creating-an-experiment',
+ activityScope: ActivityScope.EXPERIMENT,
+ },
[Scene.FeatureFlags]: {
projectBased: true,
name: 'Feature flags',
@@ -236,12 +248,6 @@ export const sceneConfigurations: Record = {
name: 'New survey',
defaultDocsPath: '/docs/surveys/creating-surveys',
},
- [Scene.DataModel]: {
- projectBased: true,
- name: 'Visualize person schema',
- defaultDocsPath: '/docs/data-datawarehouse',
- layout: 'app-canvas',
- },
[Scene.DataWarehouse]: {
projectBased: true,
name: 'Data warehouse',
@@ -501,6 +507,7 @@ export const redirects: Record<
'/apps': urls.pipeline(PipelineTab.Overview),
'/apps/:id': ({ id }) => urls.pipelineNode(PipelineStage.Transformation, id),
'/messaging': urls.messagingBroadcasts(),
+ '/settings/organization-rbac': urls.settings('organization-roles'),
}
export const routes: Record = {
@@ -559,6 +566,8 @@ export const routes: Record = {
[urls.cohort(':id')]: Scene.Cohort,
[urls.cohorts()]: Scene.PersonsManagement,
[urls.experiments()]: Scene.Experiments,
+ [urls.experimentsSavedMetrics()]: Scene.ExperimentsSavedMetrics,
+ [urls.experimentsSavedMetric(':id')]: Scene.ExperimentsSavedMetric,
[urls.experiment(':id')]: Scene.Experiment,
[urls.earlyAccessFeatures()]: Scene.EarlyAccessFeatures,
[urls.earlyAccessFeature(':id')]: Scene.EarlyAccessFeature,
@@ -568,7 +577,6 @@ export const routes: Record = {
[urls.surveys()]: Scene.Surveys,
[urls.survey(':id')]: Scene.Survey,
[urls.surveyTemplates()]: Scene.SurveyTemplates,
- [urls.dataModel()]: Scene.DataModel,
[urls.dataWarehouse()]: Scene.DataWarehouse,
[urls.dataWarehouseView(':id')]: Scene.DataWarehouse,
[urls.dataWarehouseTable()]: Scene.DataWarehouseTable,
diff --git a/frontend/src/scenes/session-recordings/player/PlayerMeta.tsx b/frontend/src/scenes/session-recordings/player/PlayerMeta.tsx
index 6bac98a9fc381..6e4e69a038f79 100644
--- a/frontend/src/scenes/session-recordings/player/PlayerMeta.tsx
+++ b/frontend/src/scenes/session-recordings/player/PlayerMeta.tsx
@@ -1,13 +1,13 @@
import './PlayerMeta.scss'
-import { LemonBanner, LemonSelect, LemonSelectOption, Link } from '@posthog/lemon-ui'
+import { LemonSelect, LemonSelectOption, Link } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver'
import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
-import { percentage } from 'lib/utils'
+import { isObject, percentage } from 'lib/utils'
import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook'
import { IconWindow } from 'scenes/session-recordings/player/icons'
import { PlayerMetaLinks } from 'scenes/session-recordings/player/PlayerMetaLinks'
@@ -20,6 +20,12 @@ import { Logo } from '~/toolbar/assets/Logo'
import { sessionRecordingPlayerLogic, SessionRecordingPlayerMode } from './sessionRecordingPlayerLogic'
function URLOrScreen({ lastUrl }: { lastUrl: string | undefined }): JSX.Element | null {
+ if (isObject(lastUrl) && 'href' in lastUrl) {
+ // regression protection, we saw a user whose site was sometimes sending the string-ified location object
+ // this is a best-effort attempt to show the href in that case
+ lastUrl = lastUrl['href'] as string | undefined
+ }
+
if (!lastUrl) {
return null
}
@@ -59,26 +65,6 @@ function URLOrScreen({ lastUrl }: { lastUrl: string | undefined }): JSX.Element
)
}
-function PlayerWarningsRow(): JSX.Element | null {
- const { messageTooLargeWarnings } = useValues(sessionRecordingPlayerLogic)
-
- return messageTooLargeWarnings.length ? (
-
-
- This session recording had recording data that was too large and could not be captured. This will mean
- playback is not 100% accurate.{' '}
-
-
- ) : null
-}
-
export function PlayerMeta({ iconsOnly }: { iconsOnly: boolean }): JSX.Element {
const { logicProps, isFullScreen } = useValues(sessionRecordingPlayerLogic)
@@ -206,7 +192,6 @@ export function PlayerMeta({ iconsOnly }: { iconsOnly: boolean }): JSX.Element {
{resolutionView}
-
)
diff --git a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx
index 69f3541aa3ed6..42b8b7d317768 100644
--- a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx
+++ b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx
@@ -57,6 +57,7 @@ function PinToPlaylistButton({
/>
) : (
: }
{...buttonProps}
@@ -135,7 +136,7 @@ export function PlayerMetaLinks({ iconsOnly }: { iconsOnly: boolean }): JSX.Elem
{buttonContent('Comment')}
- } onClick={onShare} {...commonProps}>
+ } onClick={onShare} {...commonProps} tooltip="Share this recording">
{buttonContent('Share')}
@@ -149,6 +150,7 @@ export function PlayerMetaLinks({ iconsOnly }: { iconsOnly: boolean }): JSX.Elem
attrs: { id: sessionRecordingId },
})
}}
+ tooltip="Comment in a notebook"
/>
) : null}
diff --git a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx
index 6b28feca120ae..d70a3267a2532 100644
--- a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx
+++ b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx
@@ -4,7 +4,6 @@ import { LemonButton } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { BindLogic, useActions, useValues } from 'kea'
import { BuilderHog2 } from 'lib/components/hedgehogs'
-import { dayjs } from 'lib/dayjs'
import { FloatingContainerContext } from 'lib/hooks/useFloatingContainerContext'
import { HotkeysInterface, useKeyboardHotkeys } from 'lib/hooks/useKeyboardHotkeys'
import { usePageVisibility } from 'lib/hooks/usePageVisibility'
@@ -87,11 +86,9 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
setSpeed,
closeExplorer,
} = useActions(sessionRecordingPlayerLogic(logicProps))
- const { isNotFound, snapshotsInvalid, start } = useValues(sessionRecordingDataLogic(logicProps))
+ const { isNotFound, isRecentAndInvalid } = useValues(sessionRecordingDataLogic(logicProps))
const { loadSnapshots } = useActions(sessionRecordingDataLogic(logicProps))
- const { isFullScreen, explorerMode, isBuffering, messageTooLargeWarnings } = useValues(
- sessionRecordingPlayerLogic(logicProps)
- )
+ const { isFullScreen, explorerMode, isBuffering } = useValues(sessionRecordingPlayerLogic(logicProps))
const { setPlayNextAnimationInterrupted } = useActions(sessionRecordingPlayerLogic(logicProps))
const speedHotkeys = useMemo(() => createPlaybackSpeedKey(setSpeed), [setSpeed])
const { isVerticallyStacked, sidebarOpen, playbackMode } = useValues(playerSettingsLogic)
@@ -158,9 +155,6 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
}
)
- const lessThanFiveMinutesOld = dayjs().diff(start, 'minute') <= 5
- const cannotPlayback = snapshotsInvalid && lessThanFiveMinutesOld && !messageTooLargeWarnings
-
const { draggable, elementProps } = useNotebookDrag({ href: urls.replaySingle(sessionRecordingId) })
if (isNotFound) {
@@ -198,7 +192,7 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX.
className="SessionRecordingPlayer__main flex flex-col h-full w-full"
ref={playerMainRef}
>
- {cannotPlayback ? (
+ {isRecentAndInvalid ? (
We're still working on it
diff --git a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx
index 0498fb9efe05f..f539c787f7209 100644
--- a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx
+++ b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx
@@ -82,7 +82,7 @@ function ShowMouseTail(): JSX.Element {
return (
setTimestampFormat(TimestampFormat.UTC),
+ active: timestampFormat === TimestampFormat.UTC,
+ },
+ {
+ label: 'Device',
+ onClick: () => setTimestampFormat(TimestampFormat.Device),
+ active: timestampFormat === TimestampFormat.Device,
+ },
+ {
+ label: 'Relative',
+ onClick: () => setTimestampFormat(TimestampFormat.Relative),
+ active: timestampFormat === TimestampFormat.Relative,
+ },
+ ]}
+ icon={ }
+ label={TimestampFormatToLabel[timestampFormat]}
+ />
+ )
+}
+
function InspectDOM(): JSX.Element {
const { sessionPlayerMetaData } = useValues(sessionRecordingPlayerLogic)
const { openExplorer } = useActions(sessionRecordingPlayerLogic)
@@ -125,39 +155,15 @@ function InspectDOM(): JSX.Element {
}
function PlayerBottomSettings(): JSX.Element {
- const { timestampFormat } = useValues(playerSettingsLogic)
- const { setTimestampFormat } = useActions(playerSettingsLogic)
-
return (
- setTimestampFormat(TimestampFormat.UTC),
- active: timestampFormat === TimestampFormat.UTC,
- },
- {
- label: 'Device',
- onClick: () => setTimestampFormat(TimestampFormat.Device),
- active: timestampFormat === TimestampFormat.Device,
- },
- {
- label: 'Relative',
- onClick: () => setTimestampFormat(TimestampFormat.Relative),
- active: timestampFormat === TimestampFormat.Relative,
- },
- ]}
- icon={ }
- label={TimestampFormatToLabel[timestampFormat]}
- />
-
+
+
)
}
diff --git a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
index 98958a186e51b..89e36899c5aeb 100644
--- a/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
+++ b/frontend/src/scenes/session-recordings/player/playerSettingsLogic.ts
@@ -1,4 +1,5 @@
-import { actions, connect, kea, path, reducers, selectors } from 'kea'
+import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
+import posthog from 'posthog-js'
import { teamLogic } from 'scenes/teamLogic'
import { AutoplayDirection, SessionRecordingSidebarStacking } from '~/types'
@@ -122,4 +123,13 @@ export const playerSettingsLogic = kea([
(preferredSidebarStacking) => preferredSidebarStacking === SessionRecordingSidebarStacking.Vertical,
],
}),
+
+ listeners({
+ setSpeed: ({ speed }) => {
+ posthog.capture('recording player speed changed', { new_speed: speed })
+ },
+ setSkipInactivitySetting: ({ skipInactivitySetting }) => {
+ posthog.capture('recording player skip inactivity toggled', { skip_inactivity: skipInactivitySetting })
+ },
+ }),
])
diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts
index 10118ce5defdc..ff63bd4b1f397 100644
--- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts
+++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts
@@ -1087,13 +1087,13 @@ export const sessionRecordingDataLogic = kea([
if (everyWindowMissingFullSnapshot) {
// video is definitely unplayable
posthog.capture('recording_has_no_full_snapshot', {
- sessionId: sessionRecordingId,
+ watchedSession: sessionRecordingId,
teamId: currentTeam?.id,
teamName: currentTeam?.name,
})
} else if (anyWindowMissingFullSnapshot) {
posthog.capture('recording_window_missing_full_snapshot', {
- sessionId: sessionRecordingId,
+ watchedSession: sessionRecordingId,
teamID: currentTeam?.id,
teamName: currentTeam?.name,
})
@@ -1103,6 +1103,14 @@ export const sessionRecordingDataLogic = kea([
},
],
+ isRecentAndInvalid: [
+ (s) => [s.start, s.snapshotsInvalid],
+ (start, snapshotsInvalid) => {
+ const lessThanFiveMinutesOld = dayjs().diff(start, 'minute') <= 5
+ return snapshotsInvalid && lessThanFiveMinutesOld
+ },
+ ],
+
bufferedToTime: [
(s) => [s.segments],
(segments): number | null => {
@@ -1160,6 +1168,13 @@ export const sessionRecordingDataLogic = kea([
actions.loadFullEventData(value)
}
},
+ isRecentAndInvalid: (prev: boolean, next: boolean) => {
+ if (!prev && next) {
+ posthog.capture('recording cannot playback yet', {
+ watchedSession: values.sessionPlayerData.sessionRecordingId,
+ })
+ }
+ },
})),
afterMount(({ cache }) => {
resetTimingsCache(cache)
diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts
index 3dde171f5c309..7a45c26637046 100644
--- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts
+++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts
@@ -141,7 +141,7 @@ describe('sessionRecordingPlayerLogic', () => {
sessionRecordingDataLogic({ sessionRecordingId: '2' }).actionTypes.loadSnapshotSourcesFailure,
])
.toFinishAllListeners()
- .toDispatchActions(['setErrorPlayerState'])
+ .toDispatchActions(['setPlayerError'])
expect(logic.values).toMatchObject({
sessionPlayerData: {
@@ -149,7 +149,7 @@ describe('sessionRecordingPlayerLogic', () => {
snapshotsByWindowId: {},
bufferedToTime: 0,
},
- isErrored: true,
+ playerError: 'loadSnapshotSourcesFailure',
})
resumeKeaLoadersErrors()
})
diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts
index cfda001ed9ea5..5e7e7955f4602 100644
--- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts
+++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts
@@ -1,5 +1,5 @@
import { lemonToast } from '@posthog/lemon-ui'
-import { customEvent, EventType, eventWithTime, IncrementalSource } from '@rrweb/types'
+import { EventType, eventWithTime, IncrementalSource } from '@rrweb/types'
import { captureException } from '@sentry/react'
import {
actions,
@@ -138,12 +138,7 @@ export const sessionRecordingPlayerLogic = kea(
playerSettingsLogic,
['setSpeed', 'setSkipInactivitySetting'],
eventUsageLogic,
- [
- 'reportNextRecordingTriggered',
- 'reportRecordingPlayerSkipInactivityToggled',
- 'reportRecordingPlayerSpeedChanged',
- 'reportRecordingExportedToFile',
- ],
+ ['reportNextRecordingTriggered', 'reportRecordingExportedToFile'],
],
})),
actions({
@@ -156,7 +151,8 @@ export const sessionRecordingPlayerLogic = kea(
endBuffer: true,
startScrub: true,
endScrub: true,
- setErrorPlayerState: (show: boolean) => ({ show }),
+ setPlayerError: (reason: string) => ({ reason }),
+ clearPlayerError: true,
setSkippingInactivity: (isSkippingInactivity: boolean) => ({ isSkippingInactivity }),
syncPlayerSpeed: true,
setCurrentTimestamp: (timestamp: number) => ({ timestamp }),
@@ -189,7 +185,6 @@ export const sessionRecordingPlayerLogic = kea(
// the error is emitted from code we don't control in rrweb, so we can't guarantee it's really an Error
playerErrorSeen: (error: any) => ({ error }),
fingerprintReported: (fingerprint: string) => ({ fingerprint }),
- reportMessageTooLargeWarningSeen: (sessionRecordingId: string) => ({ sessionRecordingId }),
setDebugSnapshotTypes: (types: EventType[]) => ({ types }),
setDebugSnapshotIncrementalSources: (incrementalSources: IncrementalSource[]) => ({ incrementalSources }),
setPlayNextAnimationInterrupted: (interrupted: boolean) => ({ interrupted }),
@@ -349,10 +344,7 @@ export const sessionRecordingPlayerLogic = kea(
bufferTime: state.bufferTime,
}
},
- setErrorPlayerState: (state, { show }) => {
- if (!show) {
- return state
- }
+ setPlayerError: (state) => {
return {
isPlaying: state.isPlaying,
isBuffering: state.isBuffering,
@@ -374,7 +366,13 @@ export const sessionRecordingPlayerLogic = kea(
},
],
isBuffering: [true, { startBuffer: () => true, endBuffer: () => false }],
- isErrored: [false, { setErrorPlayerState: (_, { show }) => show }],
+ playerError: [
+ null as string | null,
+ {
+ setPlayerError: (_, { reason }) => (reason.trim().length ? reason : null),
+ clearPlayerError: () => null,
+ },
+ ],
isScrubbing: [false, { startScrub: () => true, endScrub: () => false }],
errorCount: [0, { incrementErrorCount: (prevErrorCount) => prevErrorCount + 1 }],
@@ -400,12 +398,6 @@ export const sessionRecordingPlayerLogic = kea(
setIsFullScreen: (_, { isFullScreen }) => isFullScreen,
},
],
- messageTooLargeWarningSeen: [
- null as string | null,
- {
- reportMessageTooLargeWarningSeen: (_, { sessionRecordingId }) => sessionRecordingId,
- },
- ],
debugSettings: [
{
types: [EventType.FullSnapshot, EventType.IncrementalSnapshot],
@@ -431,7 +423,7 @@ export const sessionRecordingPlayerLogic = kea(
(s) => [
s.playingState,
s.isBuffering,
- s.isErrored,
+ s.playerError,
s.isScrubbing,
s.isSkippingInactivity,
s.snapshotsLoaded,
@@ -440,7 +432,7 @@ export const sessionRecordingPlayerLogic = kea(
(
playingState,
isBuffering,
- isErrored,
+ playerError,
isScrubbing,
isSkippingInactivity,
snapshotsLoaded,
@@ -452,7 +444,7 @@ export const sessionRecordingPlayerLogic = kea(
return playingState
case !snapshotsLoaded && !snapshotsLoading:
return SessionPlayerState.READY
- case isErrored:
+ case !!playerError?.trim().length:
return SessionPlayerState.ERROR
case isSkippingInactivity && playingState !== SessionPlayerState.PAUSE:
return SessionPlayerState.SKIP
@@ -544,13 +536,6 @@ export const sessionRecordingPlayerLogic = kea(
},
],
- messageTooLargeWarnings: [
- (s) => [s.customRRWebEvents],
- (customRRWebEvents: customEvent[]) => {
- return customRRWebEvents.filter((event) => event.data.tag === 'Message too large')
- },
- ],
-
debugSnapshots: [
(s) => [s.sessionPlayerData, s.debugSettings],
(sessionPlayerData: SessionPlayerData, debugSettings): eventWithTime[] => {
@@ -672,7 +657,6 @@ export const sessionRecordingPlayerLogic = kea(
}
},
setSkipInactivitySetting: ({ skipInactivitySetting }) => {
- actions.reportRecordingPlayerSkipInactivityToggled(skipInactivitySetting)
if (!values.currentSegment?.isActive && skipInactivitySetting) {
actions.setSkippingInactivity(true)
} else {
@@ -784,13 +768,13 @@ export const sessionRecordingPlayerLogic = kea(
loadSnapshotsForSourceFailure: () => {
if (Object.keys(values.sessionPlayerData.snapshotsByWindowId).length === 0) {
console.error('PostHog Recording Playback Error: No snapshots loaded')
- actions.setErrorPlayerState(true)
+ actions.setPlayerError('loadSnapshotsForSourceFailure')
}
},
loadSnapshotSourcesFailure: () => {
if (Object.keys(values.sessionPlayerData.snapshotsByWindowId).length === 0) {
console.error('PostHog Recording Playback Error: No snapshots loaded')
- actions.setErrorPlayerState(true)
+ actions.setPlayerError('loadSnapshotSourcesFailure')
}
},
setPlay: () => {
@@ -839,18 +823,17 @@ export const sessionRecordingPlayerLogic = kea(
startBuffer: () => {
actions.stopAnimation()
},
- setErrorPlayerState: ({ show }) => {
- if (show) {
- actions.incrementErrorCount()
- actions.stopAnimation()
- }
+ setPlayerError: () => {
+ actions.incrementErrorCount()
+ actions.stopAnimation()
},
startScrub: () => {
actions.stopAnimation()
},
- setSpeed: ({ speed }) => {
- actions.reportRecordingPlayerSpeedChanged(speed)
- actions.syncPlayerSpeed()
+ setSpeed: () => {
+ if (props.mode !== SessionRecordingPlayerMode.Preview) {
+ actions.syncPlayerSpeed()
+ }
},
seekToTimestamp: ({ timestamp, forcePlay }, breakpoint) => {
actions.stopAnimation()
@@ -866,25 +849,13 @@ export const sessionRecordingPlayerLogic = kea(
// If next time is greater than last buffered time, set to buffering
else if (segment?.kind === 'buffer') {
- const isStillLoading = values.isRealtimePolling || values.snapshotsLoading
- const isPastEnd = values.sessionPlayerData.end && timestamp > values.sessionPlayerData.end.valueOf()
- if (isStillLoading) {
+ const isPastEnd = values.sessionPlayerData.end && timestamp >= values.sessionPlayerData.end.valueOf()
+ if (isPastEnd) {
+ actions.setEndReached(true)
+ } else {
values.player?.replayer?.pause()
actions.startBuffer()
- actions.setErrorPlayerState(false)
- } else {
- if (isPastEnd) {
- actions.setEndReached(true)
- } else {
- // If not currently loading anything,
- // not past the end of the recording,
- // and part of the recording hasn't loaded,
- // set error state
- values.player?.replayer?.pause()
- actions.endBuffer()
- console.error("Error: Player tried to seek to a position that hasn't loaded yet")
- actions.setErrorPlayerState(true)
- }
+ actions.clearPlayerError()
}
}
@@ -895,14 +866,14 @@ export const sessionRecordingPlayerLogic = kea(
// can consume 100% CPU and freeze the entire page
values.player?.replayer?.pause(values.toRRWebPlayerTime(timestamp))
actions.endBuffer()
- actions.setErrorPlayerState(false)
+ actions.clearPlayerError()
}
// Otherwise play
else {
values.player?.replayer?.play(values.toRRWebPlayerTime(timestamp))
actions.updateAnimation()
actions.endBuffer()
- actions.setErrorPlayerState(false)
+ actions.clearPlayerError()
}
breakpoint()
@@ -962,7 +933,7 @@ export const sessionRecordingPlayerLogic = kea(
// when the buffering progresses
values.player?.replayer?.pause()
actions.startBuffer()
- actions.setErrorPlayerState(false)
+ actions.clearPlayerError()
cache.debug('buffering')
return
}
@@ -1018,7 +989,7 @@ export const sessionRecordingPlayerLogic = kea(
cache.pausedMediaElements = values.endReached ? [] : playingElements
},
restartIframePlayback: () => {
- cache.pausedMediaElements.forEach((el: HTMLMediaElement) => el.play())
+ cache.pausedMediaElements?.forEach((el: HTMLMediaElement) => el.play())
cache.pausedMediaElements = []
},
@@ -1107,13 +1078,9 @@ export const sessionRecordingPlayerLogic = kea(
await document.exitFullscreen()
}
},
-
- reportMessageTooLargeWarningSeen: async ({ sessionRecordingId }) => {
- posthog.capture('message too large warning seen', { sessionRecordingId })
- },
})),
- subscriptions(({ actions, values, props }) => ({
+ subscriptions(({ actions, values }) => ({
sessionPlayerData: (next, prev) => {
const hasSnapshotChanges = next?.snapshotsByWindowId !== prev?.snapshotsByWindowId
@@ -1134,13 +1101,15 @@ export const sessionRecordingPlayerLogic = kea(
actions.skipPlayerForward(rrwebPlayerTime, values.roughAnimationFPS)
}
},
- messageTooLargeWarnings: (next) => {
- if (
- values.messageTooLargeWarningSeen !== values.sessionRecordingId &&
- next.length > 0 &&
- props.mode !== SessionRecordingPlayerMode.Preview
- ) {
- actions.reportMessageTooLargeWarningSeen(values.sessionRecordingId)
+ playerError: (next) => {
+ if (next) {
+ posthog.capture('recording player error', {
+ watchedSessionId: values.sessionRecordingId,
+ currentTimestamp: values.currentTimestamp,
+ currentSegment: values.currentSegment,
+ currentPlayerTime: values.currentPlayerTime,
+ error: next,
+ })
}
},
})),
diff --git a/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts b/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts
index 5f1bee532fdaa..246fa495ba5a2 100644
--- a/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts
+++ b/frontend/src/scenes/session-recordings/sessionReplaySceneLogic.ts
@@ -6,7 +6,7 @@ import { capitalizeFirstLetter } from 'lib/utils'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
-import { ActivityFilters } from '~/layout/navigation-3000/sidepanel/panels/activity/activityForSceneLogic'
+import { SIDE_PANEL_CONTEXT_KEY, SidePanelSceneContext } from '~/layout/navigation-3000/sidepanel/types'
import { ActivityScope, Breadcrumb, ReplayTabs } from '~/types'
import type { sessionReplaySceneLogicType } from './sessionReplaySceneLogicType'
@@ -92,13 +92,13 @@ export const sessionReplaySceneLogic = kea([
return breadcrumbs
},
],
- activityFilters: [
+ [SIDE_PANEL_CONTEXT_KEY]: [
() => [router.selectors.searchParams],
- (searchParams): ActivityFilters | null => {
+ (searchParams): SidePanelSceneContext | null => {
return searchParams.sessionRecordingId
? {
- scope: ActivityScope.REPLAY,
- item_id: searchParams.sessionRecordingId,
+ activity_scope: ActivityScope.REPLAY,
+ activity_item_id: searchParams.sessionRecordingId,
}
: null
},
diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx
index 67c0c99f827d1..d2e46cb6e6636 100644
--- a/frontend/src/scenes/settings/SettingsMap.tsx
+++ b/frontend/src/scenes/settings/SettingsMap.tsx
@@ -1,4 +1,6 @@
+import { BounceRateDurationSetting } from 'scenes/settings/environment/BounceRateDuration'
import { BounceRatePageViewModeSetting } from 'scenes/settings/environment/BounceRatePageViewMode'
+import { CookielessServerHashModeSetting } from 'scenes/settings/environment/CookielessServerHashMode'
import { CustomChannelTypes } from 'scenes/settings/environment/CustomChannelTypes'
import { DeadClicksAutocaptureSettings } from 'scenes/settings/environment/DeadClicksAutocaptureSettings'
import { PersonsJoinMode } from 'scenes/settings/environment/PersonsJoinMode'
@@ -15,6 +17,7 @@ import {
} from './environment/AutocaptureSettings'
import { CorrelationConfig } from './environment/CorrelationConfig'
import { DataAttributes } from './environment/DataAttributes'
+import { DataColorThemes } from './environment/DataColorThemes'
import { GroupAnalyticsConfig } from './environment/GroupAnalyticsConfig'
import { HeatmapsSettings } from './environment/HeatmapsSettings'
import { IPAllowListInfo } from './environment/IPAllowListInfo'
@@ -50,7 +53,7 @@ import { OrganizationDangerZone } from './organization/OrganizationDangerZone'
import { OrganizationDisplayName } from './organization/OrgDisplayName'
import { OrganizationEmailPreferences } from './organization/OrgEmailPreferences'
import { OrganizationLogo } from './organization/OrgLogo'
-import { PermissionsGrid } from './organization/Permissions/PermissionsGrid'
+import { RoleBasedAccess } from './organization/Permissions/RoleBasedAccess'
import { VerifiedDomains } from './organization/VerifiedDomains/VerifiedDomains'
import { ProjectDangerZone } from './project/ProjectDangerZone'
import { ProjectDisplayName, ProjectProductDescription } from './project/ProjectSettings'
@@ -156,6 +159,12 @@ export const SETTINGS_MAP: SettingSection[] = [
title: 'Filter out internal and test users',
component: ,
},
+ {
+ id: 'data-theme',
+ title: 'Data colors',
+ component: ,
+ flag: 'INSIGHT_COLORS',
+ },
{
id: 'persons-on-events',
title: 'Person properties mode',
@@ -193,12 +202,6 @@ export const SETTINGS_MAP: SettingSection[] = [
component: ,
flag: 'SETTINGS_PERSONS_JOIN_MODE',
},
- {
- id: 'bounce-rate-page-view-mode',
- title: 'Bounce rate page view mode',
- component: ,
- flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE',
- },
{
id: 'session-table-version',
title: 'Sessions Table Version',
@@ -218,8 +221,24 @@ export const SETTINGS_MAP: SettingSection[] = [
title: 'Custom channel type',
component: ,
},
+ {
+ id: 'cookieless-server-hash-mode',
+ title: 'Cookieless server hash mode',
+ component: ,
+ flag: 'COOKIELESS_SERVER_HASH_MODE_SETTING',
+ },
+ {
+ id: 'bounce-rate-duration',
+ title: 'Bounce rate duration',
+ component: ,
+ },
+ {
+ id: 'bounce-rate-page-view-mode',
+ title: 'Bounce rate page view mode',
+ component: ,
+ flag: 'SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE',
+ },
],
- flag: 'CUSTOM_CHANNEL_TYPE_RULES',
},
{
@@ -315,11 +334,11 @@ export const SETTINGS_MAP: SettingSection[] = [
},
{
level: 'environment',
- id: 'environment-rbac',
+ id: 'environment-access-control',
title: 'Access control',
settings: [
{
- id: 'environment-rbac',
+ id: 'environment-access-control',
title: 'Access control',
component: ,
},
@@ -414,25 +433,25 @@ export const SETTINGS_MAP: SettingSection[] = [
},
{
level: 'organization',
- id: 'organization-authentication',
- title: 'Authentication domains & SSO',
+ id: 'organization-roles',
+ title: 'Roles',
settings: [
{
- id: 'authentication-domains',
- title: 'Authentication Domains',
- component: ,
+ id: 'organization-roles',
+ title: 'Roles',
+ component: ,
},
],
},
{
level: 'organization',
- id: 'organization-rbac',
- title: 'Role-based access',
+ id: 'organization-authentication',
+ title: 'Authentication domains & SSO',
settings: [
{
- id: 'organization-rbac',
- title: 'Role-based access',
- component: ,
+ id: 'authentication-domains',
+ title: 'Authentication Domains',
+ component: ,
},
],
},
diff --git a/frontend/src/scenes/settings/environment/BounceRateDuration.tsx b/frontend/src/scenes/settings/environment/BounceRateDuration.tsx
new file mode 100644
index 0000000000000..7adc18ef8b3f6
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/BounceRateDuration.tsx
@@ -0,0 +1,88 @@
+import { IconX } from '@posthog/icons'
+import { useActions, useValues } from 'kea'
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+import { LemonInput } from 'lib/lemon-ui/LemonInput'
+import React, { useState } from 'react'
+import { teamLogic } from 'scenes/teamLogic'
+
+const MIN_BOUNCE_RATE_DURATION = 1
+const MAX_BOUNCE_RATE_DURATION = 120
+const DEFAULT_BOUNCE_RATE_DURATION = 10
+
+export function BounceRateDurationSetting(): JSX.Element {
+ const { updateCurrentTeam } = useActions(teamLogic)
+ const { currentTeam } = useValues(teamLogic)
+
+ const savedDuration =
+ currentTeam?.modifiers?.bounceRateDurationSeconds ?? currentTeam?.default_modifiers?.bounceRateDurationSeconds
+ const [bounceRateDuration, setBounceRateDuration] = useState(savedDuration ?? DEFAULT_BOUNCE_RATE_DURATION)
+
+ const handleChange = (duration: number | undefined): void => {
+ if (Number.isNaN(duration)) {
+ duration = undefined
+ }
+ updateCurrentTeam({
+ modifiers: { ...currentTeam?.modifiers, bounceRateDurationSeconds: duration },
+ })
+ }
+
+ const inputRef = React.useRef(null)
+
+ return (
+ <>
+
+ Choose how long a user can stay on a page, in seconds, before the session is not a bounce. Leave blank
+ to use the default of {DEFAULT_BOUNCE_RATE_DURATION} seconds, or set a custom value between{' '}
+ {MIN_BOUNCE_RATE_DURATION} second and {MAX_BOUNCE_RATE_DURATION} seconds inclusive.
+
+ {
+ if (x == null || Number.isNaN(x)) {
+ setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION)
+ } else {
+ setBounceRateDuration(x)
+ }
+ }}
+ inputRef={inputRef}
+ suffix={
+ }
+ tooltip="Clear input"
+ onClick={(e) => {
+ e.stopPropagation()
+ setBounceRateDuration(DEFAULT_BOUNCE_RATE_DURATION)
+ inputRef.current?.focus()
+ }}
+ />
+ }
+ />
+
+ handleChange(bounceRateDuration)}
+ disabledReason={
+ bounceRateDuration === savedDuration
+ ? 'No changes to save'
+ : bounceRateDuration == undefined
+ ? undefined
+ : isNaN(bounceRateDuration)
+ ? 'Invalid number'
+ : bounceRateDuration < MIN_BOUNCE_RATE_DURATION
+ ? `Duration must be at least ${MIN_BOUNCE_RATE_DURATION} second`
+ : bounceRateDuration > MAX_BOUNCE_RATE_DURATION
+ ? `Duration must be less than ${MAX_BOUNCE_RATE_DURATION} seconds`
+ : undefined
+ }
+ >
+ Save
+
+
+ >
+ )
+}
diff --git a/frontend/src/scenes/settings/environment/CookielessServerHashMode.tsx b/frontend/src/scenes/settings/environment/CookielessServerHashMode.tsx
new file mode 100644
index 0000000000000..57cd21e0ff709
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/CookielessServerHashMode.tsx
@@ -0,0 +1,65 @@
+import { useActions, useValues } from 'kea'
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+import { LemonRadio, LemonRadioOption } from 'lib/lemon-ui/LemonRadio'
+import { useState } from 'react'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { CookielessServerHashMode } from '~/types'
+
+const options: LemonRadioOption[] = [
+ {
+ value: CookielessServerHashMode.Stateful,
+ label: (
+ <>
+ Stateful
+ >
+ ),
+ },
+ {
+ value: CookielessServerHashMode.Stateless,
+ label: (
+ <>
+ Stateless
+ >
+ ),
+ },
+ {
+ value: CookielessServerHashMode.Disabled,
+ label: (
+ <>
+ Disabled
+ >
+ ),
+ },
+]
+
+export function CookielessServerHashModeSetting(): JSX.Element {
+ const { updateCurrentTeam } = useActions(teamLogic)
+ const { currentTeam } = useValues(teamLogic)
+
+ const savedSetting = currentTeam?.cookieless_server_hash_mode ?? CookielessServerHashMode.Disabled
+ const [setting, setSetting] = useState(savedSetting)
+
+ const handleChange = (newSetting: CookielessServerHashMode): void => {
+ updateCurrentTeam({ cookieless_server_hash_mode: newSetting })
+ }
+
+ return (
+ <>
+
+ Use a cookieless server-side hash mode to hash user data. This is an experimental feature preview and
+ may result in dropped events.
+
+
+
+ handleChange(setting)}
+ disabledReason={setting === savedSetting ? 'No changes to save' : undefined}
+ >
+ Save
+
+
+ >
+ )
+}
diff --git a/frontend/src/scenes/settings/environment/DataColorThemeModal.tsx b/frontend/src/scenes/settings/environment/DataColorThemeModal.tsx
new file mode 100644
index 0000000000000..aaa11ee331f85
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/DataColorThemeModal.tsx
@@ -0,0 +1,104 @@
+import { IconCopy, IconPlus, IconTrash } from '@posthog/icons'
+import { LemonButton, LemonInput, LemonLabel, LemonModal, LemonTable } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { Form } from 'kea-forms'
+import { ColorGlyph } from 'lib/components/SeriesGlyph'
+import { LemonField } from 'lib/lemon-ui/LemonField'
+
+import { dataColorThemesModalLogic } from './dataColorThemeModalLogic'
+
+export function DataColorThemeModal(): JSX.Element {
+ const { theme, themeChanged, isOpen } = useValues(dataColorThemesModalLogic)
+ const { submitTheme, closeModal, addColor, duplicateColor, removeColor } = useActions(dataColorThemesModalLogic)
+
+ const isNew = theme?.id == null
+ const isOfficial = theme?.is_global
+ const title = isOfficial ? 'Official theme' : isNew ? 'Add theme' : 'Edit theme'
+
+ return (
+
+ Official themes can't be edited.
+
+ Close
+
+
+ ) : (
+
+ Save
+
+ )
+ }
+ hasUnsavedInput={themeChanged}
+ >
+
+
+ )
+}
diff --git a/frontend/src/scenes/settings/environment/DataColorThemes.tsx b/frontend/src/scenes/settings/environment/DataColorThemes.tsx
new file mode 100644
index 0000000000000..d73ed68295f63
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/DataColorThemes.tsx
@@ -0,0 +1,69 @@
+import { IconBadge } from '@posthog/icons'
+import { LemonButton, LemonDialog, LemonLabel, LemonSelect, LemonTable } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { DataColorThemeModal } from './DataColorThemeModal'
+import { dataColorThemesLogic } from './dataColorThemesLogic'
+
+export function DataColorThemes(): JSX.Element {
+ const { themes: _themes, themesLoading, defaultTheme } = useValues(dataColorThemesLogic)
+ const { selectTheme } = useActions(dataColorThemesLogic)
+
+ const { currentTeamLoading } = useValues(teamLogic)
+ const { updateCurrentTeam } = useActions(teamLogic)
+
+ const themes = _themes || []
+
+ return (
+
+ (
+ selectTheme(theme.id)} title={name as string} />
+ ),
+ },
+ {
+ title: 'Official',
+ dataIndex: 'is_global',
+ key: 'is_global',
+ render: (is_global) => (is_global ? : null),
+ },
+ ]}
+ />
+ selectTheme('new')}>
+ Add theme
+
+
+ Default theme
+ {
+ const theme = themes.find((theme) => theme.id === value)
+ LemonDialog.open({
+ title: `Change the default data theme to "${theme!.name}"?`,
+ description: 'This changes the default colors used when visualizing data in insights.',
+ primaryButton: {
+ children: 'Change default theme',
+ onClick: () => updateCurrentTeam({ default_data_theme: value! }),
+ },
+ secondaryButton: {
+ children: 'Cancel',
+ },
+ })
+ }}
+ loading={themesLoading || currentTeamLoading}
+ options={themes.map((theme) => ({ value: theme.id, label: theme.name }))}
+ />
+
+
+
+ )
+}
diff --git a/frontend/src/scenes/settings/environment/ReplayTriggers.tsx b/frontend/src/scenes/settings/environment/ReplayTriggers.tsx
index 48e79bea18cec..495387c5146ec 100644
--- a/frontend/src/scenes/settings/environment/ReplayTriggers.tsx
+++ b/frontend/src/scenes/settings/environment/ReplayTriggers.tsx
@@ -202,8 +202,8 @@ function UrlBlocklistOptions(): JSX.Element | null {
return (
Enable recordings using feature flag {featureFlagLoading && }
+
Linking a flag means that recordings will only be collected for users who have the flag enabled.
-
-
{samplingControlFeatureEnabled && (
<>
@@ -243,6 +242,7 @@ export function SessionRecordingIngestionSettings(): JSX.Element | null {
}
/>
+
Use this setting to restrict the percentage of sessions that will be recorded. This is
useful if you want to reduce the amount of data you collect. 100% means all sessions will be
@@ -264,6 +264,7 @@ export function SessionRecordingIngestionSettings(): JSX.Element | null {
value={currentTeam?.session_recording_minimum_duration_milliseconds}
/>
+
Setting a minimum session duration will ensure that only sessions that last longer than that
value are collected. This helps you avoid collecting sessions that are too short to be
diff --git a/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx b/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
index ff6650d2bffd5..d6c5dba244859 100644
--- a/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
+++ b/frontend/src/scenes/settings/environment/SessionRecordingSettings.tsx
@@ -56,7 +56,7 @@ export function SupportedPlatforms(props: {
flutter?: boolean | { note?: ReactNode }
}): JSX.Element {
return (
-
+
Supported platforms:
Log capture
-
+
This setting controls if browser console logs will be captured as a part of recordings. The console logs
will be shown in the recording player to help you debug any issues.
@@ -208,7 +208,13 @@ export function NetworkCaptureSettings(): JSX.Element {
return (
<>
-
+ RN network capture is only supported on iOS> }}
+ />
This setting controls if performance and network information will be captured alongside recordings. The
network requests and timings will be shown in the recording player to help you debug any issues.
diff --git a/frontend/src/scenes/settings/environment/TeamAccessControl.tsx b/frontend/src/scenes/settings/environment/TeamAccessControl.tsx
index 88cfdf5f2caee..6674c261800e8 100644
--- a/frontend/src/scenes/settings/environment/TeamAccessControl.tsx
+++ b/frontend/src/scenes/settings/environment/TeamAccessControl.tsx
@@ -4,6 +4,7 @@ import { useActions, useValues } from 'kea'
import { RestrictionScope, useRestrictedArea } from 'lib/components/RestrictedArea'
import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic'
import { OrganizationMembershipLevel, TeamMembershipLevel } from 'lib/constants'
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { IconCancel } from 'lib/lemon-ui/icons'
import { LemonDialog } from 'lib/lemon-ui/LemonDialog'
import { LemonTableColumns } from 'lib/lemon-ui/LemonTable'
@@ -19,6 +20,7 @@ import { organizationLogic } from 'scenes/organizationLogic'
import { isAuthenticatedTeam, teamLogic } from 'scenes/teamLogic'
import { userLogic } from 'scenes/userLogic'
+import { AccessControlObject } from '~/layout/navigation-3000/sidepanel/panels/access_control/AccessControlObject'
import { AvailableFeature, FusedTeamMemberType } from '~/types'
import { AddMembersModalWithButton } from './AddMembersModal'
@@ -154,7 +156,7 @@ export function TeamMembers(): JSX.Element | null {
title: 'Name',
key: 'user_first_name',
render: (_, member) =>
- member.user.uuid == user.uuid ? `${member.user.first_name} (me)` : member.user.first_name,
+ member.user.uuid == user.uuid ? `${member.user.first_name} (you)` : member.user.first_name,
sorter: (a, b) => a.user.first_name.localeCompare(b.user.first_name),
},
{
@@ -214,6 +216,11 @@ export function TeamAccessControl(): JSX.Element {
minimumAccessLevel: OrganizationMembershipLevel.Admin,
})
+ const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL')
+ if (newAccessControl) {
+ return
+ }
+
return (
<>
diff --git a/frontend/src/scenes/settings/environment/dataColorThemeModalLogic.ts b/frontend/src/scenes/settings/environment/dataColorThemeModalLogic.ts
new file mode 100644
index 0000000000000..b07d1561ee892
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/dataColorThemeModalLogic.ts
@@ -0,0 +1,88 @@
+import { lemonToast } from '@posthog/lemon-ui'
+import { actions, kea, listeners, path, reducers } from 'kea'
+import { forms } from 'kea-forms'
+import api from 'lib/api'
+
+import { DataColorThemeModelPayload } from '~/types'
+
+import type { dataColorThemesModalLogicType } from './dataColorThemeModalLogicType'
+
+const PAYLOAD_DEFAULT: DataColorThemeModelPayload = { name: '', colors: [] }
+
+export const dataColorThemesModalLogic = kea([
+ path(['scenes', 'settings', 'environment', 'dataColorThemesModalLogic']),
+ actions({
+ openModal: (theme) => ({ theme }),
+ closeModal: true,
+ addColor: true,
+ duplicateColor: (index: number) => ({ index }),
+ removeColor: (index: number) => ({ index }),
+ }),
+ reducers({
+ isOpen: [
+ false,
+ {
+ openModal: () => true,
+ closeModal: () => false,
+ },
+ ],
+ theme: [
+ { name: '', colors: [] } as DataColorThemeModelPayload,
+ {
+ addColor: (theme) => ({
+ ...theme,
+ colors: [...(theme.colors || []), theme.colors[theme.colors.length - 1] || '#1d4aff'],
+ }),
+ duplicateColor: (theme, { index }) => ({
+ ...theme,
+ colors: theme.colors.flatMap((color, idx) => (idx === index ? [color, color] : [color])),
+ }),
+ removeColor: (theme, { index }) => ({
+ ...theme,
+ colors: theme.colors.filter((_, idx) => idx !== index),
+ }),
+ },
+ ],
+ }),
+ forms(({ actions }) => ({
+ theme: {
+ defaults: PAYLOAD_DEFAULT,
+ submit: async ({ id, name, colors }, breakpoint): Promise => {
+ const payload: DataColorThemeModelPayload = {
+ name: name || '',
+ colors: colors || [],
+ }
+
+ breakpoint()
+
+ try {
+ const updatedTheme = id
+ ? await api.dataColorThemes.update(id, payload)
+ : await api.dataColorThemes.create(payload)
+
+ lemonToast.success(updatedTheme ? 'Theme saved.' : 'Theme created.')
+ actions.closeModal()
+
+ return updatedTheme
+ } catch (error: any) {
+ if (error.data?.attr && error.data?.detail) {
+ const field = error.data?.attr?.replace(/_/g, ' ')
+ lemonToast.error(`Error saving data color theme: ${field}: ${error.data.detail}`)
+ } else {
+ lemonToast.error(`Error saving data color theme`)
+ }
+ }
+
+ return payload
+ },
+ errors: (theme) => ({
+ name: !theme?.name ? 'This field is required' : undefined,
+ }),
+ },
+ })),
+ listeners(({ actions }) => ({
+ openModal: ({ theme }) => {
+ actions.resetTheme(theme)
+ },
+ })),
+])
diff --git a/frontend/src/scenes/settings/environment/dataColorThemesLogic.ts b/frontend/src/scenes/settings/environment/dataColorThemesLogic.ts
new file mode 100644
index 0000000000000..9cabf20c777a7
--- /dev/null
+++ b/frontend/src/scenes/settings/environment/dataColorThemesLogic.ts
@@ -0,0 +1,41 @@
+import { actions, connect, kea, listeners, path } from 'kea'
+import { dataThemeLogic } from 'scenes/dataThemeLogic'
+
+import { dataColorThemesModalLogic } from './dataColorThemeModalLogic'
+import type { dataColorThemesLogicType } from './dataColorThemesLogicType'
+
+export const dataColorThemesLogic = kea([
+ path(['scenes', 'settings', 'environment', 'dataColorThemesLogic']),
+ connect({
+ values: [dataThemeLogic, ['themes', 'themesLoading', 'defaultTheme', 'posthogTheme']],
+ actions: [dataColorThemesModalLogic, ['openModal', 'submitThemeSuccess'], dataThemeLogic, ['setThemes']],
+ }),
+ actions({
+ selectTheme: (id: 'new' | number | null) => ({ id }),
+ }),
+ listeners(({ values, actions }) => ({
+ selectTheme: ({ id }) => {
+ // we're not yet initialized
+ if (values.themes == null || values.posthogTheme == null || id == null) {
+ return
+ }
+
+ if (id === 'new') {
+ const { id, name, is_global, ...newTheme } = values.posthogTheme
+ actions.openModal(newTheme)
+ } else {
+ const existingTheme = values.themes.find((theme) => theme.id === id)
+ actions.openModal(existingTheme)
+ }
+ },
+ submitThemeSuccess: ({ theme }) => {
+ const existingTheme = values.themes!.find((t) => t.id === theme.id)
+ if (existingTheme != null) {
+ const updatedThemes = values.themes!.map((t) => (t.id === theme.id ? theme : t))
+ actions.setThemes(updatedThemes)
+ } else {
+ actions.setThemes([...values.themes!, theme])
+ }
+ },
+ })),
+])
diff --git a/frontend/src/scenes/settings/organization/Members.tsx b/frontend/src/scenes/settings/organization/Members.tsx
index 3659b22c952ef..42face838324a 100644
--- a/frontend/src/scenes/settings/organization/Members.tsx
+++ b/frontend/src/scenes/settings/organization/Members.tsx
@@ -1,6 +1,7 @@
import { LemonInput, LemonSwitch } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
+import { useRestrictedArea } from 'lib/components/RestrictedArea'
import { TZLabel } from 'lib/components/TZLabel'
import { OrganizationMembershipLevel } from 'lib/constants'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
@@ -19,7 +20,6 @@ import {
} from 'lib/utils/permissioning'
import { useEffect } from 'react'
import { twoFactorLogic } from 'scenes/authentication/twoFactorLogic'
-import { TwoFactorSetupModal } from 'scenes/authentication/TwoFactorSetupModal'
import { membersLogic } from 'scenes/organization/membersLogic'
import { organizationLogic } from 'scenes/organizationLogic'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
@@ -142,10 +142,11 @@ export function Members(): JSX.Element | null {
const { currentOrganization } = useValues(organizationLogic)
const { preflight } = useValues(preflightLogic)
const { user } = useValues(userLogic)
-
- const { setSearch, ensureAllMembersLoaded, loadAllMembers } = useActions(membersLogic)
+ const { setSearch, ensureAllMembersLoaded } = useActions(membersLogic)
const { updateOrganization } = useActions(organizationLogic)
- const { toggleTwoFactorSetupModal } = useActions(twoFactorLogic)
+ const { openTwoFactorSetupModal } = useActions(twoFactorLogic)
+
+ const twoFactorRestrictionReason = useRestrictedArea({ minimumAccessLevel: OrganizationMembershipLevel.Admin })
useEffect(() => {
ensureAllMembersLoaded()
@@ -167,7 +168,7 @@ export function Members(): JSX.Element | null {
title: 'Name',
key: 'user_name',
render: (_, member) =>
- member.user.uuid == user.uuid ? `${fullName(member.user)} (me)` : fullName(member.user),
+ member.user.uuid == user.uuid ? `${fullName(member.user)} (you)` : fullName(member.user),
sorter: (a, b) => fullName(a.user).localeCompare(fullName(b.user)),
},
{
@@ -212,14 +213,6 @@ export function Members(): JSX.Element | null {
render: function LevelRender(_, member) {
return (
<>
- {member.user.uuid == user.uuid && (
- {
- userLogic.actions.updateUser({})
- loadAllMembers()
- }}
- />
- )}
toggleTwoFactorSetupModal(true)
+ ? () => openTwoFactorSetupModal()
: undefined
}
data-attr="2fa-enabled"
@@ -299,6 +292,7 @@ export function Members(): JSX.Element | null {
bordered
checked={!!currentOrganization?.enforce_2fa}
onChange={(enforce_2fa) => updateOrganization({ enforce_2fa })}
+ disabledReason={twoFactorRestrictionReason}
/>
>
diff --git a/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx b/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx
new file mode 100644
index 0000000000000..62ef8ff7a1f95
--- /dev/null
+++ b/frontend/src/scenes/settings/organization/Permissions/RoleBasedAccess.tsx
@@ -0,0 +1,12 @@
+// NOTE: This is only to allow testing the new RBAC system
+
+import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
+
+import { RolesAndResourceAccessControls } from '~/layout/navigation-3000/sidepanel/panels/access_control/RolesAndResourceAccessControls'
+
+import { PermissionsGrid } from './PermissionsGrid'
+
+export function RoleBasedAccess(): JSX.Element {
+ const newAccessControl = useFeatureFlag('ROLE_BASED_ACCESS_CONTROL')
+ return newAccessControl ? :
+}
diff --git a/frontend/src/scenes/settings/types.ts b/frontend/src/scenes/settings/types.ts
index 0103298077232..fc20388e67c40 100644
--- a/frontend/src/scenes/settings/types.ts
+++ b/frontend/src/scenes/settings/types.ts
@@ -24,7 +24,8 @@ export type SettingSectionId =
| 'environment-surveys'
| 'environment-toolbar'
| 'environment-integrations'
- | 'environment-rbac'
+ | 'environment-access-control'
+ | 'environment-role-based-access-control'
| 'environment-danger-zone'
| 'project-details'
| 'project-autocapture' // TODO: This section is for backward compat ā remove when Environments are rolled out
@@ -33,12 +34,13 @@ export type SettingSectionId =
| 'project-surveys' // TODO: This section is for backward compat ā remove when Environments are rolled out
| 'project-toolbar' // TODO: This section is for backward compat ā remove when Environments are rolled out
| 'project-integrations' // TODO: This section is for backward compat ā remove when Environments are rolled out
- | 'project-rbac' // TODO: This section is for backward compat ā remove when Environments are rolled out
+ | 'project-access-control' // TODO: This section is for backward compat ā remove when Environments are rolled out
+ | 'project-role-based-access-control' // TODO: This section is for backward compat ā remove when Environments are rolled out
| 'project-danger-zone'
| 'organization-details'
| 'organization-members'
| 'organization-authentication'
- | 'organization-rbac'
+ | 'organization-roles'
| 'organization-proxy'
| 'organization-danger-zone'
| 'user-profile'
@@ -56,6 +58,7 @@ export type SettingId =
| 'autocapture-data-attributes'
| 'date-and-time'
| 'internal-user-filtering'
+ | 'data-theme'
| 'correlation-analysis'
| 'person-display-name'
| 'path-cleaning'
@@ -72,7 +75,8 @@ export type SettingId =
| 'integration-slack'
| 'integration-other'
| 'integration-ip-allowlist'
- | 'environment-rbac'
+ | 'environment-access-control'
+ | 'environment-role-based-access-control'
| 'environment-delete'
| 'project-delete'
| 'organization-logo'
@@ -81,7 +85,7 @@ export type SettingId =
| 'members'
| 'email-members'
| 'authentication-domains'
- | 'organization-rbac'
+ | 'organization-roles'
| 'organization-delete'
| 'organization-proxy'
| 'product-description'
@@ -97,10 +101,12 @@ export type SettingId =
| 'hedgehog-mode'
| 'persons-join-mode'
| 'bounce-rate-page-view-mode'
+ | 'bounce-rate-duration'
| 'session-table-version'
| 'web-vitals-autocapture'
| 'dead-clicks-autocapture'
| 'channel-type'
+ | 'cookieless-server-hash-mode'
type FeatureFlagKey = keyof typeof FEATURE_FLAGS
diff --git a/frontend/src/scenes/settings/user/TwoFactorSettings.tsx b/frontend/src/scenes/settings/user/TwoFactorSettings.tsx
index b9e71ce8575ad..dad73b097f5a9 100644
--- a/frontend/src/scenes/settings/user/TwoFactorSettings.tsx
+++ b/frontend/src/scenes/settings/user/TwoFactorSettings.tsx
@@ -3,7 +3,6 @@ import { LemonButton, LemonModal } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { twoFactorLogic } from 'scenes/authentication/twoFactorLogic'
-import { TwoFactorSetupModal } from 'scenes/authentication/TwoFactorSetupModal'
import { membersLogic } from 'scenes/organization/membersLogic'
import { userLogic } from 'scenes/userLogic'
@@ -13,13 +12,8 @@ export function TwoFactorSettings(): JSX.Element {
const { updateUser } = useActions(userLogic)
const { loadMemberUpdates } = useActions(membersLogic)
- const {
- generateBackupCodes,
- disable2FA,
- toggleTwoFactorSetupModal,
- toggleDisable2FAModal,
- toggleBackupCodesModal,
- } = useActions(twoFactorLogic)
+ const { generateBackupCodes, disable2FA, openTwoFactorSetupModal, toggleDisable2FAModal, toggleBackupCodesModal } =
+ useActions(twoFactorLogic)
const handleSuccess = (): void => {
updateUser({})
@@ -28,8 +22,6 @@ export function TwoFactorSettings(): JSX.Element {
return (
-
-
{isDisable2FAModalOpen && (
2FA is not enabled
- toggleTwoFactorSetupModal(true)}>
+ openTwoFactorSetupModal()}>
Set up 2FA
diff --git a/frontend/src/scenes/surveys/SurveyCustomization.tsx b/frontend/src/scenes/surveys/SurveyCustomization.tsx
index c6fe0b0cbeb4f..ccbe34146a922 100644
--- a/frontend/src/scenes/surveys/SurveyCustomization.tsx
+++ b/frontend/src/scenes/surveys/SurveyCustomization.tsx
@@ -137,7 +137,11 @@ export function Customization({
<>
onAppearanceChange({ ...appearance, placeholder })}
disabled={!surveysStylingAvailable}
/>
diff --git a/frontend/src/scenes/surveys/SurveyEdit.tsx b/frontend/src/scenes/surveys/SurveyEdit.tsx
index b6bf810e73577..776a7e25c1409 100644
--- a/frontend/src/scenes/surveys/SurveyEdit.tsx
+++ b/frontend/src/scenes/surveys/SurveyEdit.tsx
@@ -2,8 +2,7 @@ import './EditSurvey.scss'
import { DndContext } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
-import { IconInfo } from '@posthog/icons'
-import { IconLock, IconPlus, IconTrash } from '@posthog/icons'
+import { IconInfo, IconLock, IconPlus, IconTrash } from '@posthog/icons'
import {
LemonButton,
LemonCalendarSelect,
@@ -538,12 +537,12 @@ export default function SurveyEdit(): JSX.Element {
appearance={value || defaultSurveyAppearance}
hasBranchingLogic={hasBranchingLogic}
deleteBranchingLogic={deleteBranchingLogic}
- customizeRatingButtons={
- survey.questions[0].type === SurveyQuestionType.Rating
- }
- customizePlaceholderText={
- survey.questions[0].type === SurveyQuestionType.Open
- }
+ customizeRatingButtons={survey.questions.some(
+ (question) => question.type === SurveyQuestionType.Rating
+ )}
+ customizePlaceholderText={survey.questions.some(
+ (question) => question.type === SurveyQuestionType.Open
+ )}
onAppearanceChange={(appearance) => {
onChange(appearance)
}}
@@ -689,7 +688,7 @@ export default function SurveyEdit(): JSX.Element {
}
}}
/>
- Don't show to users who saw a survey within the last
+ Don't show to users who saw any survey in the last
)}
-
+
- Add user targeting
+ Add property targeting
)}
{targetingFlagFilters && (
@@ -772,7 +771,7 @@ export default function SurveyEdit(): JSX.Element {
setSurveyValue('remove_targeting_flag', true)
}}
>
- Remove all user properties
+ Remove all property targeting
>
)}
diff --git a/frontend/src/scenes/surveys/SurveyView.tsx b/frontend/src/scenes/surveys/SurveyView.tsx
index f064b83899bde..267879c405c43 100644
--- a/frontend/src/scenes/surveys/SurveyView.tsx
+++ b/frontend/src/scenes/surveys/SurveyView.tsx
@@ -279,7 +279,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
content: (
-
Display mode
+
Display mode
{survey.type === SurveyType.API
? survey.type.toUpperCase()
@@ -287,9 +287,9 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
{survey.questions[0].question && (
<>
-
Type
+
Type
{SurveyQuestionLabel[survey.questions[0].type]}
-
+
{pluralize(
survey.questions.length,
'Question',
@@ -304,20 +304,20 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
)}
{survey.questions[0].type === SurveyQuestionType.Link && (
<>
- Link url
+ Link url
{survey.questions[0].link}
>
)}
{survey.start_date && (
- Start date
+ Start date
)}
{survey.end_date && (
- End date
+ End date
)}
@@ -328,7 +328,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
survey.iteration_count > 0 &&
survey.iteration_frequency_days > 0 ? (
- Schedule
+ Schedule
Repeats every {survey.iteration_frequency_days}{' '}
{pluralize(
@@ -345,7 +345,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
{surveyUsesLimit && (
<>
-
Completion conditions
+
Completion conditions
The survey will be stopped once {survey.responses_limit} {' '}
responses are received.
@@ -354,7 +354,7 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
)}
{surveyUsesAdaptiveLimit && (
<>
- Completion conditions
+ Completion conditions
Survey response collection is limited to receive{' '}
{survey.response_sampling_limit} responses every{' '}
@@ -370,10 +370,10 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
targetingFlagFilters={targetingFlagFilters}
/>
-
+
{survey.type === SurveyType.API && (
-
-
+
+
Learn how to set up API surveys{' '}
Get notified whenever a survey result is submitted
{surveyNPSScore}
- Latest NPS Score
+ Latest NPS Score
>
)}
@@ -544,7 +544,7 @@ export function SurveyResult({ disableEventsTable }: { disableEventsTable?: bool
}
})}
>
-
+
{tab === SurveysTabs.Settings && (
<>
-
+
These settings apply to new surveys in this organization.
@@ -165,7 +165,7 @@ export function Surveys(): JSX.Element {
)}
-
+
Get notified whenever a survey result is submitted
-
+
{
loadSurveyRatingResults({ questionIndex, iteration })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionIndex])
return (
@@ -301,6 +302,7 @@ export function NPSSurveyResultsBarChart({
useEffect(() => {
loadSurveyRecurringNPSResults({ questionIndex })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionIndex])
return (
@@ -397,6 +399,7 @@ export function SingleChoiceQuestionPieChart({
useEffect(() => {
loadSurveySingleChoiceResults({ questionIndex })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionIndex])
return (
@@ -499,12 +502,15 @@ export function MultipleChoiceQuestionBarChart({
useEffect(() => {
loadSurveyMultipleChoiceResults({ questionIndex })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionIndex])
useEffect(() => {
if (surveyMultipleChoiceResults?.[questionIndex]?.data?.length) {
setChartHeight(100 + 20 * surveyMultipleChoiceResults[questionIndex].data.length)
}
+ // TODO this one maybe should have questionIndex as a dependency
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [surveyMultipleChoiceResults])
return (
@@ -581,6 +587,7 @@ export function OpenTextViz({
useEffect(() => {
loadSurveyOpenTextResults({ questionIndex })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionIndex])
return (
@@ -736,9 +743,9 @@ function ResponseSummaryFeedback({ surveyId }: { surveyId: string }): JSX.Elemen
return // Already rated
}
setRating(newRating)
- posthog.capture('survey_resonse_rated', {
+ posthog.capture('ai_survey_summary_rated', {
survey_id: surveyId,
- answer_rating: rating,
+ answer_rating: newRating,
})
}
diff --git a/frontend/src/scenes/teamActivityDescriber.tsx b/frontend/src/scenes/teamActivityDescriber.tsx
index 4bde2cf4d8e50..a85dd03ac3f04 100644
--- a/frontend/src/scenes/teamActivityDescriber.tsx
+++ b/frontend/src/scenes/teamActivityDescriber.tsx
@@ -15,6 +15,8 @@ import { urls } from 'scenes/urls'
import { ActivityScope, TeamSurveyConfigType, TeamType } from '~/types'
+import { ThemeName } from './dataThemeLogic'
+
const teamActionsMapping: Record<
keyof TeamType,
(change?: ActivityChange, logItem?: ActivityLogItem) => ChangeMapping | null
@@ -365,6 +367,25 @@ const teamActionsMapping: Record<
user_access_level: () => null,
live_events_token: () => null,
product_intents: () => null,
+ default_data_theme: (change) => {
+ return {
+ description: [
+ <>
+ changed the default color theme{' '}
+ {change?.before && (
+ <>
+ from {' '}
+ >
+ )}
+ to{' '}
+
+
+
+ >,
+ ],
+ }
+ },
+ cookieless_server_hash_mode: () => null,
}
function nameAndLink(logItem?: ActivityLogItem): JSX.Element {
diff --git a/frontend/src/scenes/teamLogic.tsx b/frontend/src/scenes/teamLogic.tsx
index b27c8621db68a..19cb9ac10c840 100644
--- a/frontend/src/scenes/teamLogic.tsx
+++ b/frontend/src/scenes/teamLogic.tsx
@@ -188,7 +188,8 @@ export const teamLogic = kea([
(selectors) => [selectors.currentTeam, selectors.currentTeamLoading],
// If project has been loaded and is still null, it means the user just doesn't have access.
(currentTeam, currentTeamLoading): boolean =>
- !currentTeam?.effective_membership_level && !currentTeamLoading,
+ (!currentTeam?.effective_membership_level || currentTeam.user_access_level === 'none') &&
+ !currentTeamLoading,
],
demoOnlyProject: [
(selectors) => [selectors.currentTeam, organizationLogic.selectors.currentOrganization],
@@ -210,8 +211,9 @@ export const teamLogic = kea([
isTeamTokenResetAvailable: [
(selectors) => [selectors.currentTeam],
(currentTeam): boolean =>
- !!currentTeam?.effective_membership_level &&
- currentTeam.effective_membership_level >= OrganizationMembershipLevel.Admin,
+ (!!currentTeam?.effective_membership_level &&
+ currentTeam.effective_membership_level >= OrganizationMembershipLevel.Admin) ||
+ currentTeam?.user_access_level === 'admin',
],
testAccountFilterFrequentMistakes: [
(selectors) => [selectors.currentTeam],
diff --git a/frontend/src/scenes/trends/trendsDataLogic.ts b/frontend/src/scenes/trends/trendsDataLogic.ts
index 12c59197f3c84..9021b6c757c72 100644
--- a/frontend/src/scenes/trends/trendsDataLogic.ts
+++ b/frontend/src/scenes/trends/trendsDataLogic.ts
@@ -7,6 +7,7 @@ import {
BREAKDOWN_NULL_STRING_LABEL,
BREAKDOWN_OTHER_NUMERIC_LABEL,
BREAKDOWN_OTHER_STRING_LABEL,
+ getTrendResultCustomizationColorToken,
} from 'scenes/insights/utils'
import {
@@ -77,6 +78,8 @@ export const trendsDataLogic = kea([
'showLegend',
'vizSpecificOptions',
'yAxisScaleType',
+ 'resultCustomizationBy',
+ 'theme',
],
],
actions: [
@@ -260,6 +263,35 @@ export const trendsDataLogic = kea([
return trendsFilter?.hiddenLegendIndexes || stickinessFilter?.hiddenLegendIndexes || []
},
],
+ resultCustomizations: [(s) => [s.trendsFilter], (trendsFilter) => trendsFilter?.resultCustomizations],
+ getTrendsColorToken: [
+ (s) => [s.resultCustomizationBy, s.resultCustomizations, s.theme],
+ (resultCustomizationBy, resultCustomizations, theme) => {
+ return (dataset) => {
+ if (theme == null) {
+ return null
+ }
+ return getTrendResultCustomizationColorToken(
+ resultCustomizationBy,
+ resultCustomizations,
+ theme,
+ dataset
+ )
+ }
+ },
+ ],
+ getTrendsColor: [
+ (s) => [s.theme, s.getTrendsColorToken],
+ (theme, getTrendsColorToken) => {
+ return (dataset) => {
+ if (theme == null) {
+ return '#000000' // fallback while loading
+ }
+
+ return theme[getTrendsColorToken(dataset)!]
+ }
+ },
+ ],
})),
listeners(({ actions, values }) => ({
diff --git a/frontend/src/scenes/trends/viz/ActionsHorizontalBar.tsx b/frontend/src/scenes/trends/viz/ActionsHorizontalBar.tsx
index 0fbdecb949d35..3da099c8a3e81 100644
--- a/frontend/src/scenes/trends/viz/ActionsHorizontalBar.tsx
+++ b/frontend/src/scenes/trends/viz/ActionsHorizontalBar.tsx
@@ -1,5 +1,4 @@
import { useValues } from 'kea'
-import { getSeriesColor } from 'lib/colors'
import { useEffect, useState } from 'react'
import { insightLogic } from 'scenes/insights/insightLogic'
import { formatBreakdownLabel } from 'scenes/insights/utils'
@@ -34,11 +33,13 @@ export function ActionsHorizontalBar({ showPersonsModal = true }: ChartParams):
querySource,
breakdownFilter,
hiddenLegendIndexes,
+ getTrendsColor,
+ theme,
} = useValues(trendsDataLogic(insightProps))
function updateData(): void {
const _data = [...indexedResults]
- const colorList = indexedResults.map((_, idx) => getSeriesColor(idx))
+ const colorList = indexedResults.map(getTrendsColor)
setData([
{
@@ -71,7 +72,7 @@ export function ActionsHorizontalBar({ showPersonsModal = true }: ChartParams):
if (indexedResults) {
updateData()
}
- }, [indexedResults])
+ }, [indexedResults, theme])
return data && total > 0 ? (
0 ? indexedResults[0].days : []
- const colorList = indexedResults.map(({ seriesIndex }) => getSeriesColor(seriesIndex))
+
+ const colorList = indexedResults.map(getTrendsColor)
setData([
{
diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts
index 477064de6497e..c3dda8bda6fcf 100644
--- a/frontend/src/scenes/urls.ts
+++ b/frontend/src/scenes/urls.ts
@@ -157,6 +157,8 @@ export const urls = {
cohorts: (): string => '/cohorts',
experiment: (id: string | number): string => `/experiments/${id}`,
experiments: (): string => '/experiments',
+ experimentsSavedMetrics: (): string => '/experiments/saved-metrics',
+ experimentsSavedMetric: (id: string | number): string => `/experiments/saved-metrics/${id}`,
featureFlags: (tab?: string): string => `/feature_flags${tab ? `?tab=${tab}` : ''}`,
featureFlag: (id: string | number): string => `/feature_flags/${id}`,
featureManagement: (id?: string | number): string => `/features${id ? `/${id}` : ''}`,
@@ -171,7 +173,6 @@ export const urls = {
survey: (id: string): string => `/surveys/${id}`,
surveyTemplates: (): string => '/survey_templates',
customCss: (): string => '/themes/custom-css',
- dataModel: (): string => '/data-model',
dataWarehouse: (query?: string | Record): string =>
combineUrl(`/data-warehouse`, {}, query ? { q: typeof query === 'string' ? query : JSON.stringify(query) } : {})
.url,
diff --git a/frontend/src/scenes/web-analytics/WebAnalyticsHealthCheck.tsx b/frontend/src/scenes/web-analytics/WebAnalyticsHealthCheck.tsx
index b5374e075f9bf..51de3e725a13d 100644
--- a/frontend/src/scenes/web-analytics/WebAnalyticsHealthCheck.tsx
+++ b/frontend/src/scenes/web-analytics/WebAnalyticsHealthCheck.tsx
@@ -1,14 +1,12 @@
import { useValues } from 'kea'
-import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
import { Link } from 'lib/lemon-ui/Link'
import { ConversionGoalWarning, webAnalyticsLogic } from 'scenes/web-analytics/webAnalyticsLogic'
export const WebAnalyticsHealthCheck = (): JSX.Element | null => {
const { statusCheck, conversionGoalWarning } = useValues(webAnalyticsLogic)
- const isFlagConversionGoalWarningsSet = useFeatureFlag('WEB_ANALYTICS_WARN_CUSTOM_EVENT_NO_SESSION')
- if (conversionGoalWarning && isFlagConversionGoalWarningsSet) {
+ if (conversionGoalWarning) {
switch (conversionGoalWarning) {
case ConversionGoalWarning.CustomEventWithNoSessionId:
return (
diff --git a/frontend/src/scenes/web-analytics/WebDashboard.tsx b/frontend/src/scenes/web-analytics/WebDashboard.tsx
index ebd6e478d1e42..710577cfcf5f9 100644
--- a/frontend/src/scenes/web-analytics/WebDashboard.tsx
+++ b/frontend/src/scenes/web-analytics/WebDashboard.tsx
@@ -43,7 +43,6 @@ const Filters = (): JSX.Element => {
} = useValues(webAnalyticsLogic)
const { setWebAnalyticsFilters, setDates, setCompareFilter } = useActions(webAnalyticsLogic)
const { mobileLayout } = useValues(navigationLogic)
- const { conversionGoal } = useValues(webAnalyticsLogic)
const { featureFlags } = useValues(featureFlagLogic)
return (
@@ -63,9 +62,7 @@ const Filters = (): JSX.Element => {
setWebAnalyticsFilters={setWebAnalyticsFilters}
webAnalyticsFilters={webAnalyticsFilters}
/>
- {featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOALS] || conversionGoal ? (
-
- ) : null}
+
diff --git a/frontend/src/scenes/web-analytics/liveWebAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/liveWebAnalyticsLogic.tsx
index ef6b96398e775..66b628974045b 100644
--- a/frontend/src/scenes/web-analytics/liveWebAnalyticsLogic.tsx
+++ b/frontend/src/scenes/web-analytics/liveWebAnalyticsLogic.tsx
@@ -1,5 +1,4 @@
import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { liveEventsHostOrigin } from 'lib/utils/apiHost'
import { teamLogic } from 'scenes/teamLogic'
@@ -78,20 +77,18 @@ export const liveEventsTableLogic = kea
([
}
},
})),
- events(({ actions, cache, values }) => ({
+ events(({ actions, cache }) => ({
afterMount: () => {
- if (values.featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LIVE_USER_COUNT]) {
- actions.setNow({ now: new Date() })
- actions.pollStats()
+ actions.setNow({ now: new Date() })
+ actions.pollStats()
- cache.statsInterval = setInterval(() => {
- actions.pollStats()
- }, 30000)
+ cache.statsInterval = setInterval(() => {
+ actions.pollStats()
+ }, 30000)
- cache.nowInterval = setInterval(() => {
- actions.setNow({ now: new Date() })
- }, 500)
- }
+ cache.nowInterval = setInterval(() => {
+ actions.setNow({ now: new Date() })
+ }, 500)
},
beforeUnmount: () => {
if (cache.statsInterval) {
diff --git a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx
index cda07bc69ee55..b5b04d6b419b5 100644
--- a/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx
+++ b/frontend/src/scenes/web-analytics/tiles/WebAnalyticsTile.tsx
@@ -45,9 +45,11 @@ const VariationCell = (
{ isPercentage, reverseColors }: VariationCellProps = { isPercentage: false, reverseColors: false }
): QueryContextColumnComponent => {
const formatNumber = (value: number): string =>
- isPercentage ? `${(value * 100).toFixed(1)}%` : value.toLocaleString()
+ isPercentage ? `${(value * 100).toFixed(1)}%` : value?.toLocaleString() ?? '(empty)'
return function Cell({ value }) {
+ const { compareFilter } = useValues(webAnalyticsLogic)
+
if (!value) {
return null
}
@@ -57,10 +59,11 @@ const VariationCell = (
}
const [current, previous] = value as [number, number]
+
const pctChangeFromPrevious =
previous === 0 && current === 0 // Special case, render as flatline
? 0
- : current === null
+ : current === null || !compareFilter || compareFilter.compare === false
? null
: previous === null || previous === 0
? Infinity
@@ -142,6 +145,8 @@ const BreakdownValueTitle: QueryContextColumnTitleComponent = (props) => {
return <>Browser>
case WebStatsBreakdown.OS:
return <>OS>
+ case WebStatsBreakdown.Viewport:
+ return <>Viewport>
case WebStatsBreakdown.DeviceType:
return <>Device Type>
case WebStatsBreakdown.Country:
@@ -170,6 +175,16 @@ const BreakdownValueCell: QueryContextColumnComponent = (props) => {
const { breakdownBy } = source
switch (breakdownBy) {
+ case WebStatsBreakdown.Viewport:
+ if (Array.isArray(value)) {
+ const [width, height] = value
+ return (
+ <>
+ {width}x{height}
+ >
+ )
+ }
+ break
case WebStatsBreakdown.Country:
if (typeof value === 'string') {
const countryCode = value
@@ -261,6 +276,8 @@ export const webStatsBreakdownToPropertyName = (
return { key: '$browser', type: PropertyFilterType.Event }
case WebStatsBreakdown.OS:
return { key: '$os', type: PropertyFilterType.Event }
+ case WebStatsBreakdown.Viewport:
+ return { key: '$viewport', type: PropertyFilterType.Event }
case WebStatsBreakdown.DeviceType:
return { key: '$device_type', type: PropertyFilterType.Event }
case WebStatsBreakdown.Country:
diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx
index a535b5b54ed76..65b372d40a4b9 100644
--- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx
+++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.tsx
@@ -6,7 +6,7 @@ import api from 'lib/api'
import { FEATURE_FLAGS, RETENTION_FIRST_TIME, STALE_EVENT_SECONDS } from 'lib/constants'
import { dayjs } from 'lib/dayjs'
import { Link, PostHogComDocsURL } from 'lib/lemon-ui/Link/Link'
-import { featureFlagLogic, FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { getDefaultInterval, isNotNil, objectsEqual, updateDatesWithInterval } from 'lib/utils'
import { errorTrackingQuery } from 'scenes/error-tracking/queries'
import { urls } from 'scenes/urls'
@@ -174,6 +174,7 @@ export enum DeviceTab {
BROWSER = 'BROWSER',
OS = 'OS',
DEVICE_TYPE = 'DEVICE_TYPE',
+ VIEWPORT = 'VIEWPORT',
}
export enum PathTab {
@@ -281,7 +282,7 @@ export const webAnalyticsLogic = kea([
return { tileId, tabId }
},
setConversionGoalWarning: (warning: ConversionGoalWarning | null) => ({ warning }),
- setCompareFilter: (compareFilter: CompareFilter | null) => ({ compareFilter }),
+ setCompareFilter: (compareFilter: CompareFilter) => ({ compareFilter }),
}),
reducers({
webAnalyticsFilters: [
@@ -475,7 +476,7 @@ export const webAnalyticsLogic = kea([
},
],
compareFilter: [
- { compare: true } as CompareFilter | null,
+ { compare: true } as CompareFilter,
persistConfig,
{
setCompareFilter: (_, { compareFilter }) => compareFilter,
@@ -621,7 +622,7 @@ export const webAnalyticsLogic = kea([
display: ChartDisplayType.ActionsLineGraph,
...trendsFilter,
},
- compareFilter: compareFilter || { compare: false },
+ compareFilter,
filterTestAccounts,
conversionGoal: featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOAL_FILTERS]
? conversionGoal
@@ -696,7 +697,7 @@ export const webAnalyticsLogic = kea([
compareFilter,
filterTestAccounts,
conversionGoal,
- includeLCPScore: featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LCP_SCORE] ? true : undefined,
+ includeLCPScore: true,
},
insightProps: createInsightProps(TileId.OVERVIEW),
canOpenModal: false,
@@ -870,44 +871,43 @@ export const webAnalyticsLogic = kea([
},
}
),
- featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_LAST_CLICK]
- ? {
- id: PathTab.EXIT_CLICK,
- title: 'Outbound link clicks',
- linkText: 'Outbound clicks',
- query: {
- full: true,
- kind: NodeKind.DataTableNode,
- source: {
- kind: NodeKind.WebExternalClicksTableQuery,
- properties: webAnalyticsFilters,
- dateRange,
- sampling,
- limit: 10,
- filterTestAccounts,
- conversionGoal: featureFlags[
- FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOAL_FILTERS
- ]
- ? conversionGoal
- : undefined,
- stripQueryParams: shouldStripQueryParams,
- },
- embedded: false,
- columns: ['url', 'visitors', 'clicks'],
- },
- insightProps: createInsightProps(TileId.PATHS, PathTab.END_PATH),
- canOpenModal: true,
- docs: {
- title: 'Outbound Clicks',
- description: (
-
- You'll be able to verify when someone leaves your website by
- clicking an outbound link (to a separate domain)
-
- ),
- },
- }
- : null,
+ {
+ id: PathTab.EXIT_CLICK,
+ title: 'Outbound link clicks',
+ linkText: 'Outbound clicks',
+ query: {
+ full: true,
+ kind: NodeKind.DataTableNode,
+ source: {
+ kind: NodeKind.WebExternalClicksTableQuery,
+ properties: webAnalyticsFilters,
+ dateRange,
+ compareFilter,
+ sampling,
+ limit: 10,
+ filterTestAccounts,
+ conversionGoal: featureFlags[
+ FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOAL_FILTERS
+ ]
+ ? conversionGoal
+ : undefined,
+ stripQueryParams: shouldStripQueryParams,
+ },
+ embedded: false,
+ columns: ['url', 'visitors', 'clicks'],
+ },
+ insightProps: createInsightProps(TileId.PATHS, PathTab.END_PATH),
+ canOpenModal: true,
+ docs: {
+ title: 'Outbound Clicks',
+ description: (
+
+ You'll be able to verify when someone leaves your website by clicking an
+ outbound link (to a separate domain)
+
+ ),
+ },
+ },
] as (TabsTileTab | undefined)[]
).filter(isNotNil),
},
@@ -938,21 +938,15 @@ export const webAnalyticsLogic = kea([
Channels are the different sources that bring traffic to your
website, e.g. Paid Search, Organic Social, Direct, etc.
- {featureFlags[FEATURE_FLAGS.CUSTOM_CHANNEL_TYPE_RULES] && (
-
- You can also{' '}
-
- create custom channel types
-
- , allowing you to further categorize your channels.
-
- )}
-
+
+ You can also{' '}
+
+ create custom channel types
+
+ , allowing you to further categorize your channels.
+
Something unexpected? Try the{' '}
@@ -1127,6 +1121,13 @@ export const webAnalyticsLogic = kea([
WebStatsBreakdown.Browser
),
createTableTab(TileId.DEVICES, DeviceTab.OS, 'OS', 'OS', WebStatsBreakdown.OS),
+ createTableTab(
+ TileId.DEVICES,
+ DeviceTab.VIEWPORT,
+ 'Viewports',
+ 'Viewport',
+ WebStatsBreakdown.Viewport
+ ),
],
},
shouldShowGeographyTile
@@ -1275,7 +1276,7 @@ export const webAnalyticsLogic = kea([
}
: null,
// Hiding if conversionGoal is set already because values aren't representative
- !conversionGoal && featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_CONVERSION_GOALS]
+ !conversionGoal
? {
kind: 'query',
tileId: TileId.GOALS,
@@ -1290,6 +1291,7 @@ export const webAnalyticsLogic = kea([
kind: NodeKind.WebGoalsQuery,
properties: webAnalyticsFilters,
dateRange,
+ compareFilter,
sampling,
limit: 10,
filterTestAccounts,
@@ -1321,7 +1323,7 @@ export const webAnalyticsLogic = kea([
},
}
: null,
- featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_REPLAY]
+ !conversionGoal
? {
kind: 'replay',
tileId: TileId.REPLAY,
@@ -1823,8 +1825,7 @@ export const webAnalyticsLogic = kea([
checkCustomEventConversionGoalHasSessionIdsHelper(
conversionGoal,
breakpoint,
- actions.setConversionGoalWarning,
- values.featureFlags
+ actions.setConversionGoalWarning
),
],
}
@@ -1833,8 +1834,7 @@ export const webAnalyticsLogic = kea([
checkCustomEventConversionGoalHasSessionIdsHelper(
values.conversionGoal,
undefined,
- actions.setConversionGoalWarning,
- values.featureFlags
+ actions.setConversionGoalWarning
).catch(() => {
// ignore, this warning is just a nice-to-have, no point showing an error to the user
})
@@ -1849,13 +1849,8 @@ const isDefinitionStale = (definition: EventDefinition | PropertyDefinition): bo
const checkCustomEventConversionGoalHasSessionIdsHelper = async (
conversionGoal: WebAnalyticsConversionGoal | null,
breakpoint: BreakPointFunction | undefined,
- setConversionGoalWarning: (warning: ConversionGoalWarning | null) => void,
- featureFlags: FeatureFlagsSet
+ setConversionGoalWarning: (warning: ConversionGoalWarning | null) => void
): Promise => {
- if (!featureFlags[FEATURE_FLAGS.WEB_ANALYTICS_WARN_CUSTOM_EVENT_NO_SESSION]) {
- return
- }
-
if (!conversionGoal || !('customEventName' in conversionGoal) || !conversionGoal.customEventName) {
setConversionGoalWarning(null)
return
diff --git a/frontend/src/toolbar/debug/EventDebugMenu.tsx b/frontend/src/toolbar/debug/EventDebugMenu.tsx
index c7b47688d8f45..747fe146024f4 100644
--- a/frontend/src/toolbar/debug/EventDebugMenu.tsx
+++ b/frontend/src/toolbar/debug/EventDebugMenu.tsx
@@ -1,4 +1,4 @@
-import { BaseIcon, IconCheck, IconEye, IconLogomark, IconSearch, IconVideoCamera } from '@posthog/icons'
+import { BaseIcon, IconCheck, IconEye, IconHide, IconLogomark, IconSearch, IconVideoCamera } from '@posthog/icons'
import { useActions, useValues } from 'kea'
import { AnimatedCollapsible } from 'lib/components/AnimatedCollapsible'
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
@@ -15,10 +15,10 @@ import { EventType } from '~/types'
import { ToolbarMenu } from '../bar/ToolbarMenu'
-function showEventMenuItem(
+function checkableMenuItem(
label: string,
- count: number,
- icon: JSX.Element,
+ count: number | null,
+ icon: JSX.Element | null,
isActive: boolean,
onClick: () => void
): LemonMenuItem {
@@ -30,13 +30,15 @@ function showEventMenuItem(
{icon}
{label}
-
- ({count})
-
+ {count !== null && (
+
+ ({count})
+
+ )}
),
active: isActive,
@@ -70,25 +72,35 @@ export const EventDebugMenu = (): JSX.Element => {
searchFilteredEventsCount,
expandedEvent,
selectedEventTypes,
+ hidePostHogProperties,
+ hidePostHogFlags,
+ expandedProperties,
} = useValues(eventDebugMenuLogic)
- const { markExpanded, setSelectedEventType, setSearchText, setSearchVisible } = useActions(eventDebugMenuLogic)
+ const {
+ markExpanded,
+ setSelectedEventType,
+ setSearchText,
+ setSearchVisible,
+ setHidePostHogProperties,
+ setHidePostHogFlags,
+ } = useActions(eventDebugMenuLogic)
const showEventsMenuItems = [
- showEventMenuItem(
+ checkableMenuItem(
'PostHog Events',
searchFilteredEventsCount['posthog'],
,
selectedEventTypes.includes('posthog'),
() => setSelectedEventType('posthog', !selectedEventTypes.includes('posthog'))
),
- showEventMenuItem(
+ checkableMenuItem(
'Custom Events',
searchFilteredEventsCount['custom'],
,
selectedEventTypes.includes('custom'),
() => setSelectedEventType('custom', !selectedEventTypes.includes('custom'))
),
- showEventMenuItem(
+ checkableMenuItem(
'Replay Events',
searchFilteredEventsCount['snapshot'],
,
@@ -96,13 +108,23 @@ export const EventDebugMenu = (): JSX.Element => {
() => setSelectedEventType('snapshot', !selectedEventTypes.includes('snapshot'))
),
]
+
+ const hideThingsMenuItems = [
+ checkableMenuItem('Hide PostHog properties', null, null, hidePostHogProperties, () =>
+ setHidePostHogProperties(!hidePostHogProperties)
+ ),
+ checkableMenuItem('Hide PostHog flags', null, null, hidePostHogFlags, () =>
+ setHidePostHogFlags(!hidePostHogFlags)
+ ),
+ ]
+
return (
-
+
View events from this page as they are sent to PostHog.
{
>
@@ -167,7 +189,13 @@ export const EventDebugMenu = (): JSX.Element => {
-
+
+ }
+ label="Hide properties"
+ />
([
eventType,
enabled,
}),
+ setHidePostHogProperties: (hide: boolean) => ({ hide }),
+ setHidePostHogFlags: (hide: boolean) => ({ hide }),
}),
reducers({
+ hidePostHogProperties: [
+ false,
+ {
+ setHidePostHogProperties: (_, { hide }) => hide,
+ },
+ ],
+ hidePostHogFlags: [
+ false,
+ {
+ setHidePostHogFlags: (_, { hide }) => hide,
+ },
+ ],
searchVisible: [
false,
{
@@ -123,6 +137,42 @@ export const eventDebugMenuLogic = kea([
})
},
],
+
+ expandedProperties: [
+ (s) => [s.expandedEvent, s.events, s.hidePostHogProperties, s.hidePostHogFlags],
+ (expandedEvent, events, hidePostHogProperties, hidePostHogFlags) => {
+ if (!expandedEvent) {
+ return []
+ }
+ const theExpandedEvent = events.find((e) => e.uuid === expandedEvent)
+ if (!theExpandedEvent) {
+ return []
+ }
+
+ const propsFiltered = hidePostHogProperties
+ ? Object.fromEntries(
+ Object.entries(theExpandedEvent.properties).filter(([key]) => {
+ const isPostHogProperty = key.startsWith('$') && PROPERTY_KEYS.includes(key)
+ const isNonDollarPostHogProperty = CLOUD_INTERNAL_POSTHOG_PROPERTY_KEYS.includes(key)
+ return !isPostHogProperty && !isNonDollarPostHogProperty
+ })
+ )
+ : theExpandedEvent.properties
+
+ return Object.fromEntries(
+ Object.entries(propsFiltered).filter(([key]) => {
+ if (hidePostHogFlags) {
+ if (key === '$active_feature_flags') {
+ return false
+ } else if (key.startsWith('$feature/')) {
+ return false
+ }
+ }
+ return true
+ })
+ )
+ },
+ ],
}),
afterMount(({ values, actions }) => {
values.posthog?.on('eventCaptured', (e) => {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 514caa3635726..91062d3976ab5 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -543,6 +543,7 @@ export interface TeamType extends TeamBasicType {
primary_dashboard: number // Dashboard shown on the project homepage
live_events_columns: string[] | null // Custom columns shown on the Live Events page
live_events_token: string
+ cookieless_server_hash_mode?: CookielessServerHashMode
/** Effective access level of the user in this specific team. Null if user has no access. */
effective_membership_level: OrganizationMembershipLevel | null
@@ -558,6 +559,7 @@ export interface TeamType extends TeamBasicType {
modifiers?: HogQLQueryModifiers
default_modifiers?: HogQLQueryModifiers
product_intents?: ProductIntentType[]
+ default_data_theme?: number
}
export interface ProductIntentType {
@@ -701,6 +703,7 @@ export enum ExperimentsTabs {
Yours = 'yours',
Archived = 'archived',
Holdouts = 'holdouts',
+ SavedMetrics = 'saved-metrics',
}
export enum ActivityTab {
@@ -3309,6 +3312,8 @@ export interface Experiment {
filters: TrendsFilterType | FunnelsFilterType
metrics: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[]
metrics_secondary: (ExperimentTrendsQuery | ExperimentFunnelsQuery)[]
+ saved_metrics_ids: { id: number; metadata: { type: 'primary' | 'secondary' } }[]
+ saved_metrics: any[]
parameters: {
minimum_detectable_effect?: number
recommended_running_time?: number
@@ -3578,7 +3583,7 @@ export type GraphDataset = ChartDataset &
/** Value (count) for specific data point; only valid in the context of an xy intercept */
personUrl?: string
/** Action/event filter defition */
- action?: ActionFilter
+ action?: ActionFilter | null
}
export type GraphPoint = InteractionItem & { dataset: GraphDataset }
@@ -3758,6 +3763,7 @@ export type IntegrationKind =
| 'google-pubsub'
| 'google-cloud-storage'
| 'google-ads'
+ | 'snapchat'
export interface IntegrationType {
id: number
@@ -4488,7 +4494,7 @@ export enum SidePanelTab {
Discussion = 'discussion',
Status = 'status',
Exports = 'exports',
- // AccessControl = 'access-control',
+ AccessControl = 'access-control',
}
export interface SourceFieldOauthConfig {
@@ -4667,6 +4673,7 @@ export interface HogFunctionMappingTemplateType extends HogFunctionMappingType {
export type HogFunctionTypeType =
| 'destination'
+ | 'internal_destination'
| 'site_destination'
| 'site_app'
| 'transformation'
@@ -4699,7 +4706,7 @@ export type HogFunctionType = {
}
export type HogFunctionTemplateStatus = 'alpha' | 'beta' | 'stable' | 'free' | 'deprecated' | 'client-side'
-export type HogFunctionSubTemplateIdType = 'early_access_feature_enrollment' | 'survey_response'
+export type HogFunctionSubTemplateIdType = 'early-access-feature-enrollment' | 'survey-response' | 'activity-log'
export type HogFunctionConfigurationType = Omit<
HogFunctionType,
@@ -4828,3 +4835,28 @@ export type ReplayTemplateVariableType = {
filterGroup?: UniversalFiltersGroupValue
noTouch?: boolean
}
+
+export type DataColorThemeModel = {
+ id: number
+ name: string
+ colors: string[]
+ is_global: boolean
+}
+
+export type DataColorThemeModelPayload = Omit & {
+ id?: number
+ is_global?: boolean
+}
+
+export enum CookielessServerHashMode {
+ Disabled = 0,
+ Stateless = 1,
+ Stateful = 2,
+}
+
+/**
+ * Assistant Conversation
+ */
+export interface Conversation {
+ id: string
+}
diff --git a/hogvm/__tests__/__snapshots__/stl.hoge b/hogvm/__tests__/__snapshots__/stl.hoge
index 35cad3352f54c..08000276db21c 100644
--- a/hogvm/__tests__/__snapshots__/stl.hoge
+++ b/hogvm/__tests__/__snapshots__/stl.hoge
@@ -24,4 +24,42 @@
29, 2, "notEmpty", 1, 2, "print", 1, 35, 30, 2, "notEmpty", 1, 2, "print", 1, 35, 32, "", 2, "print", 1, 35, 32,
"-- replaceAll, replaceOne --", 2, "print", 1, 35, 32, "hello world", 32, "l", 32, "L", 2, "replaceAll", 3, 2, "print",
1, 35, 32, "hello world", 32, "l", 32, "L", 2, "replaceOne", 3, 2, "print", 1, 35, 32, "", 2, "print", 1, 35, 32,
-"-- generateUUIDv4 --", 2, "print", 1, 35, 2, "generateUUIDv4", 0, 2, "length", 1, 2, "print", 1, 35]
+"-- generateUUIDv4 --", 2, "print", 1, 35, 2, "generateUUIDv4", 0, 2, "length", 1, 2, "print", 1, 35, 32, "", 2,
+"print", 1, 35, 32, "-- isNull, isNotNull --", 2, "print", 1, 35, 31, 2, "isNull", 1, 31, 2, "isNotNull", 1, 2, "print",
+2, 35, 29, 2, "isNull", 1, 29, 2, "isNotNull", 1, 2, "print", 2, 35, 32, "banana", 2, "isNull", 1, 32, "banana", 2,
+"isNotNull", 1, 2, "print", 2, 35, 30, 2, "isNull", 1, 30, 2, "isNotNull", 1, 2, "print", 2, 35, 33, 0, 2, "isNull", 1,
+33, 0, 2, "isNotNull", 1, 2, "print", 2, 35, 33, 1, 2, "isNull", 1, 33, 1, 2, "isNotNull", 1, 2, "print", 2, 35, 32, "",
+2, "print", 1, 35, 32, "-- comparisons --", 2, "print", 1, 35, 33, 1, 33, 1, 2, "equals", 2, 33, 1, 33, 2, 2, "equals",
+2, 33, 1, 32, "1", 2, "equals", 2, 2, "print", 3, 35, 33, 2, 33, 3, 2, "notEquals", 2, 29, 5, 2, "print", 2, 35, 33, 2,
+33, 1, 2, "greater", 2, 33, 2, 33, 2, 2, "greaterOrEquals", 2, 2, "print", 2, 35, 33, 1, 33, 2, 2, "less", 2, 33, 2, 33,
+2, 2, "lessOrEquals", 2, 33, -3, 33, 2, 2, "less", 2, 2, "print", 3, 35, 30, 29, 4, 2, 33, 0, 33, 0, 4, 2, 33, 1, 33, 0,
+4, 2, 33, 1, 30, 4, 2, 33, 0, 30, 4, 2, 33, 1, 2, "or", 1, 32, "string", 2, "or", 1, 33, 100, 2, "or", 1, 2, "print", 8,
+35, 30, 29, 3, 2, 33, 0, 33, 0, 3, 2, 33, 1, 33, 0, 3, 2, 33, 1, 30, 3, 2, 33, 0, 30, 3, 2, 33, 1, 33, 1, 3, 2, 33, 1,
+2, "and", 1, 29, 2, "and", 1, 32, "string", 2, "and", 1, 33, 100, 2, "and", 1, 2, "print", 10, 35, 32, "", 2, "print",
+1, 35, 32, "-- logic --", 2, "print", 1, 35, 29, 40, 4, 32, "yes", 39, 2, 32, "no", 30, 40, 4, 32, "yes", 39, 2, 32,
+"no", 2, "print", 2, 35, 29, 40, 4, 32, "one", 39, 9, 30, 40, 4, 32, "two", 39, 2, 32, "default", 2, "print", 1, 35, 32,
+"", 2, "print", 1, 35, 32, "-- math --", 2, "print", 1, 35, 33, 3, 33, 5, 2, "min2", 2, 2, "print", 1, 35, 33, 10, 33,
+5, 2, "plus", 2, 33, 10, 33, 5, 2, "minus", 2, 2, "print", 2, 35, 34, 3.99, 2, "floor", 1, 34, 3.5, 2, "round", 1, 2,
+"print", 2, 35, 33, 5, 2, "range", 1, 2, "print", 1, 35, 33, 3, 33, 6, 2, "range", 2, 2, "print", 1, 35, 32, "", 2,
+"print", 1, 35, 32, "-- string/array --", 2, "print", 1, 35, 32, "a", 32, "a", 32, "b", 32, "c", 2, "tuple", 3, 2, "in",
+2, 32, "z", 32, "a", 32, "b", 32, "c", 2, "tuple", 3, 2, "in", 2, 2, "print", 2, 35, 32, "a", 32, "a", 32, "b", 32, "c",
+43, 3, 2, "in", 2, 32, "z", 32, "a", 32, "b", 32, "c", 43, 3, 2, "in", 2, 2, "print", 2, 35, 32, "hello", 32, "he", 2,
+"startsWith", 2, 32, "abcdef", 33, 2, 33, 3, 2, "substring", 3, 2, "print", 2, 35, 31, 31, 32, "firstNonNull", 2,
+"coalesce", 3, 32, "notNull", 2, "assumeNotNull", 1, 2, "print", 2, 35, 32, "", 2, "print", 1, 35, 32, "-- date --", 2,
+"print", 1, 35, 32, "2024-12-18T00:00:00Z", 2, "toDateTime", 1, 2, "toYear", 1, 32, "2024-12-18T00:00:00Z", 2,
+"toDateTime", 1, 2, "toMonth", 1, 2, "print", 2, 35, 2, "now", 0, 2, "typeof", 1, 2, "print", 1, 35, 32,
+"2024-12-18T11:11:11Z", 2, "toDateTime", 1, 2, "toStartOfDay", 1, 32, "2024-12-18T11:11:11Z", 2, "toDateTime", 1, 2,
+"toStartOfWeek", 1, 2, "print", 2, 35, 32, "2024-12-18T00:00:00Z", 2, "toDateTime", 1, 2, "toYYYYMM", 1, 2, "print", 1,
+35, 32, "day", 33, 1, 32, "2024-12-18", 2, "toDate", 1, 2, "dateAdd", 3, 32, "day", 32, "2024-12-18", 2, "toDate", 1,
+32, "day", 33, 5, 32, "2024-12-18", 2, "toDate", 1, 2, "dateAdd", 3, 2, "dateDiff", 3, 2, "print", 2, 35, 32, "day", 32,
+"2024-12-18T12:34:56Z", 2, "toDateTime", 1, 2, "dateTrunc", 2, 2, "print", 1, 35, 32, "2024-12-18", 2, "toDate", 1, 33,
+3, 2, "addDays", 2, 2, "print", 1, 35, 33, 5, 2, "toIntervalDay", 1, 33, 2, 2, "toIntervalMonth", 1, 2, "print", 2, 35,
+2, "today", 0, 2, "typeof", 1, 2, "print", 1, 35, 32, "", 2, "print", 1, 35, 32, "-- json --", 2, "print", 1, 35, 32,
+"{\"a\":123.1}", 32, "a", 2, "JSONExtractInt", 2, 2, "jsonStringify", 1, 32, "{\"a\":\"hello\"}", 32, "a", 2,
+"JSONExtractInt", 2, 2, "jsonStringify", 1, 2, "print", 2, 35, 32, "{\"a\":123.1}", 32, "a", 2, "JSONExtractFloat", 2,
+2, "jsonStringify", 1, 32, "{\"a\":\"hello\"}", 32, "a", 2, "JSONExtractFloat", 2, 2, "jsonStringify", 1, 2, "print", 2,
+35, 32, "{\"a\":123.1}", 32, "a", 2, "JSONExtractString", 2, 2, "jsonStringify", 1, 32, "{\"a\":\"hello\"}", 32, "a", 2,
+"JSONExtractString", 2, 2, "jsonStringify", 1, 2, "print", 2, 35, 32, "{\"a\":123}", 32, "a", 2, "JSONExtractArrayRaw",
+2, 2, "jsonStringify", 1, 32, "{\"a\":\"hello\"}", 32, "a", 2, "JSONExtractArrayRaw", 2, 2, "jsonStringify", 1, 2,
+"print", 2, 35, 32, "{\"a\":[]}", 32, "a", 2, "JSONExtractArrayRaw", 2, 2, "jsonStringify", 1, 32,
+"{\"a\":[\"hello\"]}", 32, "a", 2, "JSONExtractArrayRaw", 2, 2, "jsonStringify", 1, 2, "print", 2, 35]
diff --git a/hogvm/__tests__/__snapshots__/stl.js b/hogvm/__tests__/__snapshots__/stl.js
index 9af5082cd8655..247aa1ec4abed 100644
--- a/hogvm/__tests__/__snapshots__/stl.js
+++ b/hogvm/__tests__/__snapshots__/stl.js
@@ -1,13 +1,162 @@
function upper (value) { return value.toUpperCase() }
+function __x_typeof (value) {
+ if (value === null || value === undefined) { return 'null'
+ } else if (__isHogDateTime(value)) { return 'datetime'
+ } else if (__isHogDate(value)) { return 'date'
+ } else if (__isHogError(value)) { return 'error'
+ } else if (typeof value === 'function') { return 'function'
+ } else if (Array.isArray(value)) { if (value.__isHogTuple) { return 'tuple' } return 'array'
+ } else if (typeof value === 'object') { return 'object'
+ } else if (typeof value === 'number') { return Number.isInteger(value) ? 'integer' : 'float'
+ } else if (typeof value === 'string') { return 'string'
+ } else if (typeof value === 'boolean') { return 'boolean' }
+ return 'unknown'
+}
function tuple (...args) { const tuple = args.slice(); tuple.__isHogTuple = true; return tuple; }
+function today() {
+ const now = new Date();
+ return __toHogDate(now.getUTCFullYear(), now.getUTCMonth()+1, now.getUTCDate());
+}
+function toYear(value) { return extract('year', value) }
+function toYYYYMM(value) {
+ const y = extract('year', value);
+ const m = extract('month', value);
+ return y*100 + m;
+}
+function toStartOfWeek(value) {
+ if (!__isHogDateTime(value) && !__isHogDate(value)) {
+ throw new Error('Expected HogDate or HogDateTime');
+ }
+ let d;
+ if (__isHogDate(value)) {
+ d = new Date(Date.UTC(value.year, value.month - 1, value.day));
+ } else {
+ d = new Date(value.dt * 1000);
+ }
+ // Monday=1,... Sunday=7
+ // getUTCDay(): Sunday=0,... Saturday=6
+ // We want ISO weekday: Monday=1,... Sunday=7
+ let dayOfWeek = d.getUTCDay(); // Sunday=0,...
+ let isoWeekday = dayOfWeek === 0 ? 7 : dayOfWeek;
+
+ // subtract isoWeekday-1 days
+ const start = new Date(d.getTime() - (isoWeekday - 1) * 24 * 3600 * 1000);
+
+ // Zero out hours, minutes, seconds, ms
+ start.setUTCHours(0, 0, 0, 0);
+
+ return { __hogDateTime__: true, dt: start.getTime() / 1000, zone: (__isHogDateTime(value) ? value.zone : 'UTC') };
+}
+function toStartOfDay(value) {
+ if (!__isHogDateTime(value) && !__isHogDate(value)) {
+ throw new Error('Expected HogDate or HogDateTime for toStartOfDay');
+ }
+ if (__isHogDate(value)) {
+ value = __toHogDateTime(Date.UTC(value.year, value.month-1, value.day)/1000, 'UTC');
+ }
+ return dateTrunc('day', value);
+}
+function toMonth(value) { return extract('month', value) }
+function toIntervalMonth(val) { return __toHogInterval(val, 'month') }
+function toIntervalDay(val) { return __toHogInterval(val, 'day') }
+function toDateTime (input, zone) { return __toDateTime(input, zone) }
+function toDate (input) { return __toDate(input) }
+function substring(s, start, length) {
+ if (typeof s !== 'string') return '';
+ const startIdx = start - 1;
+ if (startIdx < 0 || length < 0) return '';
+ const endIdx = startIdx + length;
+ return startIdx < s.length ? s.slice(startIdx, endIdx) : '';
+}
+function startsWith(str, prefix) {
+ return typeof str === 'string' && typeof prefix === 'string' && str.startsWith(prefix);
+}
+function round(a) { return Math.round(a) }
function reverse (value) { return value.split('').reverse().join('') }
function replaceOne (str, searchValue, replaceValue) { return str.replace(searchValue, replaceValue) }
function replaceAll (str, searchValue, replaceValue) { return str.replaceAll(searchValue, replaceValue) }
+function range(...args) {
+ if (args.length === 1) {
+ const end = args[0];
+ return Array.from({length:end}, (_,i)=>i);
+ } else {
+ const start = args[0];
+ const end = args[1];
+ return Array.from({length:end - start}, (_,i)=>start+i);
+ }
+}
function print (...args) { console.log(...args.map(__printHogStringOutput)) }
+function plus(a, b) { return a + b }
+function or(...args) { return args.some(Boolean) }
+function now () { return __now() }
+function notEquals(a, b) { return a !== b }
function notEmpty (value) { return !empty(value) }
+function minus(a, b) { return a - b }
+function min2(a, b) { return a < b ? a : b }
function lower (value) { return value.toLowerCase() }
+function lessOrEquals(a, b) { return a <= b }
+function less(a, b) { return a < b }
function length (value) { return value.length }
+function jsonStringify (value, spacing) {
+ function convert(x, marked) {
+ if (!marked) { marked = new Set() }
+ if (typeof x === 'object' && x !== null) {
+ if (marked.has(x)) { return null }
+ marked.add(x)
+ try {
+ if (x instanceof Map) {
+ const obj = {}
+ x.forEach((value, key) => { obj[convert(key, marked)] = convert(value, marked) })
+ return obj
+ }
+ if (Array.isArray(x)) { return x.map((v) => convert(v, marked)) }
+ if (__isHogDateTime(x) || __isHogDate(x) || __isHogError(x)) { return x }
+ if (typeof x === 'function') { return `fn<${x.name || 'lambda'}(${x.length})>` }
+ const obj = {}; for (const key in x) { obj[key] = convert(x[key], marked) }
+ return obj
+ } finally {
+ marked.delete(x)
+ }
+ }
+ return x
+ }
+ if (spacing && typeof spacing === 'number' && spacing > 0) {
+ return JSON.stringify(convert(value), null, spacing)
+ }
+ return JSON.stringify(convert(value), (key, val) => typeof val === 'function' ? `fn<${val.name || 'lambda'}(${val.length})>` : val)
+}
+function isNull (value) { return value === null || value === undefined }
+function isNotNull (value) { return value !== null && value !== undefined }
+function __x_in(val, arr) {
+ if (Array.isArray(arr) || (arr && arr.__isHogTuple)) {
+ return arr.includes(val);
+ }
+ return false;
+}
+function greaterOrEquals(a, b) { return a >= b }
+function greater(a, b) { return a > b }
function generateUUIDv4 () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16) })}
+function floor(a) { return Math.floor(a) }
+function extract(part, val) {
+ function toDate(obj) {
+ if (__isHogDateTime(obj)) {
+ return new Date(obj.dt * 1000);
+ } else if (__isHogDate(obj)) {
+ return new Date(Date.UTC(obj.year, obj.month - 1, obj.day));
+ } else {
+ return new Date(obj);
+ }
+ }
+ const date = toDate(val);
+ if (part === 'year') return date.getUTCFullYear();
+ else if (part === 'month') return date.getUTCMonth() + 1;
+ else if (part === 'day') return date.getUTCDate();
+ else if (part === 'hour') return date.getUTCHours();
+ else if (part === 'minute') return date.getUTCMinutes();
+ else if (part === 'second') return date.getUTCSeconds();
+ else throw new Error("Unknown extract part: " + part);
+}
+function equals(a, b) { return a === b }
function encodeURLComponent (str) { return encodeURIComponent(str) }
function empty (value) {
if (typeof value === 'object') {
@@ -16,8 +165,116 @@ function empty (value) {
} else if (typeof value === 'number' || typeof value === 'boolean') { return false }
return !value }
function decodeURLComponent (str) { return decodeURIComponent(str) }
+function dateTrunc(unit, val) {
+ if (!__isHogDateTime(val)) {
+ throw new Error('Expected a DateTime for dateTrunc');
+ }
+ const zone = val.zone || 'UTC';
+ const date = new Date(val.dt * 1000);
+ let year = date.getUTCFullYear();
+ let month = date.getUTCMonth();
+ let day = date.getUTCDate();
+ let hour = date.getUTCHours();
+ let minute = date.getUTCMinutes();
+ let second = 0;
+ let ms = 0;
+
+ if (unit === 'year') {
+ month = 0; day = 1; hour = 0; minute = 0; second = 0;
+ } else if (unit === 'month') {
+ day = 1; hour = 0; minute = 0; second = 0;
+ } else if (unit === 'day') {
+ hour = 0; minute = 0; second = 0;
+ } else if (unit === 'hour') {
+ minute = 0; second = 0;
+ } else if (unit === 'minute') {
+ second = 0;
+ } else {
+ throw new Error("Unsupported unit for dateTrunc: " + unit);
+ }
+
+ const truncated = new Date(Date.UTC(year, month, day, hour, minute, second, ms));
+ return { __hogDateTime__: true, dt: truncated.getTime()/1000, zone: zone };
+}
+function dateDiff(unit, startVal, endVal) {
+ function toDateTime(obj) {
+ if (__isHogDateTime(obj)) {
+ return new Date(obj.dt * 1000);
+ } else if (__isHogDate(obj)) {
+ return new Date(Date.UTC(obj.year, obj.month - 1, obj.day));
+ } else {
+ return new Date(obj);
+ }
+ }
+ const start = toDateTime(startVal);
+ const end = toDateTime(endVal);
+ const diffMs = end - start;
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+ if (unit === 'day') {
+ return diffDays;
+ } else if (unit === 'hour') {
+ return Math.floor(diffMs / (1000 * 60 * 60));
+ } else if (unit === 'minute') {
+ return Math.floor(diffMs / (1000 * 60));
+ } else if (unit === 'second') {
+ return Math.floor(diffMs / 1000);
+ } else if (unit === 'week') {
+ return Math.floor(diffDays / 7);
+ } else if (unit === 'month') {
+ // Approx months difference
+ const sy = start.getUTCFullYear();
+ const sm = start.getUTCMonth() + 1;
+ const ey = end.getUTCFullYear();
+ const em = end.getUTCMonth() + 1;
+ return (ey - sy)*12 + (em - sm);
+ } else if (unit === 'year') {
+ return end.getUTCFullYear() - start.getUTCFullYear();
+ } else {
+ throw new Error("Unsupported unit for dateDiff: " + unit);
+ }
+}
+function dateAdd(unit, amount, datetime) {
+ // transform unit if needed (week -> day, year -> month)
+ if (unit === 'week') {
+ unit = 'day';
+ amount = amount * 7;
+ } else if (unit === 'year') {
+ unit = 'month';
+ amount = amount * 12;
+ }
+ const interval = __toHogInterval(amount, unit);
+ return __applyIntervalToDateTime(datetime, interval);
+}
+function coalesce(...args) {
+ for (let a of args) {
+ if (a !== null && a !== undefined) return a;
+ }
+ return null;
+}
function base64Encode (str) { return Buffer.from(str).toString('base64') }
function base64Decode (str) { return Buffer.from(str, 'base64').toString() }
+function assumeNotNull(value) {
+ if (value === null || value === undefined) {
+ throw new Error("Value is null in assumeNotNull");
+ }
+ return value;
+}
+function and(...args) { return args.every(Boolean) }
+function addDays(dateOrDt, days) {
+ const interval = __toHogInterval(days, 'day');
+ return __applyIntervalToDateTime(dateOrDt, interval);
+}
+function __toHogInterval(value, unit) {
+ return { __hogInterval__: true, value: value, unit: unit };
+}
+function __toDateTime(input, zone) { let dt;
+ if (typeof input === 'number') { dt = input; }
+ else { const date = new Date(input); if (isNaN(date.getTime())) { throw new Error('Invalid date input'); } dt = date.getTime() / 1000; }
+ return { __hogDateTime__: true, dt: dt, zone: zone || 'UTC' }; }
+function __toDate(input) { let date;
+ if (typeof input === 'number') { date = new Date(input * 1000); } else { date = new Date(input); }
+ if (isNaN(date.getTime())) { throw new Error('Invalid date input'); }
+ return { __hogDate__: true, year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() }; }
function __printHogStringOutput(obj) { if (typeof obj === 'string') { return obj } return __printHogValue(obj) }
function __printHogValue(obj, marked = new Set()) {
if (typeof obj === 'object' && obj !== null && obj !== undefined) {
@@ -42,9 +299,8 @@ function __printHogValue(obj, marked = new Set()) {
if (typeof obj === 'function') return `fn<${__escapeIdentifier(obj.name || 'lambda')}(${obj.length})>`;
return obj.toString();
}
+function __now(zone) { return __toHogDateTime(Date.now() / 1000, zone) }
function __isHogError(obj) {return obj && obj.__hogError__ === true}
-function __isHogDateTime(obj) { return obj && obj.__hogDateTime__ === true }
-function __isHogDate(obj) { return obj && obj.__hogDate__ === true }
function __escapeString(value) {
const singlequoteEscapeCharsMap = { '\b': '\\b', '\f': '\\f', '\r': '\\r', '\n': '\\n', '\t': '\\t', '\0': '\\0', '\v': '\\v', '\\': '\\\\', "'": "\\'" }
return `'${value.split('').map((c) => singlequoteEscapeCharsMap[c] || c).join('')}'`;
@@ -55,6 +311,130 @@ function __escapeIdentifier(identifier) {
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) return identifier;
return `\`${identifier.split('').map((c) => backquoteEscapeCharsMap[c] || c).join('')}\``;
}
+function __applyIntervalToDateTime(base, interval) {
+ // base can be HogDate or HogDateTime
+ if (!(__isHogDate(base) || __isHogDateTime(base))) {
+ throw new Error("Expected a HogDate or HogDateTime");
+ }
+
+ let zone = __isHogDateTime(base) ? (base.zone || 'UTC') : 'UTC';
+
+ function toDate(obj) {
+ if (__isHogDateTime(obj)) {
+ return new Date(obj.dt * 1000);
+ } else {
+ return new Date(Date.UTC(obj.year, obj.month - 1, obj.day));
+ }
+ }
+
+ const dt = toDate(base);
+ const value = interval.value;
+ let unit = interval.unit;
+
+ // Expand weeks/years if needed
+ if (unit === 'week') {
+ unit = 'day';
+ interval.value = value * 7;
+ } else if (unit === 'year') {
+ unit = 'month';
+ interval.value = value * 12;
+ }
+
+ let year = dt.getUTCFullYear();
+ let month = dt.getUTCMonth() + 1;
+ let day = dt.getUTCDate();
+ let hours = dt.getUTCHours();
+ let minutes = dt.getUTCMinutes();
+ let seconds = dt.getUTCSeconds();
+ let ms = dt.getUTCMilliseconds();
+
+ if (unit === 'day') {
+ day += interval.value;
+ } else if (unit === 'hour') {
+ hours += interval.value;
+ } else if (unit === 'minute') {
+ minutes += interval.value;
+ } else if (unit === 'second') {
+ seconds += interval.value;
+ } else if (unit === 'month') {
+ month += interval.value;
+ // Adjust year and month
+ year += Math.floor((month - 1) / 12);
+ month = ((month - 1) % 12) + 1;
+ // If day is invalid for the new month, clamp it
+ let maxDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ if (day > maxDay) { day = maxDay; }
+ } else {
+ throw new Error("Unsupported interval unit: " + unit);
+ }
+
+ const newDt = new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds, ms));
+
+ if (__isHogDate(base)) {
+ return __toHogDate(newDt.getUTCFullYear(), newDt.getUTCMonth() + 1, newDt.getUTCDate());
+ } else {
+ return __toHogDateTime(newDt.getTime() / 1000, zone);
+ }
+}
+function __toHogDateTime(timestamp, zone) {
+ if (__isHogDate(timestamp)) {
+ const date = new Date(Date.UTC(timestamp.year, timestamp.month - 1, timestamp.day));
+ const dt = date.getTime() / 1000;
+ return { __hogDateTime__: true, dt: dt, zone: zone || 'UTC' };
+ }
+ return { __hogDateTime__: true, dt: timestamp, zone: zone || 'UTC' }; }
+function __toHogDate(year, month, day) { return { __hogDate__: true, year: year, month: month, day: day, } }
+function __isHogDateTime(obj) { return obj && obj.__hogDateTime__ === true }
+function __isHogDate(obj) { return obj && obj.__hogDate__ === true }
+function JSONExtractString(obj, ...path) {
+ try {
+ if (typeof obj === 'string') { obj = JSON.parse(obj); }
+ } catch (e) { return null; }
+ const val = __getNestedValue(obj, path, true);
+ return val != null ? String(val) : null;
+}
+function JSONExtractInt(obj, ...path) {
+ try {
+ if (typeof obj === 'string') { obj = JSON.parse(obj); }
+ } catch (e) { return null; }
+ const val = __getNestedValue(obj, path, true);
+ const i = parseInt(val);
+ return isNaN(i) ? null : i;
+}
+function JSONExtractFloat(obj, ...path) {
+ try {
+ if (typeof obj === 'string') { obj = JSON.parse(obj); }
+ } catch (e) { return null; }
+ const val = __getNestedValue(obj, path, true);
+ const f = parseFloat(val);
+ return isNaN(f) ? null : f;
+}
+function JSONExtractArrayRaw(obj, ...path) {
+ try {
+ if (typeof obj === 'string') { obj = JSON.parse(obj); }
+ } catch (e) { return null; }
+ const val = __getNestedValue(obj, path, true);
+ return Array.isArray(val) ? val : null;
+}
+function __getNestedValue(obj, path, allowNull = false) {
+ let current = obj
+ for (const key of path) {
+ if (current == null) {
+ return null
+ }
+ if (current instanceof Map) {
+ current = current.get(key)
+ } else if (typeof current === 'object' && current !== null) {
+ current = current[key]
+ } else {
+ return null
+ }
+ }
+ if (current === null && !allowNull) {
+ return null
+ }
+ return current
+}
print("-- empty, notEmpty, length, lower, upper, reverse --");
if (!!(empty("") && notEmpty("234"))) {
@@ -119,3 +499,54 @@ print(replaceOne("hello world", "l", "L"));
print("");
print("-- generateUUIDv4 --");
print(length(generateUUIDv4()));
+print("");
+print("-- isNull, isNotNull --");
+print(isNull(null), isNotNull(null));
+print(isNull(true), isNotNull(true));
+print(isNull("banana"), isNotNull("banana"));
+print(isNull(false), isNotNull(false));
+print(isNull(0), isNotNull(0));
+print(isNull(1), isNotNull(1));
+print("");
+print("-- comparisons --");
+print(equals(1, 1), equals(1, 2), equals(1, "1"));
+print(notEquals(2, 3), (!true));
+print(greater(2, 1), greaterOrEquals(2, 2));
+print(less(1, 2), lessOrEquals(2, 2), less(-3, 2));
+print(!!(false || true), !!(0 || 0), !!(1 || 0), !!(1 || false), !!(0 || false), or(1), or("string"), or(100));
+print(!!(false && true), !!(0 && 0), !!(1 && 0), !!(1 && false), !!(0 && false), !!(1 && 1), and(1), and(true), and("string"), and(100));
+print("");
+print("-- logic --");
+print((true ? "yes" : "no"), (false ? "yes" : "no"));
+print((true ? "one" : (false ? "two" : "default")));
+print("");
+print("-- math --");
+print(min2(3, 5));
+print(plus(10, 5), minus(10, 5));
+print(floor(3.99), round(3.5));
+print(range(5));
+print(range(3, 6));
+print("");
+print("-- string/array --");
+print(__x_in("a", tuple("a", "b", "c")), __x_in("z", tuple("a", "b", "c")));
+print(__x_in("a", ["a", "b", "c"]), __x_in("z", ["a", "b", "c"]));
+print(startsWith("hello", "he"), substring("abcdef", 2, 3));
+print(coalesce(null, null, "firstNonNull"), assumeNotNull("notNull"));
+print("");
+print("-- date --");
+print(toYear(toDateTime("2024-12-18T00:00:00Z")), toMonth(toDateTime("2024-12-18T00:00:00Z")));
+print(__x_typeof(now()));
+print(toStartOfDay(toDateTime("2024-12-18T11:11:11Z")), toStartOfWeek(toDateTime("2024-12-18T11:11:11Z")));
+print(toYYYYMM(toDateTime("2024-12-18T00:00:00Z")));
+print(dateAdd("day", 1, toDate("2024-12-18")), dateDiff("day", toDate("2024-12-18"), dateAdd("day", 5, toDate("2024-12-18"))));
+print(dateTrunc("day", toDateTime("2024-12-18T12:34:56Z")));
+print(addDays(toDate("2024-12-18"), 3));
+print(toIntervalDay(5), toIntervalMonth(2));
+print(__x_typeof(today()));
+print("");
+print("-- json --");
+print(jsonStringify(JSONExtractInt("{\"a\":123.1}", "a")), jsonStringify(JSONExtractInt("{\"a\":\"hello\"}", "a")));
+print(jsonStringify(JSONExtractFloat("{\"a\":123.1}", "a")), jsonStringify(JSONExtractFloat("{\"a\":\"hello\"}", "a")));
+print(jsonStringify(JSONExtractString("{\"a\":123.1}", "a")), jsonStringify(JSONExtractString("{\"a\":\"hello\"}", "a")));
+print(jsonStringify(JSONExtractArrayRaw("{\"a\":123}", "a")), jsonStringify(JSONExtractArrayRaw("{\"a\":\"hello\"}", "a")));
+print(jsonStringify(JSONExtractArrayRaw("{\"a\":[]}", "a")), jsonStringify(JSONExtractArrayRaw("{\"a\":[\"hello\"]}", "a")));
diff --git a/hogvm/__tests__/__snapshots__/stl.stdout b/hogvm/__tests__/__snapshots__/stl.stdout
index f80a819686adc..b9f6eabbc5c82 100644
--- a/hogvm/__tests__/__snapshots__/stl.stdout
+++ b/hogvm/__tests__/__snapshots__/stl.stdout
@@ -57,3 +57,54 @@ heLlo world
-- generateUUIDv4 --
36
+
+-- isNull, isNotNull --
+true false
+false true
+false true
+false true
+false true
+false true
+
+-- comparisons --
+true false false
+true false
+true true
+true true true
+true false true true false true true true
+false false false false false true true true true true
+
+-- logic --
+yes no
+one
+
+-- math --
+3
+15 5
+3 4
+[0, 1, 2, 3, 4]
+[3, 4, 5]
+
+-- string/array --
+true false
+true false
+true bcd
+firstNonNull notNull
+
+-- date --
+2024 12
+datetime
+DateTime(1734480000.0, 'UTC') DateTime(1734307200.0, 'UTC')
+202412
+Date(2024, 12, 19) 5
+DateTime(1734480000.0, 'UTC')
+Date(2024, 12, 21)
+{'__hogInterval__': true, 'value': 5, 'unit': 'day'} {'__hogInterval__': true, 'value': 2, 'unit': 'month'}
+date
+
+-- json --
+123 null
+123.1 null
+"123.1" "hello"
+null null
+[] ["hello"]
diff --git a/hogvm/__tests__/stl.hog b/hogvm/__tests__/stl.hog
index 2343492d90d71..a00871f041b38 100644
--- a/hogvm/__tests__/stl.hog
+++ b/hogvm/__tests__/stl.hog
@@ -57,4 +57,54 @@ print(replaceOne('hello world', 'l', 'L'))
print('')
print('-- generateUUIDv4 --')
print(length(generateUUIDv4()))
-
+print('')
+print('-- isNull, isNotNull --')
+print(isNull(null), isNotNull(null))
+print(isNull(true), isNotNull(true))
+print(isNull('banana'), isNotNull('banana'))
+print(isNull(false), isNotNull(false))
+print(isNull(0), isNotNull(0))
+print(isNull(1), isNotNull(1))
+print('')
+print('-- comparisons --')
+print(equals(1,1), equals(1,2), equals(1, '1'))
+print(notEquals(2,3), not(true))
+print(greater(2,1), greaterOrEquals(2,2))
+print(less(1,2), lessOrEquals(2,2), less(-3, 2))
+print(or(false, true), or(0, 0), or(1, 0), or(1, false), or(0, false), or(1), or('string'), or(100))
+print(and(false, true), and(0, 0), and(1, 0), and(1, false), and(0, false), and(1, 1), and(1), and(true), and('string'), and(100))
+print('')
+print('-- logic --')
+print(if(true, 'yes', 'no'), if(false, 'yes', 'no'))
+print(multiIf(true, 'one', false, 'two', 'default'))
+print('')
+print('-- math --')
+print(min2(3,5))
+print(plus(10,5), minus(10,5))
+print(floor(3.99), round(3.5))
+print(range(5))
+print(range(3,6))
+print('')
+print('-- string/array --')
+print(in('a', tuple('a','b','c')), in('z', tuple('a','b','c')))
+print(in('a', ['a','b','c']), in('z', ['a','b','c']))
+print(startsWith('hello','he'), substring('abcdef',2,3))
+print(coalesce(null, null, 'firstNonNull'), assumeNotNull('notNull'))
+print('')
+print('-- date --')
+print(toYear(toDateTime('2024-12-18T00:00:00Z')), toMonth(toDateTime('2024-12-18T00:00:00Z')))
+print(typeof(now()))
+print(toStartOfDay(toDateTime('2024-12-18T11:11:11Z')), toStartOfWeek(toDateTime('2024-12-18T11:11:11Z')))
+print(toYYYYMM(toDateTime('2024-12-18T00:00:00Z')))
+print(dateAdd('day', 1, toDate('2024-12-18')), dateDiff('day', toDate('2024-12-18'), dateAdd('day', 5, toDate('2024-12-18'))))
+print(dateTrunc('day', toDateTime('2024-12-18T12:34:56Z')))
+print(addDays(toDate('2024-12-18'), 3))
+print(toIntervalDay(5), toIntervalMonth(2))
+print(typeof(today()))
+print('')
+print('-- json --')
+print(jsonStringify(JSONExtractInt('{"a":123.1}', 'a')), jsonStringify(JSONExtractInt('{"a":"hello"}','a')))
+print(jsonStringify(JSONExtractFloat('{"a":123.1}', 'a')), jsonStringify(JSONExtractFloat('{"a":"hello"}','a')))
+print(jsonStringify(JSONExtractString('{"a":123.1}', 'a')), jsonStringify(JSONExtractString('{"a":"hello"}','a')))
+print(jsonStringify(JSONExtractArrayRaw('{"a":123}', 'a')), jsonStringify(JSONExtractArrayRaw('{"a":"hello"}','a')))
+print(jsonStringify(JSONExtractArrayRaw('{"a":[]}', 'a')), jsonStringify(JSONExtractArrayRaw('{"a":["hello"]}','a')))
diff --git a/hogvm/python/objects.py b/hogvm/python/objects.py
index aa03cb656f8d7..9dc2acdcb09bb 100644
--- a/hogvm/python/objects.py
+++ b/hogvm/python/objects.py
@@ -82,3 +82,15 @@ def is_hog_upvalue(obj: Any) -> bool:
and "value" in obj
and "id" in obj
)
+
+
+def is_hog_interval(obj: Any) -> bool:
+ return isinstance(obj, dict) and obj.get("__hogInterval__") is True
+
+
+def to_hog_interval(value: int, unit: str):
+ return {
+ "__hogInterval__": True,
+ "value": value,
+ "unit": unit,
+ }
diff --git a/hogvm/python/stl/__init__.py b/hogvm/python/stl/__init__.py
index 177b1ecf52512..4945743cdb881 100644
--- a/hogvm/python/stl/__init__.py
+++ b/hogvm/python/stl/__init__.py
@@ -1,5 +1,6 @@
import dataclasses
import datetime
+import math
import time
from typing import Any, Optional, TYPE_CHECKING
from collections.abc import Callable
@@ -23,7 +24,7 @@
is_hog_date,
)
from .crypto import sha256Hex, md5Hex, sha256HmacChainHex
-from ..objects import is_hog_error, new_hog_error, is_hog_callable, is_hog_closure
+from ..objects import is_hog_error, new_hog_error, is_hog_callable, is_hog_closure, to_hog_interval
from ..utils import like, get_nested_value
if TYPE_CHECKING:
@@ -421,6 +422,465 @@ def _typeof(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]]
return "unknown"
+def apply_interval_to_datetime(dt: dict, interval: dict) -> dict:
+ # interval["unit"] in {"day", "hour", "minute", "month"}
+ if not (is_hog_date(dt) or is_hog_datetime(dt)):
+ raise ValueError("Expected a HogDate or HogDateTime")
+
+ zone = dt["zone"] if is_hog_datetime(dt) else "UTC"
+ if is_hog_datetime(dt):
+ base_dt = datetime.datetime.utcfromtimestamp(dt["dt"])
+ base_dt = pytz.timezone(zone).localize(base_dt)
+ else:
+ base_dt = datetime.datetime(dt["year"], dt["month"], dt["day"], tzinfo=pytz.timezone(zone))
+
+ value = interval["value"]
+ unit = interval["unit"]
+
+ if unit == "day":
+ base_dt = base_dt + datetime.timedelta(days=value)
+ elif unit == "hour":
+ base_dt = base_dt + datetime.timedelta(hours=value)
+ elif unit == "minute":
+ base_dt = base_dt + datetime.timedelta(minutes=value)
+ elif unit == "second":
+ base_dt = base_dt + datetime.timedelta(seconds=value)
+ elif unit == "month":
+ # Add months by incrementing month/year
+ # Adding months can overflow year and month boundaries
+ # We'll do a rough calculation
+ year = base_dt.year
+ month = base_dt.month + value
+ day = base_dt.day
+ # adjust year and month
+ year += (month - 1) // 12
+ month = ((month - 1) % 12) + 1
+ # If day is invalid for the new month, clamp
+ # For simplicity, clamp to last valid day of month
+ # This matches ClickHouse dateAdd('month',...) behavior
+ while True:
+ try:
+ base_dt = base_dt.replace(year=year, month=month, day=day)
+ break
+ except ValueError:
+ day -= 1
+ # no need to add timedelta here
+ else:
+ raise ValueError(f"Unknown interval unit {unit}")
+
+ if is_hog_date(dt):
+ return {
+ "__hogDate__": True,
+ "year": base_dt.year,
+ "month": base_dt.month,
+ "day": base_dt.day,
+ }
+ else:
+ return {
+ "__hogDateTime__": True,
+ "dt": base_dt.timestamp(),
+ "zone": zone,
+ }
+
+
+def date_add(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # dateAdd(unit, amount, datetime)
+ # unit: 'second','minute','hour','day','week','month','year'...
+ unit = args[0]
+ amount = args[1]
+ dt = args[2]
+
+ if unit in ["day", "hour", "minute", "second", "month"]:
+ pass
+ elif unit == "week":
+ # dateAdd('week', x, ...) = dateAdd('day', x*7, ...)
+ unit = "day"
+ amount = amount * 7
+ elif unit == "year":
+ # year intervals: adding year means 12 months
+ unit = "month"
+ amount = amount * 12
+ else:
+ raise ValueError(f"Unsupported interval unit: {unit}")
+
+ interval = to_hog_interval(amount, unit)
+ return apply_interval_to_datetime(dt, interval)
+
+
+def date_diff(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # dateDiff(unit, start, end)
+ unit = args[0]
+ start = args[1]
+ end = args[2]
+
+ # Convert start/end to aware datetimes
+ def to_dt(obj):
+ if is_hog_datetime(obj):
+ z = obj["zone"]
+ return pytz.timezone(z).localize(datetime.datetime.utcfromtimestamp(obj["dt"]))
+ elif is_hog_date(obj):
+ return pytz.UTC.localize(datetime.datetime(obj["year"], obj["month"], obj["day"]))
+ else:
+ # try parse string
+ d = datetime.datetime.fromisoformat(obj)
+ return d.replace(tzinfo=pytz.UTC)
+
+ start_dt = to_dt(start)
+ end_dt = to_dt(end)
+
+ diff = end_dt - start_dt
+ if unit == "day":
+ return diff.days
+ elif unit == "hour":
+ return int(diff.total_seconds() // 3600)
+ elif unit == "minute":
+ return int(diff.total_seconds() // 60)
+ elif unit == "second":
+ return int(diff.total_seconds())
+ elif unit == "week":
+ return diff.days // 7
+ elif unit == "month":
+ # approximate: count months difference
+ return (end_dt.year - start_dt.year) * 12 + (end_dt.month - start_dt.month)
+ elif unit == "year":
+ return end_dt.year - start_dt.year
+ else:
+ raise ValueError(f"Unsupported unit for dateDiff: {unit}")
+
+
+def date_trunc(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # dateTrunc(unit, datetime)
+ unit = args[0]
+ dt = args[1]
+
+ if not is_hog_datetime(dt):
+ raise ValueError("Expected a DateTime for dateTrunc")
+
+ zone = dt["zone"]
+ base_dt = datetime.datetime.utcfromtimestamp(dt["dt"])
+ base_dt = pytz.timezone(zone).localize(base_dt)
+
+ if unit == "year":
+ truncated = datetime.datetime(base_dt.year, 1, 1, tzinfo=base_dt.tzinfo)
+ elif unit == "month":
+ truncated = datetime.datetime(base_dt.year, base_dt.month, 1, tzinfo=base_dt.tzinfo)
+ elif unit == "day":
+ truncated = datetime.datetime(base_dt.year, base_dt.month, base_dt.day, tzinfo=base_dt.tzinfo)
+ elif unit == "hour":
+ truncated = datetime.datetime(base_dt.year, base_dt.month, base_dt.day, base_dt.hour, tzinfo=base_dt.tzinfo)
+ elif unit == "minute":
+ truncated = datetime.datetime(
+ base_dt.year, base_dt.month, base_dt.day, base_dt.hour, base_dt.minute, tzinfo=base_dt.tzinfo
+ )
+ else:
+ raise ValueError(f"Unsupported unit for dateTrunc: {unit}")
+
+ return {
+ "__hogDateTime__": True,
+ "dt": truncated.timestamp(),
+ "zone": zone,
+ }
+
+
+def coalesce(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ for a in args:
+ if a is not None:
+ return a
+ return None
+
+
+def assumeNotNull(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ if args[0] is None:
+ raise ValueError("Value is null in assumeNotNull")
+ return args[0]
+
+
+def equals(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] == args[1]
+
+
+def greater(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] > args[1]
+
+
+def greaterOrEquals(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] >= args[1]
+
+
+def less(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] < args[1]
+
+
+def lessOrEquals(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] <= args[1]
+
+
+def notEquals(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] != args[1]
+
+
+def not_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return not bool(args[0])
+
+
+def and_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return all(args)
+
+
+def or_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return any(args)
+
+
+def if_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return args[1] if args[0] else args[2]
+
+
+def in_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return args[0] in args[1] if isinstance(args[1], list | tuple) else False
+
+
+def min2(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return args[0] if args[0] < args[1] else args[1]
+
+
+def max2(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return args[0] if args[0] > args[1] else args[1]
+
+
+def plus(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return args[0] + args[1]
+
+
+def minus(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return args[0] - args[1]
+
+
+def multiIf(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # multiIf(cond1, val1, cond2, val2, ..., default)
+ default = args[-1]
+ pairs = args[:-1]
+ for i in range(0, len(pairs), 2):
+ cond = pairs[i]
+ val = pairs[i + 1]
+ if cond:
+ return val
+ return default
+
+
+def floor_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return math.floor(args[0])
+
+
+def extract(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # extract(part, datetime)
+ # part in { 'year', 'month', 'day', 'hour', 'minute', 'second' }
+ part = args[0]
+ val = args[1]
+
+ def to_dt(obj):
+ if is_hog_datetime(obj):
+ z = obj["zone"]
+ return pytz.timezone(z).localize(datetime.datetime.utcfromtimestamp(obj["dt"]))
+ elif is_hog_date(obj):
+ return pytz.UTC.localize(datetime.datetime(obj["year"], obj["month"], obj["day"]))
+ else:
+ d = datetime.datetime.fromisoformat(obj)
+ return d.replace(tzinfo=pytz.UTC)
+
+ dt = to_dt(val)
+ if part == "year":
+ return dt.year
+ elif part == "month":
+ return dt.month
+ elif part == "day":
+ return dt.day
+ elif part == "hour":
+ return dt.hour
+ elif part == "minute":
+ return dt.minute
+ elif part == "second":
+ return dt.second
+ else:
+ raise ValueError(f"Unknown extract part: {part}")
+
+
+def round_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return round(args[0])
+
+
+def startsWith(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> bool:
+ return isinstance(args[0], str) and isinstance(args[1], str) and args[0].startswith(args[1])
+
+
+def substring(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # substring(str, start, length)
+ # start is 1-based.
+ s = args[0]
+ start = args[1]
+ length = args[2]
+ if not isinstance(s, str):
+ return ""
+ start_idx = start - 1
+ if start_idx < 0 or length < 0:
+ return ""
+ end_idx = start_idx + length
+ return s[start_idx:end_idx] if 0 <= start_idx < len(s) else ""
+
+
+def addDays(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ interval = to_hog_interval(args[1], "day")
+ return apply_interval_to_datetime(args[0], interval)
+
+
+def toIntervalDay(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return to_hog_interval(args[0], "day")
+
+
+def toIntervalHour(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return to_hog_interval(args[0], "hour")
+
+
+def toIntervalMinute(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return to_hog_interval(args[0], "minute")
+
+
+def toIntervalMonth(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return to_hog_interval(args[0], "month")
+
+
+def toYear(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return extract(["year", args[0]], team, stdout, timeout)
+
+
+def toMonth_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return extract(["month", args[0]], team, stdout, timeout)
+
+
+def trunc_to_unit(dt: dict, unit: str, team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> dict:
+ # helper for toStartOfDay, etc.
+ if not is_hog_datetime(dt):
+ if is_hog_date(dt):
+ dt = toDateTime(f"{dt['year']:04d}-{dt['month']:02d}-{dt['day']:02d}")
+ else:
+ raise ValueError("Expected a Date or DateTime")
+
+ return date_trunc([unit, dt], team, stdout, timeout)
+
+
+def toStartOfDay(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return trunc_to_unit(args[0], "day", team, stdout, timeout)
+
+
+def toStartOfHour(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return trunc_to_unit(args[0], "hour", team, stdout, timeout)
+
+
+def toStartOfMonth(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ return trunc_to_unit(args[0], "month", team, stdout, timeout)
+
+
+def toStartOfWeek(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ dt = args[0]
+ if not is_hog_datetime(dt):
+ if is_hog_date(dt):
+ dt = toDateTime(f"{dt['year']}-{dt['month']:02d}-{dt['day']:02d}")
+ else:
+ raise ValueError("Expected a Date or DateTime")
+ base_dt = datetime.datetime.utcfromtimestamp(dt["dt"])
+ zone = dt["zone"]
+ base_dt = pytz.timezone(zone).localize(base_dt)
+ weekday = base_dt.isoweekday() # Monday=1, Sunday=7
+ start_of_week = base_dt - datetime.timedelta(days=weekday - 1)
+ start_of_week = start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
+ return {
+ "__hogDateTime__": True,
+ "dt": start_of_week.timestamp(),
+ "zone": zone,
+ }
+
+
+def toYYYYMM(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ y = toYear([args[0]], team, stdout, timeout)
+ m = toMonth_fn([args[0]], team, stdout, timeout)
+ return y * 100 + m
+
+
+def today(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ now_dt = datetime.datetime.now(tz=pytz.UTC)
+ return {
+ "__hogDate__": True,
+ "year": now_dt.year,
+ "month": now_dt.month,
+ "day": now_dt.day,
+ }
+
+
+def range_fn(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ # range(a,b) -> [a..b-1], range(x) -> [0..x-1]
+ if len(args) == 1:
+ return list(range(args[0]))
+ elif len(args) == 2:
+ return list(range(args[0], args[1]))
+ else:
+ raise ValueError("range function supports 1 or 2 arguments only")
+
+
+def JSONExtractArrayRaw(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ obj = args[0]
+ path = args[1:]
+ try:
+ if isinstance(obj, str):
+ obj = json.loads(obj)
+ except json.JSONDecodeError:
+ return None
+ val = get_nested_value(obj, path, True)
+ if isinstance(val, list):
+ return val
+ return None
+
+
+def JSONExtractFloat(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ obj = args[0]
+ path = args[1:]
+ try:
+ if isinstance(obj, str):
+ obj = json.loads(obj)
+ except json.JSONDecodeError:
+ return None
+ val = get_nested_value(obj, path, True)
+ try:
+ return float(val)
+ except (TypeError, ValueError):
+ return None
+
+
+def JSONExtractInt(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ obj = args[0]
+ path = args[1:]
+ try:
+ if isinstance(obj, str):
+ obj = json.loads(obj)
+ except json.JSONDecodeError:
+ return None
+ val = get_nested_value(obj, path, True)
+ try:
+ return int(val)
+ except (TypeError, ValueError):
+ return None
+
+
+def JSONExtractString(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]], timeout: float) -> Any:
+ obj = args[0]
+ path = args[1:]
+ try:
+ if isinstance(obj, str):
+ obj = json.loads(obj)
+ except json.JSONDecodeError:
+ return None
+ val = get_nested_value(obj, path, True)
+ return str(val) if val is not None else None
+
+
STL: dict[str, STLFunction] = {
"concat": STLFunction(
fn=lambda args, team, stdout, timeout: "".join(
@@ -449,6 +909,8 @@ def _typeof(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]]
"toInt": STLFunction(fn=toInt, minArgs=1, maxArgs=1),
"toFloat": STLFunction(fn=toFloat, minArgs=1, maxArgs=1),
"ifNull": STLFunction(fn=ifNull, minArgs=2, maxArgs=2),
+ "isNull": STLFunction(fn=lambda args, team, stdout, timeout: args[0] is None, minArgs=1, maxArgs=1),
+ "isNotNull": STLFunction(fn=lambda args, team, stdout, timeout: args[0] is not None, minArgs=1, maxArgs=1),
"length": STLFunction(fn=lambda args, team, stdout, timeout: len(args[0]), minArgs=1, maxArgs=1),
"empty": STLFunction(fn=empty, minArgs=1, maxArgs=1),
"notEmpty": STLFunction(
@@ -563,6 +1025,50 @@ def _typeof(args: list[Any], team: Optional["Team"], stdout: Optional[list[str]]
maxArgs=2,
),
"typeof": STLFunction(fn=_typeof, minArgs=1, maxArgs=1),
+ "JSONExtractArrayRaw": STLFunction(fn=JSONExtractArrayRaw, minArgs=1),
+ "JSONExtractFloat": STLFunction(fn=JSONExtractFloat, minArgs=1),
+ "JSONExtractInt": STLFunction(fn=JSONExtractInt, minArgs=1),
+ "JSONExtractString": STLFunction(fn=JSONExtractString, minArgs=1),
+ "and": STLFunction(fn=and_fn, minArgs=2, maxArgs=2),
+ "addDays": STLFunction(fn=addDays, minArgs=2, maxArgs=2),
+ "assumeNotNull": STLFunction(fn=assumeNotNull, minArgs=1, maxArgs=1),
+ "coalesce": STLFunction(fn=coalesce, minArgs=1, maxArgs=None),
+ "dateAdd": STLFunction(fn=date_add, minArgs=3, maxArgs=3),
+ "dateDiff": STLFunction(fn=date_diff, minArgs=3, maxArgs=3),
+ "dateTrunc": STLFunction(fn=date_trunc, minArgs=2, maxArgs=2),
+ "equals": STLFunction(fn=equals, minArgs=2, maxArgs=2),
+ "extract": STLFunction(fn=extract, minArgs=2, maxArgs=2),
+ "floor": STLFunction(fn=floor_fn, minArgs=1, maxArgs=1),
+ "greater": STLFunction(fn=greater, minArgs=2, maxArgs=2),
+ "greaterOrEquals": STLFunction(fn=greaterOrEquals, minArgs=2, maxArgs=2),
+ "if": STLFunction(fn=if_fn, minArgs=3, maxArgs=3),
+ "in": STLFunction(fn=in_fn, minArgs=2, maxArgs=2),
+ "less": STLFunction(fn=less, minArgs=2, maxArgs=2),
+ "lessOrEquals": STLFunction(fn=lessOrEquals, minArgs=2, maxArgs=2),
+ "min2": STLFunction(fn=min2, minArgs=2, maxArgs=2),
+ "max2": STLFunction(fn=max2, minArgs=2, maxArgs=2),
+ "minus": STLFunction(fn=minus, minArgs=2, maxArgs=2),
+ "multiIf": STLFunction(fn=multiIf, minArgs=3),
+ "not": STLFunction(fn=not_fn, minArgs=1, maxArgs=1),
+ "notEquals": STLFunction(fn=notEquals, minArgs=2, maxArgs=2),
+ "or": STLFunction(fn=or_fn, minArgs=2, maxArgs=2),
+ "plus": STLFunction(fn=plus, minArgs=2, maxArgs=2),
+ "range": STLFunction(fn=range_fn, minArgs=1, maxArgs=2),
+ "round": STLFunction(fn=round_fn, minArgs=1, maxArgs=1),
+ "startsWith": STLFunction(fn=startsWith, minArgs=2, maxArgs=2),
+ "substring": STLFunction(fn=substring, minArgs=3, maxArgs=3),
+ "toIntervalDay": STLFunction(fn=toIntervalDay, minArgs=1, maxArgs=1),
+ "toIntervalHour": STLFunction(fn=toIntervalHour, minArgs=1, maxArgs=1),
+ "toIntervalMinute": STLFunction(fn=toIntervalMinute, minArgs=1, maxArgs=1),
+ "toIntervalMonth": STLFunction(fn=toIntervalMonth, minArgs=1, maxArgs=1),
+ "toMonth": STLFunction(fn=toMonth_fn, minArgs=1, maxArgs=1),
+ "toStartOfDay": STLFunction(fn=toStartOfDay, minArgs=1, maxArgs=1),
+ "toStartOfHour": STLFunction(fn=toStartOfHour, minArgs=1, maxArgs=1),
+ "toStartOfMonth": STLFunction(fn=toStartOfMonth, minArgs=1, maxArgs=1),
+ "toStartOfWeek": STLFunction(fn=toStartOfWeek, minArgs=1, maxArgs=1),
+ "toYYYYMM": STLFunction(fn=toYYYYMM, minArgs=1, maxArgs=1),
+ "toYear": STLFunction(fn=toYear, minArgs=1, maxArgs=1),
+ "today": STLFunction(fn=today, minArgs=0, maxArgs=0),
# only in python, async function in nodejs
"sleep": STLFunction(fn=sleep, minArgs=1, maxArgs=1),
"run": STLFunction(fn=run, minArgs=1, maxArgs=1),
diff --git a/hogvm/typescript/package.json b/hogvm/typescript/package.json
index 4e3af57e59561..c7e1035bd51e8 100644
--- a/hogvm/typescript/package.json
+++ b/hogvm/typescript/package.json
@@ -1,6 +1,6 @@
{
"name": "@posthog/hogvm",
- "version": "1.0.61",
+ "version": "1.0.66",
"description": "PostHog Hog Virtual Machine",
"types": "dist/index.d.ts",
"source": "src/index.ts",
diff --git a/hogvm/typescript/src/stl/stl.ts b/hogvm/typescript/src/stl/stl.ts
index a4b0d49b5bc14..760d041522e1f 100644
--- a/hogvm/typescript/src/stl/stl.ts
+++ b/hogvm/typescript/src/stl/stl.ts
@@ -1,7 +1,7 @@
import { DateTime } from 'luxon'
import { isHogCallable, isHogClosure, isHogDate, isHogDateTime, isHogError, newHogError } from '../objects'
-import { AsyncSTLFunction, STLFunction } from '../types'
+import { AsyncSTLFunction, STLFunction, HogInterval, HogDate, HogDateTime } from '../types'
import { getNestedValue, like } from '../utils'
import { md5Hex, sha256Hex, sha256HmacChainHex } from './crypto'
import {
@@ -33,6 +33,432 @@ function STLToString(args: any[]): string {
return printHogStringOutput(args[0])
}
+// Helper: HogInterval
+function isHogInterval(obj: any): obj is HogInterval {
+ return obj && obj.__hogInterval__ === true
+}
+
+function toHogInterval(value: number, unit: string): HogInterval {
+ return {
+ __hogInterval__: true,
+ value: value,
+ unit: unit,
+ }
+}
+
+function applyIntervalToDateTime(base: HogDate | HogDateTime, interval: HogInterval): HogDate | HogDateTime {
+ let dt: DateTime
+ let zone = 'UTC'
+ if (isHogDateTime(base)) {
+ zone = base.zone
+ dt = DateTime.fromSeconds(base.dt, { zone })
+ } else {
+ dt = DateTime.fromObject({ year: base.year, month: base.month, day: base.day }, { zone })
+ }
+
+ const { value, unit } = interval
+ // Expand certain units for uniformity
+ let effectiveUnit = unit
+ let effectiveValue = value
+ if (unit === 'week') {
+ effectiveUnit = 'day'
+ effectiveValue = value * 7
+ } else if (unit === 'year') {
+ effectiveUnit = 'month'
+ effectiveValue = value * 12
+ }
+
+ // Note: Luxon doesn't have direct month addition that can handle overflow automatically to last day of month,
+ // but plus({ months: x }) will shift the date by x months and clamp automatically if needed.
+ let newDt: DateTime
+ switch (effectiveUnit) {
+ case 'day':
+ newDt = dt.plus({ days: effectiveValue })
+ break
+ case 'hour':
+ newDt = dt.plus({ hours: effectiveValue })
+ break
+ case 'minute':
+ newDt = dt.plus({ minutes: effectiveValue })
+ break
+ case 'second':
+ newDt = dt.plus({ seconds: effectiveValue })
+ break
+ case 'month':
+ newDt = dt.plus({ months: effectiveValue })
+ break
+ default:
+ throw new Error(`Unsupported interval unit: ${unit}`)
+ }
+
+ if (isHogDateTime(base)) {
+ return {
+ __hogDateTime__: true,
+ dt: newDt.toSeconds(),
+ zone: newDt.zoneName || 'UTC',
+ }
+ } else {
+ return {
+ __hogDate__: true,
+ year: newDt.year,
+ month: newDt.month,
+ day: newDt.day,
+ }
+ }
+}
+
+// dateAdd(unit, amount, datetime)
+function dateAddFn([unit, amount, datetime]: any[]): HogDate | HogDateTime {
+ return applyIntervalToDateTime(datetime, toHogInterval(amount, unit))
+}
+
+// dateDiff(unit, start, end)
+function dateDiffFn([unit, startVal, endVal]: any[]): number {
+ function toDT(obj: any): DateTime {
+ if (isHogDateTime(obj)) {
+ return DateTime.fromSeconds(obj.dt, { zone: obj.zone })
+ } else if (isHogDate(obj)) {
+ return DateTime.fromObject({ year: obj.year, month: obj.month, day: obj.day }, { zone: 'UTC' })
+ } else {
+ // try parse ISO string
+ return DateTime.fromISO(obj, { zone: 'UTC' })
+ }
+ }
+
+ const start = toDT(startVal)
+ const end = toDT(endVal)
+ const diff = end.diff(start, ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'])
+
+ switch (unit) {
+ case 'day':
+ return Math.floor((end.toMillis() - start.toMillis()) / (1000 * 60 * 60 * 24))
+ case 'hour':
+ return Math.floor(diff.as('hours'))
+ case 'minute':
+ return Math.floor(diff.as('minutes'))
+ case 'second':
+ return Math.floor(diff.as('seconds'))
+ case 'week':
+ return Math.floor(diff.as('days') / 7)
+ case 'month':
+ // Month difference approximated by counting month differences:
+ return (end.year - start.year) * 12 + (end.month - start.month)
+ case 'year':
+ return end.year - start.year
+ default:
+ throw new Error(`Unsupported unit for dateDiff: ${unit}`)
+ }
+}
+
+// dateTrunc(unit, datetime)
+function dateTruncFn([unit, val]: any[]): HogDateTime {
+ if (!isHogDateTime(val)) {
+ throw new Error('Expected a DateTime for dateTrunc')
+ }
+ const dt = DateTime.fromSeconds(val.dt, { zone: val.zone })
+ let truncated: DateTime
+ switch (unit) {
+ case 'year':
+ truncated = DateTime.fromObject({ year: dt.year }, { zone: dt.zoneName })
+ break
+ case 'month':
+ truncated = DateTime.fromObject({ year: dt.year, month: dt.month }, { zone: dt.zoneName })
+ break
+ case 'day':
+ truncated = DateTime.fromObject({ year: dt.year, month: dt.month, day: dt.day }, { zone: dt.zoneName })
+ break
+ case 'hour':
+ truncated = DateTime.fromObject({ year: dt.year, month: dt.month, day: dt.day, hour: dt.hour }, { zone: dt.zoneName })
+ break
+ case 'minute':
+ truncated = DateTime.fromObject({ year: dt.year, month: dt.month, day: dt.day, hour: dt.hour, minute: dt.minute }, { zone: dt.zoneName })
+ break
+ default:
+ throw new Error(`Unsupported unit for dateTrunc: ${unit}`)
+ }
+ return {
+ __hogDateTime__: true,
+ dt: truncated.toSeconds(),
+ zone: truncated.zoneName || 'UTC',
+ }
+}
+
+function coalesceFn(args: any[]): any {
+ for (const a of args) {
+ if (a !== null && a !== undefined) return a
+ }
+ return null
+}
+
+function assumeNotNullFn([val]: any[]): any {
+ if (val === null || val === undefined) {
+ throw new Error("Value is null in assumeNotNull")
+ }
+ return val
+}
+
+function equalsFn([a, b]: any[]): boolean {
+ return a === b
+}
+
+function greaterFn([a, b]: any[]): boolean {
+ return a > b
+}
+
+function greaterOrEqualsFn([a, b]: any[]): boolean {
+ return a >= b
+}
+
+function lessFn([a, b]: any[]): boolean {
+ return a < b
+}
+
+function lessOrEqualsFn([a, b]: any[]): boolean {
+ return a <= b
+}
+
+function notEqualsFn([a, b]: any[]): boolean {
+ return a !== b
+}
+
+function notFn([a]: any[]): boolean {
+ return !a
+}
+
+function andFn(args: any[]): boolean {
+ return args.every(Boolean)
+}
+
+function orFn(args: any[]): boolean {
+ return args.some(Boolean)
+}
+
+function ifFn([cond, thenVal, elseVal]: any[]): any {
+ return cond ? thenVal : elseVal
+}
+
+function inFn([val, arr]: any[]): boolean {
+ return Array.isArray(arr) || (arr && arr.__isHogTuple) ? arr.includes(val) : false
+}
+
+function min2Fn([a, b]: any[]): any {
+ return a < b ? a : b
+}
+
+function plusFn([a, b]: any[]): any {
+ return a + b
+}
+
+function minusFn([a, b]: any[]): any {
+ return a - b
+}
+
+function multiIfFn(args: any[]): any {
+ // multiIf(cond1, val1, cond2, val2, ..., default)
+ const last = args[args.length - 1]
+ const pairs = args.slice(0, -1)
+ for (let i = 0; i < pairs.length; i += 2) {
+ const cond = pairs[i]
+ const val = pairs[i + 1]
+ if (cond) {
+ return val
+ }
+ }
+ return last
+}
+
+function floorFn([a]: any[]): any {
+ return Math.floor(a)
+}
+
+// extract(part, datetime)
+function extractFn([part, val]: any[]): number {
+ function toDT(obj: any): DateTime {
+ if (isHogDateTime(obj)) {
+ return DateTime.fromSeconds(obj.dt, { zone: obj.zone })
+ } else if (isHogDate(obj)) {
+ return DateTime.fromObject({ year: obj.year, month: obj.month, day: obj.day }, { zone: 'UTC' })
+ } else {
+ return DateTime.fromISO(obj, { zone: 'UTC' })
+ }
+ }
+
+ const dt = toDT(val)
+ switch (part) {
+ case 'year':
+ return dt.year
+ case 'month':
+ return dt.month
+ case 'day':
+ return dt.day
+ case 'hour':
+ return dt.hour
+ case 'minute':
+ return dt.minute
+ case 'second':
+ return dt.second
+ default:
+ throw new Error(`Unknown extract part: ${part}`)
+ }
+}
+
+function roundFn([a]: any[]): any {
+ return Math.round(a)
+}
+
+function startsWithFn([str, prefix]: any[]): boolean {
+ return typeof str === 'string' && typeof prefix === 'string' && str.startsWith(prefix)
+}
+
+function substringFn([s, start, length]: any[]): string {
+ if (typeof s !== 'string') return ''
+ const startIdx = start - 1
+ if (startIdx < 0 || length < 0) return ''
+ const endIdx = startIdx + length
+ return startIdx < s.length ? s.slice(startIdx, endIdx) : ''
+}
+
+function addDaysFn([dateOrDt, days]: any[]): HogDate | HogDateTime {
+ return applyIntervalToDateTime(dateOrDt, toHogInterval(days, 'day'))
+}
+
+function toIntervalDayFn([val]: any[]): HogInterval {
+ return toHogInterval(val, 'day')
+}
+
+function toIntervalHourFn([val]: any[]): HogInterval {
+ return toHogInterval(val, 'hour')
+}
+
+function toIntervalMinuteFn([val]: any[]): HogInterval {
+ return toHogInterval(val, 'minute')
+}
+
+function toIntervalMonthFn([val]: any[]): HogInterval {
+ return toHogInterval(val, 'month')
+}
+
+function toYearFn([val]: any[]): number {
+ return extractFn(['year', val])
+}
+
+function toMonthFn([val]: any[]): number {
+ return extractFn(['month', val])
+}
+
+function toStartOfDayFn([val]: any[]): HogDateTime {
+ return dateTruncFn(['day', isHogDateTime(val) ? val : toDateTimeFromDate(val)])
+}
+
+function toStartOfHourFn([val]: any[]): HogDateTime {
+ return dateTruncFn(['hour', isHogDateTime(val) ? val : toDateTimeFromDate(val)])
+}
+
+function toStartOfMonthFn([val]: any[]): HogDateTime {
+ return dateTruncFn(['month', isHogDateTime(val) ? val : toDateTimeFromDate(val)])
+}
+
+function toStartOfWeekFn([val]: any[]): HogDateTime {
+ const dt = isHogDateTime(val) ? DateTime.fromSeconds(val.dt, { zone: val.zone }) :
+ DateTime.fromObject({ year: val.year, month: val.month, day: val.day }, { zone: 'UTC' })
+ const weekday = dt.weekday // Monday=1, Sunday=7
+ const startOfWeek = dt.minus({ days: weekday - 1 }).startOf('day')
+ return {
+ __hogDateTime__: true,
+ dt: startOfWeek.toSeconds(),
+ zone: startOfWeek.zoneName || 'UTC'
+ }
+}
+
+function toYYYYMMFn([val]: any[]): number {
+ const y = toYearFn([val])
+ const m = toMonthFn([val])
+ return y * 100 + m
+}
+
+function todayFn(): HogDate {
+ const now = DateTime.now().setZone('UTC')
+ return {
+ __hogDate__: true,
+ year: now.year,
+ month: now.month,
+ day: now.day,
+ }
+}
+
+function toDateTimeFromDate(date: HogDate): HogDateTime {
+ const dt = DateTime.fromObject({ year: date.year, month: date.month, day: date.day }, { zone: 'UTC' })
+ return {
+ __hogDateTime__: true,
+ dt: dt.toSeconds(),
+ zone: 'UTC',
+ }
+}
+
+function rangeFn(args: any[]): any[] {
+ if (args.length === 1) {
+ return Array.from({ length: args[0] }, (_, i) => i)
+ } else {
+ return Array.from({ length: args[1] - args[0] }, (_, i) => args[0] + i)
+ }
+}
+
+// JSON extraction
+function JSONExtractArrayRawFn(args: any[]): any {
+ let [obj, ...path] = args
+ try {
+ if (typeof obj === 'string') {
+ obj = JSON.parse(obj)
+ }
+ } catch {
+ return null
+ }
+ const val = getNestedValue(obj, path, true)
+ return Array.isArray(val) ? val : null
+}
+
+function JSONExtractFloatFn(args: any[]): number | null {
+ let [obj, ...path] = args
+ try {
+ if (typeof obj === 'string') {
+ obj = JSON.parse(obj)
+ }
+ } catch {
+ return null
+ }
+ const val = getNestedValue(obj, path, true)
+ const f = parseFloat(val)
+ return isNaN(f) ? null : f
+}
+
+function JSONExtractIntFn(args: any[]): number | null {
+ let [obj, ...path] = args
+ try {
+ if (typeof obj === 'string') {
+ obj = JSON.parse(obj)
+ }
+ } catch {
+ return null
+ }
+ const val = getNestedValue(obj, path, true)
+ const i = parseInt(val)
+ return isNaN(i) ? null : i
+}
+
+function JSONExtractStringFn(args: any[]): string | null {
+ let [obj, ...path] = args
+ try {
+ if (typeof obj === 'string') {
+ obj = JSON.parse(obj)
+ }
+ } catch {
+ return null
+ }
+ const val = getNestedValue(obj, path, true)
+ return val != null ? String(val) : null
+}
+
+
export const STL: Record = {
concat: {
fn: (args) => {
@@ -113,6 +539,20 @@ export const STL: Record = {
minArgs: 2,
maxArgs: 2,
},
+ isNull: {
+ fn: (args) => {
+ return args[0] === null || args[0] === undefined
+ },
+ minArgs: 1,
+ maxArgs: 1,
+ },
+ isNotNull: {
+ fn: (args) => {
+ return args[0] !== null && args[0] !== undefined
+ },
+ minArgs: 1,
+ maxArgs: 1,
+ },
length: {
fn: (args) => {
return args[0].length
@@ -775,6 +1215,50 @@ export const STL: Record = {
minArgs: 1,
maxArgs: 1,
},
+
+ JSONExtractArrayRaw: { fn: JSONExtractArrayRawFn, minArgs: 1 },
+ JSONExtractFloat: { fn: JSONExtractFloatFn, minArgs: 1 },
+ JSONExtractInt: { fn: JSONExtractIntFn, minArgs: 1 },
+ JSONExtractString: { fn: JSONExtractStringFn, minArgs: 1 },
+ addDays: { fn: addDaysFn, minArgs: 2, maxArgs: 2 },
+ assumeNotNull: { fn: assumeNotNullFn, minArgs: 1, maxArgs: 1 },
+ coalesce: { fn: coalesceFn, minArgs: 1 },
+ dateAdd: { fn: dateAddFn, minArgs: 3, maxArgs: 3 },
+ dateDiff: { fn: dateDiffFn, minArgs: 3, maxArgs: 3 },
+ dateTrunc: { fn: dateTruncFn, minArgs: 2, maxArgs: 2 },
+ equals: { fn: equalsFn, minArgs: 2, maxArgs: 2 },
+ extract: { fn: extractFn, minArgs: 2, maxArgs: 2 },
+ floor: { fn: floorFn, minArgs: 1, maxArgs: 1 },
+ greater: { fn: greaterFn, minArgs: 2, maxArgs: 2 },
+ greaterOrEquals: { fn: greaterOrEqualsFn, minArgs: 2, maxArgs: 2 },
+ if: { fn: ifFn, minArgs: 3, maxArgs: 3 },
+ in: { fn: inFn, minArgs: 2, maxArgs: 2 },
+ less: { fn: lessFn, minArgs: 2, maxArgs: 2 },
+ lessOrEquals: { fn: lessOrEqualsFn, minArgs: 2, maxArgs: 2 },
+ min2: { fn: min2Fn, minArgs: 2, maxArgs: 2 },
+ minus: { fn: minusFn, minArgs: 2, maxArgs: 2 },
+ multiIf: { fn: multiIfFn, minArgs: 3 },
+ not: { fn: notFn, minArgs: 1, maxArgs: 1 },
+ notEquals: { fn: notEqualsFn, minArgs: 2, maxArgs: 2 },
+ and: { fn: andFn, minArgs: 2, maxArgs: 2 },
+ or: { fn: orFn, minArgs: 2, maxArgs: 2 },
+ plus: { fn: plusFn, minArgs: 2, maxArgs: 2 },
+ range: { fn: rangeFn, minArgs: 1, maxArgs: 2 },
+ round: { fn: roundFn, minArgs: 1, maxArgs: 1 },
+ startsWith: { fn: startsWithFn, minArgs: 2, maxArgs: 2 },
+ substring: { fn: substringFn, minArgs: 3, maxArgs: 3 },
+ toIntervalDay: { fn: toIntervalDayFn, minArgs: 1, maxArgs: 1 },
+ toIntervalHour: { fn: toIntervalHourFn, minArgs: 1, maxArgs: 1 },
+ toIntervalMinute: { fn: toIntervalMinuteFn, minArgs: 1, maxArgs: 1 },
+ toIntervalMonth: { fn: toIntervalMonthFn, minArgs: 1, maxArgs: 1 },
+ toMonth: { fn: toMonthFn, minArgs: 1, maxArgs: 1 },
+ toStartOfDay: { fn: toStartOfDayFn, minArgs: 1, maxArgs: 1 },
+ toStartOfHour: { fn: toStartOfHourFn, minArgs: 1, maxArgs: 1 },
+ toStartOfMonth: { fn: toStartOfMonthFn, minArgs: 1, maxArgs: 1 },
+ toStartOfWeek: { fn: toStartOfWeekFn, minArgs: 1, maxArgs: 1 },
+ toYYYYMM: { fn: toYYYYMMFn, minArgs: 1, maxArgs: 1 },
+ toYear: { fn: toYearFn, minArgs: 1, maxArgs: 1 },
+ today: { fn: todayFn, minArgs: 0, maxArgs: 0 },
}
export const ASYNC_STL: Record = {
diff --git a/hogvm/typescript/src/types.ts b/hogvm/typescript/src/types.ts
index 50008b91b0d39..810815f63bd30 100644
--- a/hogvm/typescript/src/types.ts
+++ b/hogvm/typescript/src/types.ts
@@ -144,6 +144,12 @@ export interface HogClosure {
upvalues: number[]
}
+export interface HogInterval {
+ __hogInterval__: true
+ value: number
+ unit: string
+}
+
export interface STLFunction {
fn: (args: any[], name: string, options?: ExecOptions) => any
minArgs?: number
diff --git a/mypy-baseline.txt b/mypy-baseline.txt
index ca4b578d231b4..a4444d8b67031 100644
--- a/mypy-baseline.txt
+++ b/mypy-baseline.txt
@@ -146,8 +146,6 @@ posthog/hogql_queries/legacy_compatibility/filter_to_query.py:0: error: Dict ent
posthog/hogql_queries/legacy_compatibility/filter_to_query.py:0: error: Dict entry 0 has incompatible type "str": "PathsFilter"; expected "str": "TrendsFilter" [dict-item]
posthog/hogql_queries/legacy_compatibility/filter_to_query.py:0: error: Dict entry 0 has incompatible type "str": "LifecycleFilter"; expected "str": "TrendsFilter" [dict-item]
posthog/hogql_queries/legacy_compatibility/filter_to_query.py:0: error: Dict entry 0 has incompatible type "str": "StickinessFilter"; expected "str": "TrendsFilter" [dict-item]
-posthog/warehouse/models/external_data_schema.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "int | float") [assignment]
-posthog/warehouse/models/external_data_schema.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "int | float") [assignment]
posthog/session_recordings/models/session_recording.py:0: error: Argument "distinct_id" to "MissingPerson" has incompatible type "str | None"; expected "str" [arg-type]
posthog/session_recordings/models/session_recording.py:0: error: Incompatible type for lookup 'persondistinctid__team_id': (got "Team", expected "str | int") [misc]
posthog/models/hog_functions/hog_function.py:0: error: Argument 1 to "get" of "dict" has incompatible type "str | None"; expected "str" [arg-type]
@@ -402,7 +400,6 @@ posthog/hogql_queries/insights/funnels/funnels_query_runner.py:0: error: Module
posthog/api/survey.py:0: error: Incompatible types in assignment (expression has type "Any | Sequence[Any] | None", variable has type "Survey | None") [assignment]
posthog/api/survey.py:0: error: Item "list[_ErrorFullDetails]" of "_FullDetailDict | list[_ErrorFullDetails] | dict[str, _ErrorFullDetails]" has no attribute "get" [union-attr]
posthog/api/survey.py:0: error: Item "object" of "object | Any" has no attribute "__iter__" (not iterable) [union-attr]
-posthog/hogql_queries/web_analytics/web_overview.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "datetime" [attr-defined]
posthog/api/user.py:0: error: Module has no attribute "utc" [attr-defined]
posthog/api/user.py:0: error: Module has no attribute "utc" [attr-defined]
posthog/api/user.py:0: error: "User" has no attribute "social_auth" [attr-defined]
@@ -457,7 +454,7 @@ posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def [_T0, _T1, _T2, _T3, _T4, _T5, _T6] with_only_columns(self, TypedColumnsClauseRole[_T0] | SQLCoreOperations[_T0] | type[_T0], TypedColumnsClauseRole[_T1] | SQLCoreOperations[_T1] | type[_T1], TypedColumnsClauseRole[_T2] | SQLCoreOperations[_T2] | type[_T2], TypedColumnsClauseRole[_T3] | SQLCoreOperations[_T3] | type[_T3], TypedColumnsClauseRole[_T4] | SQLCoreOperations[_T4] | type[_T4], TypedColumnsClauseRole[_T5] | SQLCoreOperations[_T5] | type[_T5], TypedColumnsClauseRole[_T6] | SQLCoreOperations[_T6] | type[_T6], /) -> Select[tuple[_T0, _T1, _T2, _T3, _T4, _T5, _T6]]
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def [_T0, _T1, _T2, _T3, _T4, _T5, _T6, _T7] with_only_columns(self, TypedColumnsClauseRole[_T0] | SQLCoreOperations[_T0] | type[_T0], TypedColumnsClauseRole[_T1] | SQLCoreOperations[_T1] | type[_T1], TypedColumnsClauseRole[_T2] | SQLCoreOperations[_T2] | type[_T2], TypedColumnsClauseRole[_T3] | SQLCoreOperations[_T3] | type[_T3], TypedColumnsClauseRole[_T4] | SQLCoreOperations[_T4] | type[_T4], TypedColumnsClauseRole[_T5] | SQLCoreOperations[_T5] | type[_T5], TypedColumnsClauseRole[_T6] | SQLCoreOperations[_T6] | type[_T6], TypedColumnsClauseRole[_T7] | SQLCoreOperations[_T7] | type[_T7], /) -> Select[tuple[_T0, _T1, _T2, _T3, _T4, _T5, _T6, _T7]]
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def with_only_columns(self, *entities: TypedColumnsClauseRole[Any] | ColumnsClauseRole | SQLCoreOperations[Any] | Literal['*', 1] | type[Any] | Inspectable[_HasClauseElement[Any]] | _HasClauseElement[Any], maintain_column_froms: bool = ..., **Any) -> Select[Any]
-posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: error: No overload variant of "resource" matches argument types "Callable[[Engine, Table, int, Literal['sqlalchemy', 'pyarrow', 'pandas', 'connectorx'], Incremental[Any] | None, bool, Callable[[Table], None] | None, Literal['minimal', 'full', 'full_with_precision'], dict[str, Any] | None, Callable[[TypeEngine[Any]], TypeEngine[Any] | type[TypeEngine[Any]] | None] | None, list[str] | None, Callable[[Select[Any], Table], Select[Any]] | None, list[str] | None], Iterator[Any]]", "str", "list[str] | None", "list[str] | None", "dict[str, TColumnSchema]", "Collection[str]", "str" [call-overload]
+posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: error: No overload variant of "resource" matches argument types "Callable[[Engine, Table, int, Literal['sqlalchemy', 'pyarrow', 'pandas', 'connectorx'], Incremental[Any] | None, Any | None, bool, Callable[[Table], None] | None, Literal['minimal', 'full', 'full_with_precision'], dict[str, Any] | None, Callable[[TypeEngine[Any]], TypeEngine[Any] | type[TypeEngine[Any]] | None] | None, list[str] | None, Callable[[Select[Any], Table], Select[Any]] | None, list[str] | None], Iterator[Any]]", "str", "list[str] | None", "list[str] | None", "dict[str, TColumnSchema]", "Collection[str]", "str" [call-overload]
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: Possible overload variants:
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def [TResourceFunParams`-1, TDltResourceImpl: DltResource] resource(Callable[TResourceFunParams, Any], /, name: str = ..., table_name: str | Callable[[Any], str] = ..., max_table_nesting: int = ..., write_disposition: Literal['skip', 'append', 'replace', 'merge'] | TWriteDispositionDict | TMergeDispositionDict | TScd2StrategyDict | Callable[[Any], Literal['skip', 'append', 'replace', 'merge'] | TWriteDispositionDict | TMergeDispositionDict | TScd2StrategyDict] = ..., columns: dict[str, TColumnSchema] | Sequence[TColumnSchema] | BaseModel | type[BaseModel] | Callable[[Any], dict[str, TColumnSchema] | Sequence[TColumnSchema] | BaseModel | type[BaseModel]] = ..., primary_key: str | Sequence[str] | Callable[[Any], str | Sequence[str]] = ..., merge_key: str | Sequence[str] | Callable[[Any], str | Sequence[str]] = ..., schema_contract: Literal['evolve', 'discard_value', 'freeze', 'discard_row'] | TSchemaContractDict | Callable[[Any], Literal['evolve', 'discard_value', 'freeze', 'discard_row'] | TSchemaContractDict] = ..., table_format: Literal['iceberg', 'delta', 'hive'] | Callable[[Any], Literal['iceberg', 'delta', 'hive']] = ..., file_format: Literal['preferred', 'jsonl', 'typed-jsonl', 'insert_values', 'parquet', 'csv', 'reference'] | Callable[[Any], Literal['preferred', 'jsonl', 'typed-jsonl', 'insert_values', 'parquet', 'csv', 'reference']] = ..., references: Sequence[TTableReference] | Callable[[Any], Sequence[TTableReference]] = ..., selected: bool = ..., spec: type[BaseConfiguration] = ..., parallelized: bool = ..., _impl_cls: type[TDltResourceImpl] = ...) -> TDltResourceImpl
posthog/temporal/data_imports/pipelines/sql_database_v2/__init__.py:0: note: def [TDltResourceImpl: DltResource] resource(None = ..., /, name: str = ..., table_name: str | Callable[[Any], str] = ..., max_table_nesting: int = ..., write_disposition: Literal['skip', 'append', 'replace', 'merge'] | TWriteDispositionDict | TMergeDispositionDict | TScd2StrategyDict | Callable[[Any], Literal['skip', 'append', 'replace', 'merge'] | TWriteDispositionDict | TMergeDispositionDict | TScd2StrategyDict] = ..., columns: dict[str, TColumnSchema] | Sequence[TColumnSchema] | BaseModel | type[BaseModel] | Callable[[Any], dict[str, TColumnSchema] | Sequence[TColumnSchema] | BaseModel | type[BaseModel]] = ..., primary_key: str | Sequence[str] | Callable[[Any], str | Sequence[str]] = ..., merge_key: str | Sequence[str] | Callable[[Any], str | Sequence[str]] = ..., schema_contract: Literal['evolve', 'discard_value', 'freeze', 'discard_row'] | TSchemaContractDict | Callable[[Any], Literal['evolve', 'discard_value', 'freeze', 'discard_row'] | TSchemaContractDict] = ..., table_format: Literal['iceberg', 'delta', 'hive'] | Callable[[Any], Literal['iceberg', 'delta', 'hive']] = ..., file_format: Literal['preferred', 'jsonl', 'typed-jsonl', 'insert_values', 'parquet', 'csv', 'reference'] | Callable[[Any], Literal['preferred', 'jsonl', 'typed-jsonl', 'insert_values', 'parquet', 'csv', 'reference']] = ..., references: Sequence[TTableReference] | Callable[[Any], Sequence[TTableReference]] = ..., selected: bool = ..., spec: type[BaseConfiguration] = ..., parallelized: bool = ..., _impl_cls: type[TDltResourceImpl] = ...) -> Callable[[Callable[TResourceFunParams, Any]], TDltResourceImpl]
@@ -719,6 +716,7 @@ posthog/api/test/dashboards/test_dashboard.py:0: error: Value of type variable "
posthog/api/test/dashboards/test_dashboard.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "timedelta" [attr-defined]
posthog/api/test/dashboards/test_dashboard.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "timedelta" [attr-defined]
posthog/api/test/dashboards/test_dashboard.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "timedelta" [attr-defined]
+posthog/api/query.py:0: error: Statement is unreachable [unreachable]
posthog/api/property_definition.py:0: error: Item "AnonymousUser" of "User | AnonymousUser" has no attribute "organization" [union-attr]
posthog/api/property_definition.py:0: error: Item "None" of "Organization | Any | None" has no attribute "is_feature_available" [union-attr]
posthog/api/property_definition.py:0: error: Item "ForeignObjectRel" of "Field[Any, Any] | ForeignObjectRel | GenericForeignKey" has no attribute "cached_col" [union-attr]
@@ -789,7 +787,9 @@ posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: "FilesystemDe
posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: "type[FilesystemDestinationClientConfiguration]" has no attribute "delta_jobs_per_write" [attr-defined]
posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "object", variable has type "DataWarehouseCredential | Combinable | None") [assignment]
posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "object", variable has type "str | int | Combinable") [assignment]
-posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Incompatible types in assignment (expression has type "dict[str, dict[str, str | bool]] | dict[str, str]", variable has type "dict[str, dict[str, str]]") [assignment]
+posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Right operand of "and" is never evaluated [unreachable]
+posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Statement is unreachable [unreachable]
+posthog/temporal/data_imports/pipelines/pipeline_sync.py:0: error: Name "raw_db_columns" already defined on line 0 [no-redef]
posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type]
posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type]
posthog/queries/app_metrics/test/test_app_metrics.py:0: error: Argument 3 to "AppMetricsErrorDetailsQuery" has incompatible type "AppMetricsRequestSerializer"; expected "AppMetricsErrorsRequestSerializer" [arg-type]
@@ -824,22 +824,34 @@ posthog/api/plugin_log_entry.py:0: error: Module "django.utils.timezone" does no
posthog/api/plugin_log_entry.py:0: error: Name "timezone.datetime" is not defined [name-defined]
posthog/api/plugin_log_entry.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "datetime" [attr-defined]
posthog/temporal/tests/batch_exports/test_redshift_batch_export_workflow.py:0: error: Incompatible types in assignment (expression has type "str | int", variable has type "int") [assignment]
-posthog/temporal/data_imports/external_data_job.py:0: error: Argument "status" to "update_external_job_status" has incompatible type "str"; expected "Status" [arg-type]
posthog/api/sharing.py:0: error: Item "None" of "list[Any] | None" has no attribute "__iter__" (not iterable) [union-attr]
+posthog/temporal/data_imports/external_data_job.py:0: error: Argument "status" to "update_external_job_status" has incompatible type "str"; expected "Status" [arg-type]
posthog/api/test/batch_exports/conftest.py:0: error: Signature of "run" incompatible with supertype "Worker" [override]
posthog/api/test/batch_exports/conftest.py:0: note: Superclass:
posthog/api/test/batch_exports/conftest.py:0: note: def run(self) -> Coroutine[Any, Any, None]
posthog/api/test/batch_exports/conftest.py:0: note: Subclass:
posthog/api/test/batch_exports/conftest.py:0: note: def run(self, loop: Any) -> Any
posthog/api/test/batch_exports/conftest.py:0: error: Argument "activities" to "ThreadedWorker" has incompatible type "list[function]"; expected "Sequence[Callable[..., Any]]" [arg-type]
+posthog/api/test/test_team.py:0: error: "HttpResponse" has no attribute "json" [attr-defined]
+posthog/api/test/test_team.py:0: error: "HttpResponse" has no attribute "json" [attr-defined]
+posthog/api/test/test_capture.py:0: error: Statement is unreachable [unreachable]
+posthog/api/test/test_capture.py:0: error: Incompatible return value type (got "_MonkeyPatchedWSGIResponse", expected "HttpResponse") [return-value]
+posthog/api/test/test_capture.py:0: error: Module has no attribute "utc" [attr-defined]
+posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
+posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
+posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
+posthog/test/test_middleware.py:0: error: Incompatible types in assignment (expression has type "_MonkeyPatchedWSGIResponse", variable has type "_MonkeyPatchedResponse") [assignment]
posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index]
posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index]
posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index]
posthog/temporal/tests/external_data/test_external_data_job.py:0: error: Invalid index type "str" for "dict[Type, Sequence[str]]"; expected type "Type" [index]
posthog/temporal/tests/data_imports/test_end_to_end.py:0: error: Unused "type: ignore" comment [unused-ignore]
-posthog/api/test/test_team.py:0: error: "HttpResponse" has no attribute "json" [attr-defined]
-posthog/api/test/test_team.py:0: error: "HttpResponse" has no attribute "json" [attr-defined]
-posthog/test/test_middleware.py:0: error: Incompatible types in assignment (expression has type "_MonkeyPatchedWSGIResponse", variable has type "_MonkeyPatchedResponse") [assignment]
posthog/management/commands/test/test_create_batch_export_from_app.py:0: error: Incompatible return value type (got "dict[str, Collection[str]]", expected "dict[str, str]") [return-value]
posthog/management/commands/test/test_create_batch_export_from_app.py:0: error: Incompatible types in assignment (expression has type "dict[str, Collection[str]]", variable has type "dict[str, str]") [assignment]
posthog/management/commands/test/test_create_batch_export_from_app.py:0: error: Unpacked dict entry 1 has incompatible type "str"; expected "SupportsKeysAndGetItem[str, str]" [dict-item]
@@ -881,16 +893,3 @@ posthog/api/test/batch_exports/test_update.py:0: error: Value of type "BatchExpo
posthog/api/test/batch_exports/test_update.py:0: error: Value of type "BatchExport" is not indexable [index]
posthog/api/test/batch_exports/test_update.py:0: error: Value of type "BatchExport" is not indexable [index]
posthog/api/test/batch_exports/test_pause.py:0: error: "batch_export_delete_schedule" does not return a value (it only ever returns None) [func-returns-value]
-posthog/api/query.py:0: error: Statement is unreachable [unreachable]
-posthog/api/test/test_capture.py:0: error: Statement is unreachable [unreachable]
-posthog/api/test/test_capture.py:0: error: Incompatible return value type (got "_MonkeyPatchedWSGIResponse", expected "HttpResponse") [return-value]
-posthog/api/test/test_capture.py:0: error: Module has no attribute "utc" [attr-defined]
-posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
-posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
-posthog/api/test/test_capture.py:0: error: Unpacked dict entry 0 has incompatible type "Collection[str]"; expected "SupportsKeysAndGetItem[str, dict[Never, Never]]" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
-posthog/api/test/test_capture.py:0: error: Dict entry 0 has incompatible type "str": "float"; expected "str": "int" [dict-item]
diff --git a/package.json b/package.json
index 14bf97b7876c0..56b33a5516741 100644
--- a/package.json
+++ b/package.json
@@ -27,11 +27,11 @@
"test:visual:ci:update": "test-storybook -u --no-index-json --maxWorkers=2",
"test:visual:ci:verify": "test-storybook --ci --no-index-json --maxWorkers=2",
"start": "concurrently -n ESBUILD,TYPEGEN -c yellow,green \"pnpm start-http\" \"pnpm run typegen:watch\"",
- "start-http": "pnpm clean && pnpm copy-scripts && node frontend/build.mjs --dev",
+ "start-http": "pnpm clean && pnpm copy-scripts && pnpm build:esbuild --dev",
"start-docker": "pnpm start-http --host 0.0.0.0",
"clean": "rm -rf frontend/dist && mkdir frontend/dist",
"build": "pnpm copy-scripts && pnpm build:esbuild",
- "build:esbuild": "node frontend/build.mjs",
+ "build:esbuild": "DEBUG=0 node frontend/build.mjs",
"schema:build": "pnpm run schema:build:json && pnpm run schema:build:python",
"schema:build:json": "ts-node bin/build-schema-json.mjs && prettier --write frontend/src/queries/schema.json",
"schema:build:python": "bash bin/build-schema-python.sh",
@@ -77,7 +77,7 @@
"@microlink/react-json-view": "^1.21.3",
"@microsoft/fetch-event-source": "^2.0.1",
"@monaco-editor/react": "4.6.0",
- "@posthog/hogvm": "^1.0.61",
+ "@posthog/hogvm": "^1.0.66",
"@posthog/icons": "0.9.2",
"@posthog/plugin-scaffold": "^1.4.4",
"@react-hook/size": "^2.1.2",
@@ -161,7 +161,7 @@
"pmtiles": "^2.11.0",
"postcss": "^8.4.31",
"postcss-preset-env": "^9.3.0",
- "posthog-js": "1.200.2",
+ "posthog-js": "1.203.1",
"posthog-js-lite": "3.0.0",
"prettier": "^2.8.8",
"prop-types": "^15.7.2",
@@ -265,7 +265,7 @@
"axe-core": "^4.4.3",
"babel-loader": "^8.0.6",
"babel-plugin-import": "^1.13.0",
- "caniuse-lite": "^1.0.30001687",
+ "caniuse-lite": "^1.0.30001689",
"concurrently": "^5.3.0",
"css-loader": "^3.4.2",
"cypress": "^13.11.0",
diff --git a/plugin-server/package.json b/plugin-server/package.json
index 1d46f73ad6c74..5f2a9dfdac165 100644
--- a/plugin-server/package.json
+++ b/plugin-server/package.json
@@ -54,7 +54,7 @@
"@maxmind/geoip2-node": "^3.4.0",
"@posthog/clickhouse": "^1.7.0",
"@posthog/cyclotron": "file:../rust/cyclotron-node",
- "@posthog/hogvm": "^1.0.61",
+ "@posthog/hogvm": "^1.0.66",
"@posthog/plugin-scaffold": "1.4.4",
"@sentry/node": "^7.49.0",
"@sentry/profiling-node": "^0.3.0",
@@ -69,16 +69,17 @@
"express": "^4.18.2",
"faker": "^5.5.3",
"fast-deep-equal": "^3.1.3",
+ "fastpriorityqueue": "^0.7.5",
"fernet-nodejs": "^1.0.6",
"generic-pool": "^3.7.1",
"graphile-worker": "0.13.0",
"ioredis": "^4.27.6",
"ipaddr.js": "^2.1.0",
"kafkajs": "^2.2.0",
- "lz4-kafkajs": "1.0.0",
"kafkajs-snappy": "^1.1.0",
"lru-cache": "^6.0.0",
"luxon": "^3.4.4",
+ "lz4-kafkajs": "1.0.0",
"node-fetch": "^2.6.1",
"node-rdkafka": "^2.17.0",
"node-schedule": "^2.1.0",
@@ -92,7 +93,8 @@
"tail": "^2.2.6",
"uuid": "^9.0.1",
"v8-profiler-next": "^1.9.0",
- "vm2": "3.9.18"
+ "vm2": "3.9.18",
+ "zod": "^3.24.1"
},
"devDependencies": {
"0x": "^5.5.0",
@@ -111,7 +113,7 @@
"@types/ioredis": "^4.26.4",
"@types/jest": "^28.1.1",
"@types/long": "4.x.x",
- "@types/luxon": "^1.27.0",
+ "@types/luxon": "^3.4.2",
"@types/node": "^16.0.0",
"@types/node-fetch": "^2.5.10",
"@types/node-schedule": "^2.1.0",
diff --git a/plugin-server/pnpm-lock.yaml b/plugin-server/pnpm-lock.yaml
index 685a4b68c5314..e23910979edfc 100644
--- a/plugin-server/pnpm-lock.yaml
+++ b/plugin-server/pnpm-lock.yaml
@@ -47,8 +47,8 @@ dependencies:
specifier: file:../rust/cyclotron-node
version: file:../rust/cyclotron-node
'@posthog/hogvm':
- specifier: ^1.0.61
- version: 1.0.61(luxon@3.4.4)
+ specifier: ^1.0.66
+ version: 1.0.66(luxon@3.4.4)
'@posthog/plugin-scaffold':
specifier: 1.4.4
version: 1.4.4
@@ -91,6 +91,9 @@ dependencies:
fast-deep-equal:
specifier: ^3.1.3
version: 3.1.3
+ fastpriorityqueue:
+ specifier: ^0.7.5
+ version: 0.7.5
fernet-nodejs:
specifier: ^1.0.6
version: 1.0.6
@@ -163,6 +166,9 @@ dependencies:
vm2:
specifier: 3.9.18
version: 3.9.18
+ zod:
+ specifier: ^3.24.1
+ version: 3.24.1
devDependencies:
0x:
@@ -214,8 +220,8 @@ devDependencies:
specifier: 4.x.x
version: 4.0.2
'@types/luxon':
- specifier: ^1.27.0
- version: 1.27.1
+ specifier: ^3.4.2
+ version: 3.4.2
'@types/node':
specifier: ^16.0.0
version: 16.18.25
@@ -2794,8 +2800,8 @@ packages:
engines: {node: '>=12'}
dev: false
- /@posthog/hogvm@1.0.61(luxon@3.4.4):
- resolution: {integrity: sha512-zCwQp6Zn2F2/QNhd1/0IwpndhElVZ2pzsoh2IOZWapZ+jNo6y/taL3128Uwoiec01IYiPXF8EVYOJP7X4Woj+A==}
+ /@posthog/hogvm@1.0.66(luxon@3.4.4):
+ resolution: {integrity: sha512-bczn4tB2rXRJVXihkRHGiNT+6ruYRLRtGRf9xhGlZmdFBL/QSJa5/gQqflp5de+N6UMofkyjdX8yvBwiTt3VHw==}
peerDependencies:
luxon: ^3.4.4
dependencies:
@@ -3797,8 +3803,8 @@ packages:
resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==}
dev: false
- /@types/luxon@1.27.1:
- resolution: {integrity: sha512-cPiXpOvPFDr2edMnOXlz3UBDApwUfR+cpizvxCy0n3vp9bz/qe8BWzHPIEFcy+ogUOyjKuCISgyq77ELZPmkkg==}
+ /@types/luxon@3.4.2:
+ resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
dev: true
/@types/markdown-it@12.2.3:
@@ -6276,6 +6282,10 @@ packages:
strnum: 1.0.5
dev: false
+ /fastpriorityqueue@0.7.5:
+ resolution: {integrity: sha512-3Pa0n9gwy8yIbEsT3m2j/E9DXgWvvjfiZjjqcJ+AdNKTAlVMIuFYrYG5Y3RHEM8O6cwv9hOpOWY/NaMfywoQVA==}
+ dev: false
+
/fastq@1.15.0:
resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==}
dependencies:
@@ -10908,6 +10918,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ /zod@3.24.1:
+ resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
+ dev: false
+
file:../rust/cyclotron-node:
resolution: {directory: ../rust/cyclotron-node, type: directory}
name: '@posthog/cyclotron'
diff --git a/plugin-server/src/capabilities.ts b/plugin-server/src/capabilities.ts
index 6a9d30af15ff4..9cefda83bb90d 100644
--- a/plugin-server/src/capabilities.ts
+++ b/plugin-server/src/capabilities.ts
@@ -24,6 +24,7 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin
appManagementSingleton: true,
preflightSchedules: true,
cdpProcessedEvents: true,
+ cdpInternalEvents: true,
cdpFunctionCallbacks: true,
cdpCyclotronWorker: true,
syncInlinePlugins: true,
@@ -98,6 +99,11 @@ export function getPluginServerCapabilities(config: PluginsServerConfig): Plugin
cdpProcessedEvents: true,
...sharedCapabilities,
}
+ case PluginServerMode.cdp_internal_events:
+ return {
+ cdpInternalEvents: true,
+ ...sharedCapabilities,
+ }
case PluginServerMode.cdp_function_callbacks:
return {
cdpFunctionCallbacks: true,
diff --git a/plugin-server/src/cdp/cdp-api.ts b/plugin-server/src/cdp/cdp-api.ts
index ed4e60976b1e9..a5c3f84e4f276 100644
--- a/plugin-server/src/cdp/cdp-api.ts
+++ b/plugin-server/src/cdp/cdp-api.ts
@@ -136,6 +136,7 @@ export class CdpApi {
id: team.id,
name: team.name,
url: `${this.hub.SITE_URL ?? 'http://localhost:8000'}/project/${team.id}`,
+ ...globals.project,
},
},
compoundConfiguration,
diff --git a/plugin-server/src/cdp/cdp-consumers.ts b/plugin-server/src/cdp/cdp-consumers.ts
index dbbd163c72ae8..a219cd6242864 100644
--- a/plugin-server/src/cdp/cdp-consumers.ts
+++ b/plugin-server/src/cdp/cdp-consumers.ts
@@ -7,6 +7,7 @@ import { buildIntegerMatcher } from '../config/config'
import {
KAFKA_APP_METRICS_2,
KAFKA_CDP_FUNCTION_CALLBACKS,
+ KAFKA_CDP_INTERNAL_EVENTS,
KAFKA_EVENTS_JSON,
KAFKA_EVENTS_PLUGIN_INGESTION,
KAFKA_LOG_ENTRIES,
@@ -28,7 +29,7 @@ import { createKafkaProducerWrapper } from '../utils/db/hub'
import { KafkaProducerWrapper } from '../utils/db/kafka-producer-wrapper'
import { safeClickhouseString } from '../utils/db/utils'
import { status } from '../utils/status'
-import { castTimestampOrNow } from '../utils/utils'
+import { castTimestampOrNow, UUIDT } from '../utils/utils'
import { RustyHook } from '../worker/rusty-hook'
import { FetchExecutor } from './fetch-executor'
import { GroupsManager } from './groups-manager'
@@ -37,16 +38,21 @@ import { HogFunctionManager } from './hog-function-manager'
import { HogMasker } from './hog-masker'
import { HogWatcher, HogWatcherState } from './hog-watcher'
import { CdpRedis, createCdpRedisPool } from './redis'
+import { CdpInternalEventSchema } from './schema'
import {
HogFunctionInvocation,
HogFunctionInvocationGlobals,
HogFunctionInvocationResult,
HogFunctionInvocationSerialized,
HogFunctionInvocationSerializedCompressed,
+ HogFunctionLogEntrySerialized,
HogFunctionMessageToProduce,
+ HogFunctionType,
+ HogFunctionTypeType,
HogHooksFetchResponse,
} from './types'
import {
+ convertInternalEventToHogFunctionInvocationGlobals,
convertToCaptureEvent,
convertToHogFunctionInvocationGlobals,
createInvocation,
@@ -79,6 +85,12 @@ const counterFunctionInvocation = new Counter({
labelNames: ['outcome'], // One of 'failed', 'succeeded', 'overflowed', 'disabled', 'filtered'
})
+const counterParseError = new Counter({
+ name: 'cdp_function_parse_error',
+ help: 'A function invocation was parsed with an error',
+ labelNames: ['error'],
+})
+
const gaugeBatchUtilization = new Gauge({
name: 'cdp_cyclotron_batch_utilization',
help: 'Indicates how big batches are we are processing compared to the max batch size. Useful as a scaling metric',
@@ -108,6 +120,7 @@ abstract class CdpConsumerBase {
messagesToProduce: HogFunctionMessageToProduce[] = []
redis: CdpRedis
+ protected hogTypes: HogFunctionTypeType[] = []
protected kafkaProducer?: KafkaProducerWrapper
protected abstract name: string
@@ -199,6 +212,24 @@ abstract class CdpConsumerBase {
})
}
+ protected logFilteringError(item: HogFunctionType, error: string) {
+ const logEntry: HogFunctionLogEntrySerialized = {
+ team_id: item.team_id,
+ log_source: 'hog_function',
+ log_source_id: item.id,
+ instance_id: new UUIDT().toString(), // random UUID, like it would be for an invocation
+ timestamp: castTimestampOrNow(null, TimestampFormat.ClickHouse),
+ level: 'error',
+ message: error,
+ }
+
+ this.messagesToProduce.push({
+ topic: KAFKA_LOG_ENTRIES,
+ value: logEntry,
+ key: logEntry.instance_id,
+ })
+ }
+
// NOTE: These will be removed once we are only on Cyclotron
protected async queueInvocationsToKafka(invocation: HogFunctionInvocation[]) {
await Promise.all(
@@ -343,7 +374,7 @@ abstract class CdpConsumerBase {
public async start(): Promise {
// NOTE: This is only for starting shared services
await Promise.all([
- this.hogFunctionManager.start(),
+ this.hogFunctionManager.start(this.hogTypes),
createKafkaProducerWrapper(this.hub).then((producer) => {
this.kafkaProducer = producer
this.kafkaProducer.producer.connect()
@@ -377,6 +408,10 @@ abstract class CdpConsumerBase {
*/
export class CdpProcessedEventsConsumer extends CdpConsumerBase {
protected name = 'CdpProcessedEventsConsumer'
+ protected topic = KAFKA_EVENTS_JSON
+ protected groupId = 'cdp-processed-events-consumer'
+ protected hogTypes: HogFunctionTypeType[] = ['destination']
+
private cyclotronMatcher: ValueMatcher
private cyclotronManager?: CyclotronManager
@@ -479,7 +514,7 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase {
})
)
- erroredFunctions.forEach((item) =>
+ erroredFunctions.forEach(([item, error]) => {
this.produceAppMetric({
team_id: item.team_id,
app_source_id: item.id,
@@ -487,7 +522,8 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase {
metric_name: 'filtering_failed',
count: 1,
})
- )
+ this.logFilteringError(item, error)
+ })
})
const states = await this.hogWatcher.getStates(possibleInvocations.map((x) => x.hogFunction.id))
@@ -538,8 +574,8 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase {
}
// This consumer always parses from kafka
- public async _handleKafkaBatch(messages: Message[]): Promise {
- const invocationGlobals = await this.runWithHeartbeat(() =>
+ public async _parseKafkaBatch(messages: Message[]): Promise {
+ return await this.runWithHeartbeat(() =>
runInstrumentedFunction({
statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`,
func: async () => {
@@ -575,16 +611,17 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase {
},
})
)
-
- await this.processBatch(invocationGlobals)
}
public async start(): Promise {
await super.start()
await this.startKafkaConsumer({
- topic: KAFKA_EVENTS_JSON,
- groupId: 'cdp-processed-events-consumer',
- handleBatch: (messages) => this._handleKafkaBatch(messages),
+ topic: this.topic,
+ groupId: this.groupId,
+ handleBatch: async (messages) => {
+ const invocationGlobals = await this._parseKafkaBatch(messages)
+ await this.processBatch(invocationGlobals)
+ },
})
const shardDepthLimit = this.hub.CYCLOTRON_SHARD_DEPTH_LIMIT ?? 1000000
@@ -597,11 +634,66 @@ export class CdpProcessedEventsConsumer extends CdpConsumerBase {
}
}
+/**
+ * This consumer handles incoming events from the main clickhouse topic
+ * Currently it produces to both kafka and Cyclotron based on the team
+ */
+export class CdpInternalEventsConsumer extends CdpProcessedEventsConsumer {
+ protected name = 'CdpInternalEventsConsumer'
+ protected topic = KAFKA_CDP_INTERNAL_EVENTS
+ protected groupId = 'cdp-internal-events-consumer'
+ protected hogTypes: HogFunctionTypeType[] = ['internal_destination']
+
+ // This consumer always parses from kafka
+ public async _parseKafkaBatch(messages: Message[]): Promise {
+ return await this.runWithHeartbeat(() =>
+ runInstrumentedFunction({
+ statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`,
+ func: async () => {
+ const events: HogFunctionInvocationGlobals[] = []
+ await Promise.all(
+ messages.map(async (message) => {
+ try {
+ const kafkaEvent = JSON.parse(message.value!.toString()) as unknown
+ // This is the input stream from elsewhere so we want to do some proper validation
+ const event = CdpInternalEventSchema.parse(kafkaEvent)
+
+ if (!this.hogFunctionManager.teamHasHogDestinations(event.team_id)) {
+ // No need to continue if the team doesn't have any functions
+ return
+ }
+
+ const team = await this.hub.teamManager.fetchTeam(event.team_id)
+ if (!team) {
+ return
+ }
+ events.push(
+ convertInternalEventToHogFunctionInvocationGlobals(
+ event,
+ team,
+ this.hub.SITE_URL ?? 'http://localhost:8000'
+ )
+ )
+ } catch (e) {
+ status.error('Error parsing message', e)
+ counterParseError.labels({ error: e.message }).inc()
+ }
+ })
+ )
+
+ return events
+ },
+ })
+ )
+ }
+}
+
/**
* This consumer only deals with kafka messages and will eventually be replaced by the Cyclotron worker
*/
export class CdpFunctionCallbackConsumer extends CdpConsumerBase {
protected name = 'CdpFunctionCallbackConsumer'
+ protected hogTypes: HogFunctionTypeType[] = ['destination', 'internal_destination']
public async processBatch(invocations: HogFunctionInvocation[]): Promise {
if (!invocations.length) {
@@ -637,8 +729,8 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase {
await this.produceQueuedMessages()
}
- public async _handleKafkaBatch(messages: Message[]): Promise {
- const events = await this.runWithHeartbeat(() =>
+ public async _parseKafkaBatch(messages: Message[]): Promise {
+ return await this.runWithHeartbeat(() =>
runInstrumentedFunction({
statsKey: `cdpConsumer.handleEachBatch.parseKafkaMessages`,
func: async () => {
@@ -706,8 +798,6 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase {
},
})
)
-
- await this.processBatch(events)
}
public async start(): Promise {
@@ -715,7 +805,10 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase {
await this.startKafkaConsumer({
topic: KAFKA_CDP_FUNCTION_CALLBACKS,
groupId: 'cdp-function-callback-consumer',
- handleBatch: (messages) => this._handleKafkaBatch(messages),
+ handleBatch: async (messages) => {
+ const invocations = await this._parseKafkaBatch(messages)
+ await this.processBatch(invocations)
+ },
})
}
}
@@ -728,6 +821,7 @@ export class CdpCyclotronWorker extends CdpConsumerBase {
private cyclotronWorker?: CyclotronWorker
private runningWorker: Promise | undefined
protected queue: 'hog' | 'fetch' = 'hog'
+ protected hogTypes: HogFunctionTypeType[] = ['destination', 'internal_destination']
public async processBatch(invocations: HogFunctionInvocation[]): Promise {
if (!invocations.length) {
diff --git a/plugin-server/src/cdp/hog-executor.ts b/plugin-server/src/cdp/hog-executor.ts
index 15e147f022b7f..e45536dc947da 100644
--- a/plugin-server/src/cdp/hog-executor.ts
+++ b/plugin-server/src/cdp/hog-executor.ts
@@ -10,6 +10,7 @@ import { status } from '../utils/status'
import { HogFunctionManager } from './hog-function-manager'
import {
CyclotronFetchFailureInfo,
+ HogFunctionInputType,
HogFunctionInvocation,
HogFunctionInvocationGlobals,
HogFunctionInvocationGlobalsWithInputs,
@@ -103,6 +104,16 @@ const sanitizeLogMessage = (args: any[], sensitiveValues?: string[]): string =>
return message
}
+const orderInputsByDependency = (hogFunction: HogFunctionType): [string, HogFunctionInputType][] => {
+ const allInputs: HogFunctionType['inputs'] = {
+ ...hogFunction.inputs,
+ ...hogFunction.encrypted_inputs,
+ }
+ return Object.entries(allInputs).sort(([_, input1], [__, input2]) => {
+ return (input1.order ?? -1) - (input2.order ?? -1)
+ })
+}
+
export class HogExecutor {
private telemetryMatcher: ValueMatcher
@@ -110,17 +121,17 @@ export class HogExecutor {
this.telemetryMatcher = buildIntegerMatcher(this.hub.CDP_HOG_FILTERS_TELEMETRY_TEAMS, true)
}
- findMatchingFunctions(event: HogFunctionInvocationGlobals): {
+ findMatchingFunctions(globals: HogFunctionInvocationGlobals): {
matchingFunctions: HogFunctionType[]
nonMatchingFunctions: HogFunctionType[]
- erroredFunctions: HogFunctionType[]
+ erroredFunctions: [HogFunctionType, string][]
} {
- const allFunctionsForTeam = this.hogFunctionManager.getTeamHogDestinations(event.project.id)
- const filtersGlobals = convertToHogFunctionFilterGlobal(event)
+ const allFunctionsForTeam = this.hogFunctionManager.getTeamHogFunctions(globals.project.id)
+ const filtersGlobals = convertToHogFunctionFilterGlobal(globals)
const nonMatchingFunctions: HogFunctionType[] = []
const matchingFunctions: HogFunctionType[] = []
- const erroredFunctions: HogFunctionType[] = []
+ const erroredFunctions: [HogFunctionType, string][] = []
// Filter all functions based on the invocation
allFunctionsForTeam.forEach((hogFunction) => {
@@ -143,7 +154,10 @@ export class HogExecutor {
error: filterResult.error.message,
result: filterResult,
})
- erroredFunctions.push(hogFunction)
+ erroredFunctions.push([
+ hogFunction,
+ `Error filtering event ${globals.event.uuid}: ${filterResult.error.message}`,
+ ])
return
}
} catch (error) {
@@ -153,7 +167,10 @@ export class HogExecutor {
teamId: hogFunction.team_id,
error: error.message,
})
- erroredFunctions.push(hogFunction)
+ erroredFunctions.push([
+ hogFunction,
+ `Error filtering event ${globals.event.uuid}: ${error.message}`,
+ ])
return
} finally {
const duration = performance.now() - start
@@ -165,7 +182,7 @@ export class HogExecutor {
hogFunctionName: hogFunction.name,
teamId: hogFunction.team_id,
duration,
- eventId: event.event.uuid,
+ eventId: globals.event.uuid,
})
}
}
@@ -316,39 +333,39 @@ export class HogExecutor {
// We need to pass these in but they don't actually do anything as it is a sync exec
fetch: async () => Promise.resolve(),
},
- importBytecode: (module) => {
- // TODO: more than one hardcoded module
- if (module === 'provider/email') {
- const provider = this.hogFunctionManager.getTeamHogEmailProvider(invocation.teamId)
- if (!provider) {
- throw new Error('No email provider configured')
- }
- try {
- const providerGlobals = this.buildHogFunctionGlobals({
- id: '',
- teamId: invocation.teamId,
- hogFunction: provider,
- globals: {} as any,
- queue: 'hog',
- timings: [],
- priority: 0,
- } satisfies HogFunctionInvocation)
-
- return {
- bytecode: provider.bytecode,
- globals: providerGlobals,
- }
- } catch (e) {
- result.logs.push({
- level: 'error',
- timestamp: DateTime.now(),
- message: `Error building inputs: ${e}`,
- })
- throw e
- }
- }
- throw new Error(`Can't import unknown module: ${module}`)
- },
+ // importBytecode: (module) => {
+ // // TODO: more than one hardcoded module
+ // if (module === 'provider/email') {
+ // const provider = this.hogFunctionManager.getTeamHogEmailProvider(invocation.teamId)
+ // if (!provider) {
+ // throw new Error('No email provider configured')
+ // }
+ // try {
+ // const providerGlobals = this.buildHogFunctionGlobals({
+ // id: '',
+ // teamId: invocation.teamId,
+ // hogFunction: provider,
+ // globals: {} as any,
+ // queue: 'hog',
+ // timings: [],
+ // priority: 0,
+ // } satisfies HogFunctionInvocation)
+
+ // return {
+ // bytecode: provider.bytecode,
+ // globals: providerGlobals,
+ // }
+ // } catch (e) {
+ // result.logs.push({
+ // level: 'error',
+ // timestamp: DateTime.now(),
+ // message: `Error building inputs: ${e}`,
+ // })
+ // throw e
+ // }
+ // }
+ // throw new Error(`Can't import unknown module: ${module}`)
+ // },
functions: {
print: (...args) => {
hogLogs++
@@ -523,30 +540,23 @@ export class HogExecutor {
}
buildHogFunctionGlobals(invocation: HogFunctionInvocation): HogFunctionInvocationGlobalsWithInputs {
- const builtInputs: Record = {}
-
- Object.entries(invocation.hogFunction.inputs ?? {}).forEach(([key, item]) => {
- builtInputs[key] = item.value
+ const newGlobals: HogFunctionInvocationGlobalsWithInputs = {
+ ...invocation.globals,
+ inputs: {},
+ }
- if (item.bytecode) {
- // Use the bytecode to compile the field
- builtInputs[key] = formatInput(item.bytecode, invocation.globals, key)
- }
- })
+ const orderedInputs = orderInputsByDependency(invocation.hogFunction)
- Object.entries(invocation.hogFunction.encrypted_inputs ?? {}).forEach(([key, item]) => {
- builtInputs[key] = item.value
+ for (const [key, input] of orderedInputs) {
+ newGlobals.inputs[key] = input.value
- if (item.bytecode) {
+ if (input.bytecode) {
// Use the bytecode to compile the field
- builtInputs[key] = formatInput(item.bytecode, invocation.globals, key)
+ newGlobals.inputs[key] = formatInput(input.bytecode, newGlobals, key)
}
- })
-
- return {
- ...invocation.globals,
- inputs: builtInputs,
}
+
+ return newGlobals
}
getSensitiveValues(hogFunction: HogFunctionType, inputs: Record): string[] {
diff --git a/plugin-server/src/cdp/hog-function-manager.ts b/plugin-server/src/cdp/hog-function-manager.ts
index c53ff71952ec2..aea3ffb9b10e5 100644
--- a/plugin-server/src/cdp/hog-function-manager.ts
+++ b/plugin-server/src/cdp/hog-function-manager.ts
@@ -5,7 +5,7 @@ import { Hub, Team } from '../types'
import { PostgresUse } from '../utils/db/postgres'
import { PubSub } from '../utils/pubsub'
import { status } from '../utils/status'
-import { HogFunctionType, IntegrationType } from './types'
+import { HogFunctionType, HogFunctionTypeType, IntegrationType } from './types'
type HogFunctionCache = {
functions: Record
@@ -26,14 +26,13 @@ const HOG_FUNCTION_FIELDS = [
'type',
]
-const RELOAD_HOG_FUNCTION_TYPES = ['destination', 'email']
-
export class HogFunctionManager {
private started: boolean
private ready: boolean
private cache: HogFunctionCache
private pubSub: PubSub
private refreshJob?: schedule.Job
+ private hogTypes: HogFunctionTypeType[] = []
constructor(private hub: Hub) {
this.started = false
@@ -60,7 +59,8 @@ export class HogFunctionManager {
})
}
- public async start(): Promise {
+ public async start(hogTypes: HogFunctionTypeType[]): Promise {
+ this.hogTypes = hogTypes
// TRICKY - when running with individual capabilities, this won't run twice but locally or as a complete service it will...
if (this.started) {
return
@@ -96,14 +96,6 @@ export class HogFunctionManager {
.filter((x) => !!x) as HogFunctionType[]
}
- public getTeamHogDestinations(teamId: Team['id']): HogFunctionType[] {
- return this.getTeamHogFunctions(teamId).filter((x) => x.type === 'destination' || !x.type)
- }
-
- public getTeamHogEmailProvider(teamId: Team['id']): HogFunctionType | undefined {
- return this.getTeamHogFunctions(teamId).find((x) => x.type === 'email')
- }
-
public getHogFunction(id: HogFunctionType['id']): HogFunctionType | undefined {
if (!this.ready) {
throw new Error('HogFunctionManager is not ready! Run HogFunctionManager.start() before this')
@@ -124,7 +116,7 @@ export class HogFunctionManager {
}
public teamHasHogDestinations(teamId: Team['id']): boolean {
- return !!Object.keys(this.getTeamHogDestinations(teamId)).length
+ return !!Object.keys(this.getTeamHogFunctions(teamId)).length
}
public async reloadAllHogFunctions(): Promise {
@@ -134,9 +126,9 @@ export class HogFunctionManager {
`
SELECT ${HOG_FUNCTION_FIELDS.join(', ')}
FROM posthog_hogfunction
- WHERE deleted = FALSE AND enabled = TRUE AND (type is NULL or type = ANY($1))
+ WHERE deleted = FALSE AND enabled = TRUE AND type = ANY($1)
`,
- [RELOAD_HOG_FUNCTION_TYPES],
+ [this.hogTypes],
'fetchAllHogFunctions'
)
).rows
@@ -167,8 +159,8 @@ export class HogFunctionManager {
PostgresUse.COMMON_READ,
`SELECT ${HOG_FUNCTION_FIELDS.join(', ')}
FROM posthog_hogfunction
- WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE`,
- [ids],
+ WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE AND type = ANY($2)`,
+ [ids, this.hogTypes],
'fetchEnabledHogFunctions'
)
).rows
@@ -218,6 +210,11 @@ export class HogFunctionManager {
items.forEach((item) => {
const encryptedInputsString = item.encrypted_inputs as string | undefined
+ if (!Array.isArray(item.inputs_schema)) {
+ // NOTE: The sql lib can sometimes return an empty object instead of an empty array
+ item.inputs_schema = []
+ }
+
if (encryptedInputsString) {
try {
const decrypted = this.hub.encryptedFields.decrypt(encryptedInputsString || '')
diff --git a/plugin-server/src/cdp/schema.ts b/plugin-server/src/cdp/schema.ts
new file mode 100644
index 0000000000000..35dbf01e5e3f3
--- /dev/null
+++ b/plugin-server/src/cdp/schema.ts
@@ -0,0 +1,27 @@
+import { z } from 'zod'
+
+export const CdpInternalEventSchema = z.object({
+ team_id: z.number(),
+ event: z.object({
+ uuid: z.string(),
+ event: z.string(),
+ // In this context distinct_id should be whatever we want to use if doing follow up things (like tracking a standard event)
+ distinct_id: z.string(),
+ properties: z.record(z.any()),
+ timestamp: z.string(),
+ url: z.string().optional().nullable(),
+ }),
+ // Person may be a event-style person or an org member
+ person: z
+ .object({
+ id: z.string(),
+ properties: z.record(z.any()),
+ name: z.string().optional().nullable(),
+ url: z.string().optional().nullable(),
+ })
+ .optional()
+ .nullable(),
+})
+
+// Infer the TypeScript type
+export type CdpInternalEvent = z.infer
diff --git a/plugin-server/src/cdp/types.ts b/plugin-server/src/cdp/types.ts
index e9d506a7a7823..9f7b8bba7433f 100644
--- a/plugin-server/src/cdp/types.ts
+++ b/plugin-server/src/cdp/types.ts
@@ -275,7 +275,16 @@ export type HogFunctionInputSchemaType = {
requiredScopes?: string
}
-export type HogFunctionTypeType = 'destination' | 'email' | 'sms' | 'push' | 'activity' | 'alert' | 'broadcast'
+export type HogFunctionTypeType =
+ | 'destination'
+ | 'transformation'
+ | 'internal_destination'
+ | 'email'
+ | 'sms'
+ | 'push'
+ | 'activity'
+ | 'alert'
+ | 'broadcast'
export type HogFunctionType = {
id: string
@@ -297,6 +306,7 @@ export type HogFunctionInputType = {
value: any
secret?: boolean
bytecode?: HogBytecode | object
+ order?: number
}
export type IntegrationType = {
diff --git a/plugin-server/src/cdp/utils.ts b/plugin-server/src/cdp/utils.ts
index f4c09b602a514..73909ec9e5a7b 100644
--- a/plugin-server/src/cdp/utils.ts
+++ b/plugin-server/src/cdp/utils.ts
@@ -11,6 +11,7 @@ import { RawClickHouseEvent, Team, TimestampFormat } from '../types'
import { safeClickhouseString } from '../utils/db/utils'
import { status } from '../utils/status'
import { castTimestampOrNow, clickHouseTimestampToISO, UUIDT } from '../utils/utils'
+import { CdpInternalEvent } from './schema'
import {
HogFunctionCapturedEvent,
HogFunctionFilterGlobals,
@@ -90,6 +91,47 @@ export function convertToHogFunctionInvocationGlobals(
return context
}
+export function convertInternalEventToHogFunctionInvocationGlobals(
+ data: CdpInternalEvent,
+ team: Team,
+ siteUrl: string
+): HogFunctionInvocationGlobals {
+ const projectUrl = `${siteUrl}/project/${team.id}`
+
+ let person: HogFunctionInvocationGlobals['person']
+
+ if (data.person) {
+ const personDisplayName = getPersonDisplayName(team, data.event.distinct_id, data.person.properties)
+
+ person = {
+ id: data.person.id,
+ properties: data.person.properties,
+ name: personDisplayName,
+ url: data.person.url ?? '',
+ }
+ }
+
+ const context: HogFunctionInvocationGlobals = {
+ project: {
+ id: team.id,
+ name: team.name,
+ url: projectUrl,
+ },
+ event: {
+ uuid: data.event.uuid,
+ event: data.event.event,
+ elements_chain: '', // Not applicable but left here for compatibility
+ distinct_id: data.event.distinct_id,
+ properties: data.event.properties,
+ timestamp: data.event.timestamp,
+ url: data.event.url ?? '',
+ },
+ person,
+ }
+
+ return context
+}
+
function getElementsChainHref(elementsChain: string): string {
// Adapted from SQL: extract(elements_chain, '(?::|\")href="(.*?)"'),
const hrefRegex = new RE2(/(?::|")href="(.*?)"/)
diff --git a/plugin-server/src/config/kafka-topics.ts b/plugin-server/src/config/kafka-topics.ts
index 8610bf8f0b819..79959a951e9a7 100644
--- a/plugin-server/src/config/kafka-topics.ts
+++ b/plugin-server/src/config/kafka-topics.ts
@@ -45,6 +45,7 @@ export const KAFKA_LOG_ENTRIES = `${prefix}log_entries${suffix}`
// CDP topics
export const KAFKA_CDP_FUNCTION_CALLBACKS = `${prefix}cdp_function_callbacks${suffix}`
export const KAFKA_CDP_FUNCTION_OVERFLOW = `${prefix}cdp_function_overflow${suffix}`
+export const KAFKA_CDP_INTERNAL_EVENTS = `${prefix}cdp_internal_events${suffix}`
// Error tracking topics
export const KAFKA_EXCEPTION_SYMBOLIFICATION_EVENTS = `${prefix}exception_symbolification_events${suffix}`
diff --git a/plugin-server/src/kafka/batch-consumer.ts b/plugin-server/src/kafka/batch-consumer.ts
index 2f1082f2aa5b4..66f4eac0ea0f7 100644
--- a/plugin-server/src/kafka/batch-consumer.ts
+++ b/plugin-server/src/kafka/batch-consumer.ts
@@ -249,6 +249,8 @@ export const startBatchConsumer = async ({
let batchesProcessed = 0
const statusLogInterval = setInterval(() => {
status.info('š', 'main_loop', {
+ groupId,
+ topic,
messagesPerSecond: messagesProcessed / (STATUS_LOG_INTERVAL_MS / 1000),
batchesProcessed: batchesProcessed,
lastHeartbeatTime: new Date(lastHeartbeatTime).toISOString(),
diff --git a/plugin-server/src/main/pluginsServer.ts b/plugin-server/src/main/pluginsServer.ts
index d61f3bb5e0510..ac482ca21a6fa 100644
--- a/plugin-server/src/main/pluginsServer.ts
+++ b/plugin-server/src/main/pluginsServer.ts
@@ -15,6 +15,7 @@ import {
CdpCyclotronWorker,
CdpCyclotronWorkerFetch,
CdpFunctionCallbackConsumer,
+ CdpInternalEventsConsumer,
CdpProcessedEventsConsumer,
} from '../cdp/cdp-consumers'
import { defaultConfig } from '../config/config'
@@ -451,6 +452,13 @@ export async function startPluginsServer(
services.push(consumer.service)
}
+ if (capabilities.cdpInternalEvents) {
+ const hub = await setupHub()
+ const consumer = new CdpInternalEventsConsumer(hub)
+ await consumer.start()
+ services.push(consumer.service)
+ }
+
if (capabilities.cdpFunctionCallbacks) {
const hub = await setupHub()
const consumer = new CdpFunctionCallbackConsumer(hub)
diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts
index 47ac3764a3528..390f7d8d3a5a5 100644
--- a/plugin-server/src/types.ts
+++ b/plugin-server/src/types.ts
@@ -84,6 +84,7 @@ export enum PluginServerMode {
recordings_blob_ingestion = 'recordings-blob-ingestion',
recordings_blob_ingestion_overflow = 'recordings-blob-ingestion-overflow',
cdp_processed_events = 'cdp-processed-events',
+ cdp_internal_events = 'cdp-internal-events',
cdp_function_callbacks = 'cdp-function-callbacks',
cdp_cyclotron_worker = 'cdp-cyclotron-worker',
functional_tests = 'functional-tests',
@@ -358,6 +359,7 @@ export interface PluginServerCapabilities {
sessionRecordingBlobIngestion?: boolean
sessionRecordingBlobOverflowIngestion?: boolean
cdpProcessedEvents?: boolean
+ cdpInternalEvents?: boolean
cdpFunctionCallbacks?: boolean
cdpCyclotronWorker?: boolean
appManagementSingleton?: boolean
@@ -528,6 +530,12 @@ export enum PluginLogLevel {
Critical = 4, // only error type and system source
}
+export enum CookielessServerHashMode {
+ Disabled = 0,
+ Stateless = 1,
+ Stateful = 2,
+}
+
export interface PluginLogEntry {
id: string
team_id: number
@@ -633,13 +641,15 @@ export interface Team {
api_token: string
slack_incoming_webhook: string | null
session_recording_opt_in: boolean
- person_processing_opt_out?: boolean
+ person_processing_opt_out: boolean | null
heatmaps_opt_in: boolean | null
ingested_event: boolean
person_display_name_properties: string[] | null
test_account_filters:
| (EventPropertyFilter | PersonPropertyFilter | ElementPropertyFilter | CohortPropertyFilter)[]
| null
+ cookieless_server_hash_mode: CookielessServerHashMode | null
+ timezone: string
}
/** Properties shared by RawEventMessage and EventMessage. */
diff --git a/plugin-server/src/utils/concurrencyController.ts b/plugin-server/src/utils/concurrencyController.ts
new file mode 100644
index 0000000000000..ac84d439fd507
--- /dev/null
+++ b/plugin-server/src/utils/concurrencyController.ts
@@ -0,0 +1,133 @@
+import FastPriorityQueue from 'fastpriorityqueue'
+
+export function promiseResolveReject(): {
+ resolve: (value: T) => void
+ reject: (reason?: any) => void
+ promise: Promise
+} {
+ let resolve: (value: T) => void
+ let reject: (reason?: any) => void
+ const promise = new Promise((innerResolve, innerReject) => {
+ resolve = innerResolve
+ reject = innerReject
+ })
+ return { resolve: resolve!, reject: reject!, promise }
+}
+
+// Note that this file also exists in the frontend code, please keep them in sync as the tests only exist in the other version
+class ConcurrencyControllerItem {
+ _debugTag?: string
+ _runFn: () => Promise
+ _priority: number = Infinity
+ _promise: Promise
+ constructor(
+ concurrencyController: ConcurrencyController,
+ userFn: () => Promise,
+ abortController: AbortController | undefined,
+ priority: number = Infinity,
+ debugTag: string | undefined
+ ) {
+ this._debugTag = debugTag
+ this._priority = priority
+ const { promise, resolve, reject } = promiseResolveReject()
+ this._promise = promise
+ this._runFn = async () => {
+ if (abortController?.signal.aborted) {
+ reject(new FakeAbortError(abortController.signal.reason || 'AbortError'))
+ return
+ }
+ if (concurrencyController._current.length >= concurrencyController._concurrencyLimit) {
+ throw new Error('Developer Error: ConcurrencyControllerItem: _runFn called while already running')
+ }
+ try {
+ concurrencyController._current.push(this)
+ const result = await userFn()
+ resolve(result)
+ } catch (error) {
+ reject(error)
+ }
+ }
+ abortController?.signal.addEventListener('abort', () => {
+ reject(new FakeAbortError(abortController.signal.reason || 'AbortError'))
+ })
+ promise
+ .catch(() => {
+ // ignore
+ })
+ .finally(() => {
+ if (concurrencyController._current.includes(this)) {
+ concurrencyController._current = concurrencyController._current.filter((item) => item !== this)
+ concurrencyController._runNext()
+ }
+ })
+ }
+}
+
+export class ConcurrencyController {
+ _concurrencyLimit: number
+
+ _current: ConcurrencyControllerItem[] = []
+ private _queue: FastPriorityQueue> = new FastPriorityQueue(
+ (a, b) => a._priority < b._priority
+ )
+
+ constructor(concurrencyLimit: number) {
+ this._concurrencyLimit = concurrencyLimit
+ }
+
+ /**
+ * Run a function with a mutex. If the mutex is already running, the function will be queued and run when the mutex
+ * is available.
+ * @param fn The function to run
+ * @param priority The priority of the function. Lower numbers will be run first. Defaults to Infinity.
+ * @param abortController An AbortController that, if aborted, will reject the promise and immediately start the next item in the queue.
+ * @param debugTag
+ */
+ run = ({
+ fn,
+ priority,
+ abortController,
+ debugTag,
+ }: {
+ fn: () => Promise
+ priority?: number
+ abortController?: AbortController
+ debugTag?: string
+ }): Promise => {
+ const item = new ConcurrencyControllerItem(this, fn, abortController, priority, debugTag)
+
+ this._queue.add(item)
+
+ this._tryRunNext()
+
+ return item._promise
+ }
+
+ _runNext(): void {
+ const next = this._queue.poll()
+ if (next) {
+ next._runFn()
+ .catch(() => {
+ // ignore
+ })
+ .finally(() => {
+ this._tryRunNext()
+ })
+ }
+ }
+
+ _tryRunNext(): void {
+ if (this._current.length < this._concurrencyLimit) {
+ this._runNext()
+ }
+ }
+
+ setConcurrencyLimit = (limit: number): void => {
+ this._concurrencyLimit = limit
+ }
+}
+
+// Create a fake AbortError that allows us to use e.name === 'AbortError' to check if an error is an AbortError
+class FakeAbortError extends Error {
+ name = 'AbortError'
+}
diff --git a/plugin-server/src/utils/db/db.ts b/plugin-server/src/utils/db/db.ts
index 7d59501006cf1..c89c08520fffe 100644
--- a/plugin-server/src/utils/db/db.ts
+++ b/plugin-server/src/utils/db/db.ts
@@ -228,6 +228,22 @@ export class DB {
})
}
+ public redisGetBuffer(key: string, tag: string): Promise {
+ return instrumentQuery('query.redisGetBuffer', tag, async () => {
+ const client = await this.redisPool.acquire()
+ const timeout = timeoutGuard('Getting redis key delayed. Waiting over 30 sec to get key.', { key })
+ try {
+ return await tryTwice(
+ async () => await client.getBuffer(key),
+ `Waited 5 sec to get redis key: ${key}, retrying once!`
+ )
+ } finally {
+ clearTimeout(timeout)
+ await this.redisPool.release(client)
+ }
+ })
+ }
+
public redisSet(
key: string,
value: unknown,
@@ -254,6 +270,49 @@ export class DB {
})
}
+ public redisSetBuffer(key: string, value: Buffer, tag: string, ttlSeconds?: number): Promise {
+ return instrumentQuery('query.redisSetBuffer', tag, async () => {
+ const client = await this.redisPool.acquire()
+ const timeout = timeoutGuard('Setting redis key delayed. Waiting over 30 sec to set key', { key })
+ try {
+ if (ttlSeconds) {
+ await client.setBuffer(key, value, 'EX', ttlSeconds)
+ } else {
+ await client.setBuffer(key, value)
+ }
+ } finally {
+ clearTimeout(timeout)
+ await this.redisPool.release(client)
+ }
+ })
+ }
+
+ public redisSetNX(
+ key: string,
+ value: unknown,
+ tag: string,
+ ttlSeconds?: number,
+ options: CacheOptions = {}
+ ): Promise<'OK' | null> {
+ const { jsonSerialize = true } = options
+
+ return instrumentQuery('query.redisSetNX', tag, async () => {
+ const client = await this.redisPool.acquire()
+ const timeout = timeoutGuard('Setting redis key delayed. Waiting over 30 sec to set key (NX)', { key })
+ try {
+ const serializedValue = jsonSerialize ? JSON.stringify(value) : (value as string)
+ if (ttlSeconds) {
+ return await client.set(key, serializedValue, 'EX', ttlSeconds, 'NX')
+ } else {
+ return await client.set(key, serializedValue, 'NX')
+ }
+ } finally {
+ clearTimeout(timeout)
+ await this.redisPool.release(client)
+ }
+ })
+ }
+
public redisSetMulti(kv: Array<[string, unknown]>, ttlSeconds?: number, options: CacheOptions = {}): Promise {
const { jsonSerialize = true } = options
@@ -403,6 +462,45 @@ export class DB {
})
}
+ public redisSAddAndSCard(key: string, value: Redis.ValueType, ttlSeconds?: number): Promise {
+ return instrumentQuery('query.redisSAddAndSCard', undefined, async () => {
+ const client = await this.redisPool.acquire()
+ const timeout = timeoutGuard('SADD+SCARD delayed. Waiting over 30 sec to perform SADD+SCARD', {
+ key,
+ value,
+ })
+ try {
+ const multi = client.multi()
+ multi.sadd(key, value)
+ if (ttlSeconds) {
+ multi.expire(key, ttlSeconds)
+ }
+ multi.scard(key)
+ const results = await multi.exec()
+ const scardResult = ttlSeconds ? results[2] : results[1]
+ return scardResult[1]
+ } finally {
+ clearTimeout(timeout)
+ await this.redisPool.release(client)
+ }
+ })
+ }
+
+ public redisSCard(key: string): Promise {
+ return instrumentQuery('query.redisSCard', undefined, async () => {
+ const client = await this.redisPool.acquire()
+ const timeout = timeoutGuard('SCARD delayed. Waiting over 30 sec to perform SCARD', {
+ key,
+ })
+ try {
+ return await client.scard(key)
+ } finally {
+ clearTimeout(timeout)
+ await this.redisPool.release(client)
+ }
+ })
+ }
+
public redisPublish(channel: string, message: string): Promise {
return instrumentQuery('query.redisPublish', undefined, async () => {
const client = await this.redisPool.acquire()
diff --git a/plugin-server/src/utils/db/utils.ts b/plugin-server/src/utils/db/utils.ts
index 933921667db82..bf23716bc5c63 100644
--- a/plugin-server/src/utils/db/utils.ts
+++ b/plugin-server/src/utils/db/utils.ts
@@ -18,7 +18,10 @@ import { status } from '../../utils/status'
import { areMapsEqual, castTimestampOrNow } from '../../utils/utils'
export function unparsePersonPartial(person: Partial): Partial {
- return { ...(person as BasePerson), ...(person.created_at ? { created_at: person.created_at.toISO() } : {}) }
+ return {
+ ...(person as BasePerson),
+ ...(person.created_at ? { created_at: person.created_at.toISO() ?? undefined } : {}),
+ }
}
export function escapeQuotes(input: string): string {
diff --git a/plugin-server/src/utils/utils.ts b/plugin-server/src/utils/utils.ts
index ccaf793c21c93..afce3ee1766fc 100644
--- a/plugin-server/src/utils/utils.ts
+++ b/plugin-server/src/utils/utils.ts
@@ -211,6 +211,53 @@ export class UUIDT extends UUID {
}
}
+export class UUID7 extends UUID {
+ constructor(bufferOrUnixTimeMs?: number | Buffer, rand?: Buffer) {
+ if (bufferOrUnixTimeMs instanceof Buffer) {
+ if (bufferOrUnixTimeMs.length !== 16) {
+ throw new Error(`UUID7 from buffer requires 16 bytes, got ${bufferOrUnixTimeMs.length}`)
+ }
+ super(bufferOrUnixTimeMs)
+ return
+ }
+ const unixTimeMs = bufferOrUnixTimeMs ?? DateTime.utc().toMillis()
+ let unixTimeMsBig = BigInt(unixTimeMs)
+
+ if (!rand) {
+ rand = randomBytes(10)
+ } else if (rand.length !== 10) {
+ throw new Error(`UUID7 requires 10 bytes of random data, got ${rand.length}`)
+ }
+
+ // see https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-7
+ // a UUIDv7 is 128 bits (16 bytes) total
+ // 48 bits for unix_ts_ms,
+ // 4 bits for ver = 0b111 (7)
+ // 12 bits for rand_a
+ // 2 bits for var = 0b10
+ // 62 bits for rand_b
+ // we set fully random values for rand_a and rand_b
+
+ const array = new Uint8Array(16)
+ // 48 bits for time, WILL FAIL in 10 895 CE
+ // XXXXXXXX-XXXX-****-****-************
+ for (let i = 5; i >= 0; i--) {
+ array[i] = Number(unixTimeMsBig & 0xffn) // use last 8 binary digits to set UUID 2 hexadecimal digits
+ unixTimeMsBig >>= 8n // remove these last 8 binary digits
+ }
+ // rand_a and rand_b
+ // ********-****-*XXX-XXXX-XXXXXXXXXXXX
+ array.set(rand, 6)
+
+ // ver and var
+ // ********-****-7***-X***-************
+ array[6] = 0b0111_0000 | (array[6] & 0b0000_1111)
+ array[8] = 0b1000_0000 | (array[8] & 0b0011_1111)
+
+ super(array)
+ }
+}
+
/* Format timestamps.
Allowed timestamp formats support ISO and ClickHouse formats according to
`timestampFormat`. This distinction is relevant because ClickHouse does NOT
diff --git a/plugin-server/src/worker/ingestion/properties-updater.ts b/plugin-server/src/worker/ingestion/properties-updater.ts
index ad886fbe39e98..b1f0800e7d144 100644
--- a/plugin-server/src/worker/ingestion/properties-updater.ts
+++ b/plugin-server/src/worker/ingestion/properties-updater.ts
@@ -3,8 +3,10 @@ import { DateTime } from 'luxon'
import { Group, GroupTypeIndex, TeamId } from '../../types'
import { DB } from '../../utils/db/db'
+import { MessageSizeTooLarge } from '../../utils/db/error'
import { PostgresUse } from '../../utils/db/postgres'
import { RaceConditionError } from '../../utils/utils'
+import { captureIngestionWarning } from './utils'
interface PropertiesUpdate {
updated: boolean
@@ -71,18 +73,24 @@ export async function upsertGroup(
)
if (propertiesUpdate.updated) {
- await Promise.all([
- db.upsertGroupClickhouse(
- teamId,
- groupTypeIndex,
- groupKey,
- propertiesUpdate.properties,
- createdAt,
- version
- ),
- ])
+ await db.upsertGroupClickhouse(
+ teamId,
+ groupTypeIndex,
+ groupKey,
+ propertiesUpdate.properties,
+ createdAt,
+ version
+ )
}
} catch (error) {
+ if (error instanceof MessageSizeTooLarge) {
+ // Message is too large, for kafka - this is unrecoverable so we capture an ingestion warning instead
+ await captureIngestionWarning(db.kafkaProducer, teamId, 'group_upsert_message_size_too_large', {
+ groupTypeIndex,
+ groupKey,
+ })
+ return
+ }
if (error instanceof RaceConditionError) {
// Try again - lock the row and insert!
return upsertGroup(db, teamId, projectId, groupTypeIndex, groupKey, properties, timestamp)
diff --git a/plugin-server/src/worker/ingestion/team-manager.ts b/plugin-server/src/worker/ingestion/team-manager.ts
index f70d96a5799a5..d787c50c6c948 100644
--- a/plugin-server/src/worker/ingestion/team-manager.ts
+++ b/plugin-server/src/worker/ingestion/team-manager.ts
@@ -170,7 +170,9 @@ export async function fetchTeam(client: PostgresRouter, teamId: Team['id']): Pro
heatmaps_opt_in,
ingested_event,
person_display_name_properties,
- test_account_filters
+ test_account_filters,
+ cookieless_server_hash_mode,
+ timezone
FROM posthog_team
WHERE id = $1
`,
@@ -203,7 +205,10 @@ export async function fetchTeamByToken(client: PostgresRouter, token: string): P
person_processing_opt_out,
heatmaps_opt_in,
ingested_event,
- test_account_filters
+ person_display_name_properties,
+ test_account_filters,
+ cookieless_server_hash_mode,
+ timezone
FROM posthog_team
WHERE api_token = $1
LIMIT 1
diff --git a/plugin-server/src/worker/ingestion/timestamps.ts b/plugin-server/src/worker/ingestion/timestamps.ts
index bf1e82f4dffdf..c41a5e33757ea 100644
--- a/plugin-server/src/worker/ingestion/timestamps.ts
+++ b/plugin-server/src/worker/ingestion/timestamps.ts
@@ -119,3 +119,30 @@ export function parseDate(supposedIsoString: string): DateTime {
}
return DateTime.fromJSDate(jsDate).toUTC()
}
+
+export function toYearMonthDayInTimezone(
+ timestamp: number,
+ timeZone: string
+): { year: number; month: number; day: number } {
+ const parts = new Intl.DateTimeFormat('en', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(new Date(timestamp))
+ const year = parts.find((part) => part.type === 'year')?.value
+ const month = parts.find((part) => part.type === 'month')?.value
+ const day = parts.find((part) => part.type === 'day')?.value
+ if (!year || !month || !day) {
+ throw new Error('Failed to get year, month, or day')
+ }
+ return { year: Number(year), month: Number(month), day: Number(day) }
+}
+
+export function toStartOfDayInTimezone(timestamp: number, timeZone: string): Date {
+ const { year, month, day } = toYearMonthDayInTimezone(timestamp, timeZone)
+ return DateTime.fromObject(
+ { year, month, day, hour: 0, minute: 0, second: 0, millisecond: 0 },
+ { zone: timeZone }
+ ).toJSDate()
+}
diff --git a/plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts b/plugin-server/tests/cdp/cdp-events-consumer.test.ts
similarity index 82%
rename from plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts
rename to plugin-server/tests/cdp/cdp-events-consumer.test.ts
index 4bd6eb339c5cf..db400a56672b3 100644
--- a/plugin-server/tests/cdp/cdp-processed-events-consumer.test.ts
+++ b/plugin-server/tests/cdp/cdp-events-consumer.test.ts
@@ -1,4 +1,4 @@
-import { CdpProcessedEventsConsumer } from '../../src/cdp/cdp-consumers'
+import { CdpInternalEventsConsumer, CdpProcessedEventsConsumer } from '../../src/cdp/cdp-consumers'
import { HogWatcherState } from '../../src/cdp/hog-watcher'
import { HogFunctionInvocationGlobals, HogFunctionType } from '../../src/cdp/types'
import { Hub, Team } from '../../src/types'
@@ -74,13 +74,22 @@ const decodeAllKafkaMessages = (): any[] => {
return mockProducer.produce.mock.calls.map((x) => decodeKafkaMessage(x[0]))
}
-describe('CDP Processed Events Consumer', () => {
- let processor: CdpProcessedEventsConsumer
+/**
+ * NOTE: The internal and normal events consumers are very similar so we can test them together
+ */
+describe.each([
+ [CdpProcessedEventsConsumer.name, CdpProcessedEventsConsumer, 'destination' as const],
+ [CdpInternalEventsConsumer.name, CdpInternalEventsConsumer, 'internal_destination' as const],
+])('%s', (_name, Consumer, hogType) => {
+ let processor: CdpProcessedEventsConsumer | CdpInternalEventsConsumer
let hub: Hub
let team: Team
const insertHogFunction = async (hogFunction: Partial) => {
- const item = await _insertHogFunction(hub.postgres, team.id, hogFunction)
+ const item = await _insertHogFunction(hub.postgres, team.id, {
+ ...hogFunction,
+ type: hogType,
+ })
// Trigger the reload that django would do
await processor.hogFunctionManager.reloadAllHogFunctions()
return item
@@ -91,7 +100,7 @@ describe('CDP Processed Events Consumer', () => {
hub = await createHub()
team = await getFirstTeam(hub)
- processor = new CdpProcessedEventsConsumer(hub)
+ processor = new Consumer(hub)
await processor.start()
mockFetch.mockClear()
@@ -333,5 +342,56 @@ describe('CDP Processed Events Consumer', () => {
])
})
})
+
+ describe('filtering errors', () => {
+ let globals: HogFunctionInvocationGlobals
+
+ beforeEach(() => {
+ globals = createHogExecutionGlobals({
+ project: {
+ id: team.id,
+ } as any,
+ event: {
+ uuid: 'b3a1fe86-b10c-43cc-acaf-d208977608d0',
+ event: '$pageview',
+ properties: {
+ $current_url: 'https://posthog.com',
+ $lib_version: '1.0.0',
+ },
+ } as any,
+ })
+ })
+
+ it('should filter out functions that error while filtering', async () => {
+ const erroringFunction = await insertHogFunction({
+ ...HOG_EXAMPLES.input_printer,
+ ...HOG_INPUTS_EXAMPLES.secret_inputs,
+ ...HOG_FILTERS_EXAMPLES.broken_filters,
+ })
+ await processor.processBatch([globals])
+ expect(decodeAllKafkaMessages()).toMatchObject([
+ {
+ key: expect.any(String),
+ topic: 'clickhouse_app_metrics2_test',
+ value: {
+ app_source: 'hog_function',
+ app_source_id: erroringFunction.id,
+ count: 1,
+ metric_kind: 'other',
+ metric_name: 'filtering_failed',
+ team_id: 2,
+ timestamp: expect.any(String),
+ },
+ },
+ {
+ topic: 'log_entries_test',
+ value: {
+ message:
+ 'Error filtering event b3a1fe86-b10c-43cc-acaf-d208977608d0: Invalid HogQL bytecode, stack is empty, can not pop',
+ },
+ },
+ ])
+ })
+ })
})
})
diff --git a/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts b/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts
new file mode 100644
index 0000000000000..995b2eeae2667
--- /dev/null
+++ b/plugin-server/tests/cdp/cdp-internal-events-consumer.test.ts
@@ -0,0 +1,99 @@
+import { CdpInternalEventsConsumer } from '../../src/cdp/cdp-consumers'
+import { HogFunctionType } from '../../src/cdp/types'
+import { Hub, Team } from '../../src/types'
+import { closeHub, createHub } from '../../src/utils/db/hub'
+import { getFirstTeam, resetTestDatabase } from '../helpers/sql'
+import { HOG_EXAMPLES, HOG_FILTERS_EXAMPLES, HOG_INPUTS_EXAMPLES } from './examples'
+import { createInternalEvent, createKafkaMessage, insertHogFunction as _insertHogFunction } from './fixtures'
+
+describe('CDP Internal Events Consumer', () => {
+ let processor: CdpInternalEventsConsumer
+ let hub: Hub
+ let team: Team
+
+ const insertHogFunction = async (hogFunction: Partial) => {
+ const item = await _insertHogFunction(hub.postgres, team.id, hogFunction)
+ // Trigger the reload that django would do
+ await processor.hogFunctionManager.reloadAllHogFunctions()
+ return item
+ }
+
+ beforeEach(async () => {
+ await resetTestDatabase()
+ hub = await createHub()
+ team = await getFirstTeam(hub)
+
+ processor = new CdpInternalEventsConsumer(hub)
+ // Speed hack as we don't need all of kafka to be started for this test
+ await processor.hogFunctionManager.start(processor['hogTypes'])
+ })
+
+ afterEach(async () => {
+ jest.setTimeout(1000)
+ await closeHub(hub)
+ })
+
+ afterAll(() => {
+ jest.useRealTimers()
+ })
+
+ describe('_handleKafkaBatch', () => {
+ it('should ignore invalid message', async () => {
+ const events = await processor._parseKafkaBatch([createKafkaMessage({})])
+ expect(events).toHaveLength(0)
+ })
+
+ it('should ignore message with no team', async () => {
+ const events = await processor._parseKafkaBatch([createKafkaMessage(createInternalEvent(999999, {}))])
+ expect(events).toHaveLength(0)
+ })
+
+ describe('with an existing team and hog function', () => {
+ beforeEach(async () => {
+ await insertHogFunction({
+ ...HOG_EXAMPLES.simple_fetch,
+ ...HOG_INPUTS_EXAMPLES.simple_fetch,
+ ...HOG_FILTERS_EXAMPLES.no_filters,
+ type: 'internal_destination',
+ })
+ })
+
+ it('should ignore invalid payloads', async () => {
+ const events = await processor._parseKafkaBatch([
+ createKafkaMessage(
+ createInternalEvent(team.id, {
+ event: 'WRONG' as any,
+ })
+ ),
+ ])
+ expect(events).toHaveLength(0)
+ })
+
+ it('should parse a valid message with an existing team and hog function ', async () => {
+ const event = createInternalEvent(team.id, {})
+ event.event.timestamp = '2024-12-18T15:06:23.545Z'
+ event.event.uuid = 'b6da2f33-ba54-4550-9773-50d3278ad61f'
+
+ const events = await processor._parseKafkaBatch([createKafkaMessage(event)])
+ expect(events).toHaveLength(1)
+ expect(events[0]).toEqual({
+ event: {
+ distinct_id: 'distinct_id',
+ elements_chain: '',
+ event: '$pageview',
+ properties: {},
+ timestamp: '2024-12-18T15:06:23.545Z',
+ url: '',
+ uuid: 'b6da2f33-ba54-4550-9773-50d3278ad61f',
+ },
+ person: undefined,
+ project: {
+ id: 2,
+ name: 'TEST PROJECT',
+ url: 'http://localhost:8000/project/2',
+ },
+ })
+ })
+ })
+ })
+})
diff --git a/plugin-server/tests/cdp/fixtures.ts b/plugin-server/tests/cdp/fixtures.ts
index e34920fdd981e..79c56798866db 100644
--- a/plugin-server/tests/cdp/fixtures.ts
+++ b/plugin-server/tests/cdp/fixtures.ts
@@ -1,6 +1,7 @@
import { randomUUID } from 'crypto'
import { Message } from 'node-rdkafka'
+import { CdpInternalEvent } from '../../src/cdp/schema'
import {
HogFunctionInvocation,
HogFunctionInvocationGlobals,
@@ -60,7 +61,7 @@ export const createIncomingEvent = (teamId: number, data: Partial = {}): Message => {
+export const createKafkaMessage = (event: any, overrides: Partial = {}): Message => {
return {
partition: 1,
topic: 'test',
@@ -72,6 +73,20 @@ export const createMessage = (event: RawClickHouseEvent, overrides: Partial): CdpInternalEvent => {
+ return {
+ team_id: teamId,
+ event: {
+ timestamp: new Date().toISOString(),
+ properties: {},
+ uuid: randomUUID(),
+ event: '$pageview',
+ distinct_id: 'distinct_id',
+ },
+ ...data,
+ }
+}
+
export const insertHogFunction = async (
postgres: PostgresRouter,
team_id: Team['id'],
diff --git a/plugin-server/tests/cdp/hog-executor.test.ts b/plugin-server/tests/cdp/hog-executor.test.ts
index aeacc1067d0f4..99feb53d62207 100644
--- a/plugin-server/tests/cdp/hog-executor.test.ts
+++ b/plugin-server/tests/cdp/hog-executor.test.ts
@@ -48,9 +48,8 @@ describe('Hog Executor', () => {
const mockFunctionManager = {
reloadAllHogFunctions: jest.fn(),
- getTeamHogDestinations: jest.fn(),
+ getTeamHogFunctions: jest.fn(),
getTeamHogFunction: jest.fn(),
- getTeamHogEmailProvider: jest.fn(),
}
beforeEach(async () => {
@@ -70,7 +69,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.no_filters,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([hogFunction])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([hogFunction])
mockFunctionManager.getTeamHogFunction.mockReturnValue(hogFunction)
})
@@ -254,7 +253,7 @@ describe('Hog Executor', () => {
})
})
- describe('email provider functions', () => {
+ describe.skip('email provider functions', () => {
let hogFunction: HogFunctionType
let providerFunction: HogFunctionType
beforeEach(() => {
@@ -270,9 +269,9 @@ describe('Hog Executor', () => {
...HOG_INPUTS_EXAMPLES.email,
...HOG_FILTERS_EXAMPLES.no_filters,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([hogFunction, providerFunction])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([hogFunction, providerFunction])
mockFunctionManager.getTeamHogFunction.mockReturnValue(hogFunction)
- mockFunctionManager.getTeamHogEmailProvider.mockReturnValue(providerFunction)
+ // mockFunctionManager.getTeamHogEmailProvider.mockReturnValue(providerFunction)
})
it('can execute an invocation', () => {
@@ -326,7 +325,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.pageview_or_autocapture_filter,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const resultsShouldntMatch = executor.findMatchingFunctions(createHogExecutionGlobals({ groups: {} }))
expect(resultsShouldntMatch.matchingFunctions).toHaveLength(0)
@@ -356,7 +355,7 @@ describe('Hog Executor', () => {
...HOG_INPUTS_EXAMPLES.simple_fetch,
...HOG_FILTERS_EXAMPLES.broken_filters,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const resultsShouldMatch = executor.findMatchingFunctions(
createHogExecutionGlobals({
groups: {},
@@ -388,7 +387,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.elements_text_filter,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const elementsChain = (buttonText: string) =>
`span.LemonButton__content:attr__class="LemonButton__content"nth-child="2"nth-of-type="2"text="${buttonText}";span.LemonButton__chrome:attr__class="LemonButton__chrome"nth-child="1"nth-of-type="1";button.LemonButton.LemonButton--has-icon.LemonButton--secondary.LemonButton--status-default:attr__class="LemonButton LemonButton--secondary LemonButton--status-default LemonButton--has-icon"attr__type="button"nth-child="1"nth-of-type="1"text="${buttonText}";div.flex.gap-4.items-center:attr__class="flex gap-4 items-center"nth-child="1"nth-of-type="1";div.flex.flex-wrap.gap-4.justify-between:attr__class="flex gap-4 justify-between flex-wrap"nth-child="3"nth-of-type="3";div.flex.flex-1.flex-col.gap-4.h-full.relative.w-full:attr__class="relative w-full flex flex-col gap-4 flex-1 h-full"nth-child="1"nth-of-type="1";div.LemonTabs__content:attr__class="LemonTabs__content"nth-child="2"nth-of-type="1";div.LemonTabs.LemonTabs--medium:attr__class="LemonTabs LemonTabs--medium"attr__style="--lemon-tabs-slider-width: 48px; --lemon-tabs-slider-offset: 0px;"nth-child="1"nth-of-type="1";div.Navigation3000__scene:attr__class="Navigation3000__scene"nth-child="2"nth-of-type="2";main:nth-child="2"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"`
@@ -438,7 +437,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.elements_href_filter,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const elementsChain = (link: string) =>
`span.LemonButton__content:attr__class="LemonButton__content"attr__href="${link}"href="${link}"nth-child="2"nth-of-type="2"text="Activity";span.LemonButton__chrome:attr__class="LemonButton__chrome"nth-child="1"nth-of-type="1";a.LemonButton.LemonButton--full-width.LemonButton--has-icon.LemonButton--secondary.LemonButton--status-alt.Link.NavbarButton:attr__class="Link LemonButton LemonButton--secondary LemonButton--status-alt LemonButton--full-width LemonButton--has-icon NavbarButton"attr__data-attr="menu-item-activity"attr__href="${link}"href="${link}"nth-child="1"nth-of-type="1"text="Activity";li.w-full:attr__class="w-full"nth-child="6"nth-of-type="6";ul:nth-child="1"nth-of-type="1";div.Navbar3000__top.ScrollableShadows__inner:attr__class="ScrollableShadows__inner Navbar3000__top"nth-child="1"nth-of-type="1";div.ScrollableShadows.ScrollableShadows--vertical:attr__class="ScrollableShadows ScrollableShadows--vertical"nth-child="1"nth-of-type="1";div.Navbar3000__content:attr__class="Navbar3000__content"nth-child="1"nth-of-type="1";nav.Navbar3000:attr__class="Navbar3000"nth-child="1"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"`
@@ -488,7 +487,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.elements_tag_and_id_filter,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const elementsChain = (id: string) =>
`a.Link.font-semibold.text-text-3000.text-xl:attr__class="Link font-semibold text-xl text-text-3000"attr__href="/project/1/dashboard/1"attr__id="${id}"attr_id="${id}"href="/project/1/dashboard/1"nth-child="1"nth-of-type="1"text="My App Dashboard";div.ProjectHomepage__dashboardheader__title:attr__class="ProjectHomepage__dashboardheader__title"nth-child="1"nth-of-type="1";div.ProjectHomepage__dashboardheader:attr__class="ProjectHomepage__dashboardheader"nth-child="2"nth-of-type="2";div.ProjectHomepage:attr__class="ProjectHomepage"nth-child="1"nth-of-type="1";div.Navigation3000__scene:attr__class="Navigation3000__scene"nth-child="2"nth-of-type="2";main:nth-child="2"nth-of-type="1";div.Navigation3000:attr__class="Navigation3000"nth-child="1"nth-of-type="1";div:attr__id="root"attr_id="root"nth-child="3"nth-of-type="1";body.overflow-hidden:attr__class="overflow-hidden"attr__theme="light"nth-child="2"nth-of-type="1"`
@@ -579,7 +578,7 @@ describe('Hog Executor', () => {
...HOG_FILTERS_EXAMPLES.no_filters,
})
- mockFunctionManager.getTeamHogDestinations.mockReturnValue([fn])
+ mockFunctionManager.getTeamHogFunctions.mockReturnValue([fn])
const result = executor.execute(createInvocation(fn))
expect(result.error).toContain('Execution timed out after 0.1 seconds. Performed ')
diff --git a/plugin-server/tests/cdp/hog-function-manager.test.ts b/plugin-server/tests/cdp/hog-function-manager.test.ts
index d5d5b575dd3ec..752927c3d53dd 100644
--- a/plugin-server/tests/cdp/hog-function-manager.test.ts
+++ b/plugin-server/tests/cdp/hog-function-manager.test.ts
@@ -62,22 +62,31 @@ describe('HogFunctionManager', () => {
hogFunctions.push(
await insertHogFunction(hub.postgres, teamId1, {
- name: 'Email Provider team 1',
- type: 'email',
- inputs_schema: [
- {
- type: 'email',
- key: 'message',
- },
- ],
- inputs: {
- email: {
- value: { from: 'me@a.com', to: 'you@b.com', subject: 'subject', html: 'text' },
- },
- },
+ name: 'Test Hog Function team 1 - transformation',
+ type: 'transformation',
+ inputs_schema: [],
+ inputs: {},
})
)
+ // hogFunctions.push(
+ // await insertHogFunction(hub.postgres, teamId1, {
+ // name: 'Email Provider team 1',
+ // type: 'email',
+ // inputs_schema: [
+ // {
+ // type: 'email',
+ // key: 'message',
+ // },
+ // ],
+ // inputs: {
+ // email: {
+ // value: { from: 'me@a.com', to: 'you@b.com', subject: 'subject', html: 'text' },
+ // },
+ // },
+ // })
+ // )
+
hogFunctions.push(
await insertHogFunction(hub.postgres, teamId2, {
name: 'Test Hog Function team 2',
@@ -98,7 +107,7 @@ describe('HogFunctionManager', () => {
})
)
- await manager.start()
+ await manager.start(['destination'])
})
afterEach(async () => {
@@ -107,7 +116,7 @@ describe('HogFunctionManager', () => {
})
it('returns the hog functions', async () => {
- let items = manager.getTeamHogDestinations(teamId1)
+ let items = manager.getTeamHogFunctions(teamId1)
expect(items).toEqual([
{
@@ -142,13 +151,6 @@ describe('HogFunctionManager', () => {
},
])
- const allFunctions = manager.getTeamHogFunctions(teamId1)
- expect(allFunctions.length).toEqual(2)
- expect(allFunctions.map((f) => f.type).sort()).toEqual(['destination', 'email'])
-
- const emailProvider = manager.getTeamHogEmailProvider(teamId1)
- expect(emailProvider.type).toEqual('email')
-
await hub.db.postgres.query(
PostgresUse.COMMON_WRITE,
`UPDATE posthog_hogfunction SET name='Test Hog Function team 1 updated' WHERE id = $1`,
@@ -159,7 +161,7 @@ describe('HogFunctionManager', () => {
// This is normally dispatched by django
await manager.reloadHogFunctions(teamId1, [hogFunctions[0].id])
- items = manager.getTeamHogDestinations(teamId1)
+ items = manager.getTeamHogFunctions(teamId1)
expect(items).toMatchObject([
{
@@ -169,8 +171,21 @@ describe('HogFunctionManager', () => {
])
})
+ it('filters hog functions by type', async () => {
+ manager['hogTypes'] = ['transformation']
+ await manager.reloadAllHogFunctions()
+ expect(manager.getTeamHogFunctions(teamId1).length).toEqual(1)
+ expect(manager.getTeamHogFunctions(teamId1)[0].type).toEqual('transformation')
+
+ manager['hogTypes'] = ['transformation', 'destination']
+ await manager.reloadAllHogFunctions()
+ expect(manager.getTeamHogFunctions(teamId1).length).toEqual(2)
+ expect(manager.getTeamHogFunctions(teamId1)[0].type).toEqual('destination')
+ expect(manager.getTeamHogFunctions(teamId1)[1].type).toEqual('transformation')
+ })
+
it('removes disabled functions', async () => {
- let items = manager.getTeamHogDestinations(teamId1)
+ let items = manager.getTeamHogFunctions(teamId1)
expect(items).toMatchObject([
{
@@ -188,14 +203,14 @@ describe('HogFunctionManager', () => {
// This is normally dispatched by django
await manager.reloadHogFunctions(teamId1, [hogFunctions[0].id])
- items = manager.getTeamHogDestinations(teamId1)
+ items = manager.getTeamHogFunctions(teamId1)
expect(items).toEqual([])
})
it('enriches integration inputs if found and belonging to the team', () => {
- const function1Inputs = manager.getTeamHogDestinations(teamId1)[0].inputs
- const function2Inputs = manager.getTeamHogDestinations(teamId2)[0].inputs
+ const function1Inputs = manager.getTeamHogFunctions(teamId1)[0].inputs
+ const function2Inputs = manager.getTeamHogFunctions(teamId2)[0].inputs
// Only the right team gets the integration inputs enriched
expect(function1Inputs).toEqual({
diff --git a/plugin-server/tests/main/db.test.ts b/plugin-server/tests/main/db.test.ts
index 10e514d8323c9..5c465aadfbae0 100644
--- a/plugin-server/tests/main/db.test.ts
+++ b/plugin-server/tests/main/db.test.ts
@@ -855,7 +855,7 @@ describe('DB', () => {
anonymize_ips: false,
api_token: 'token1',
id: teamId,
- project_id: teamId,
+ project_id: teamId as Team['project_id'],
ingested_event: true,
name: 'TEST PROJECT',
organization_id: organizationId,
@@ -866,6 +866,8 @@ describe('DB', () => {
uuid: expect.any(String),
person_display_name_properties: [],
test_account_filters: {} as any, // NOTE: Test insertion data gets set as an object weirdly
+ cookieless_server_hash_mode: null,
+ timezone: 'UTC',
} as Team)
})
@@ -885,17 +887,20 @@ describe('DB', () => {
anonymize_ips: false,
api_token: 'token2',
id: teamId,
- project_id: teamId,
+ project_id: teamId as Team['project_id'],
ingested_event: true,
name: 'TEST PROJECT',
organization_id: organizationId,
session_recording_opt_in: true,
person_processing_opt_out: null,
+ person_display_name_properties: [],
heatmaps_opt_in: null,
slack_incoming_webhook: null,
uuid: expect.any(String),
test_account_filters: {} as any, // NOTE: Test insertion data gets set as an object weirdly
- })
+ cookieless_server_hash_mode: null,
+ timezone: 'UTC',
+ } as Team)
})
it('returns null if the team does not exist', async () => {
@@ -903,6 +908,87 @@ describe('DB', () => {
expect(fetchedTeam).toEqual(null)
})
})
+
+ describe('redis', () => {
+ describe('buffer operations', () => {
+ it('writes and reads buffers', async () => {
+ const buffer = Buffer.from('test')
+ await db.redisSetBuffer('test', buffer, 'testTag', 60)
+ const result = await db.redisGetBuffer('test', 'testTag')
+ expect(result).toEqual(buffer)
+ })
+ })
+
+ describe('redisSetNX', () => {
+ it('it should only set a value if there is not already one present', async () => {
+ const set1 = await db.redisSetNX('test', 'first', 'testTag')
+ expect(set1).toEqual('OK')
+ const get1 = await db.redisGet('test', '', 'testTag')
+ expect(get1).toEqual('first')
+
+ const set2 = await db.redisSetNX('test', 'second', 'testTag')
+ expect(set2).toEqual(null)
+ const get2 = await db.redisGet('test', '', 'testTag')
+ expect(get2).toEqual('first')
+ })
+
+ it('it should only set a value if there is not already one present, with a ttl', async () => {
+ const set1 = await db.redisSetNX('test', 'first', 'testTag', 60)
+ expect(set1).toEqual('OK')
+ const get1 = await db.redisGet('test', '', 'testTag')
+ expect(get1).toEqual('first')
+
+ const set2 = await db.redisSetNX('test', 'second', 'testTag', 60)
+ expect(set2).toEqual(null)
+ const get2 = await db.redisGet('test', '', 'testTag')
+ expect(get2).toEqual('first')
+ })
+ })
+
+ describe('redisSAddAndSCard', () => {
+ it('it should add a value to a set and return the number of elements in the set', async () => {
+ const add1 = await db.redisSAddAndSCard('test', 'A')
+ expect(add1).toEqual(1)
+ const add2 = await db.redisSAddAndSCard('test', 'A')
+ expect(add2).toEqual(1)
+ const add3 = await db.redisSAddAndSCard('test', 'B')
+ expect(add3).toEqual(2)
+ const add4 = await db.redisSAddAndSCard('test', 'B')
+ expect(add4).toEqual(2)
+ const add5 = await db.redisSAddAndSCard('test', 'A')
+ expect(add5).toEqual(2)
+ })
+
+ it('it should add a value to a set and return the number of elements in the set, with a TTL', async () => {
+ const add1 = await db.redisSAddAndSCard('test', 'A', 60)
+ expect(add1).toEqual(1)
+ const add2 = await db.redisSAddAndSCard('test', 'A', 60)
+ expect(add2).toEqual(1)
+ const add3 = await db.redisSAddAndSCard('test', 'B', 60)
+ expect(add3).toEqual(2)
+ const add4 = await db.redisSAddAndSCard('test', 'B', 60)
+ expect(add4).toEqual(2)
+ const add5 = await db.redisSAddAndSCard('test', 'A', 60)
+ expect(add5).toEqual(2)
+ })
+ })
+
+ describe('redisSCard', () => {
+ it('it should return the number of elements in the set', async () => {
+ await db.redisSAddAndSCard('test', 'A')
+ const scard1 = await db.redisSCard('test')
+ expect(scard1).toEqual(1)
+
+ await db.redisSAddAndSCard('test', 'B')
+ const scard2 = await db.redisSCard('test')
+ expect(scard2).toEqual(2)
+
+ await db.redisSAddAndSCard('test', 'B')
+ const scard3 = await db.redisSCard('test')
+ expect(scard3).toEqual(2)
+ })
+ })
+ })
})
describe('PostgresRouter()', () => {
diff --git a/plugin-server/tests/utils.test.ts b/plugin-server/tests/utils.test.ts
index 9fd37dd5b12fd..44d690d798d60 100644
--- a/plugin-server/tests/utils.test.ts
+++ b/plugin-server/tests/utils.test.ts
@@ -13,6 +13,7 @@ import {
sanitizeSqlIdentifier,
stringify,
UUID,
+ UUID7,
UUIDT,
} from '../src/utils/utils'
@@ -124,6 +125,33 @@ describe('utils', () => {
})
})
+ describe('UUIDv7', () => {
+ it('is well-formed', () => {
+ const uuid7 = new UUID7()
+ const uuid7String = uuid7.toString()
+ // UTC timestamp matching (roughly, only comparing the beginning as the timestamp's end inevitably drifts away)
+ expect(uuid7String.slice(0, 8)).toEqual(Date.now().toString(16).padStart(12, '0').slice(0, 8))
+ // version digit matching
+ expect(uuid7String[14]).toEqual('7')
+ // var matching
+ const variant = parseInt(uuid7String[19], 16) >>> 2
+ expect(variant).toEqual(2)
+ })
+ it('has the correct value when given a timestamp and random bytes', () => {
+ const timestamp = new Date('Wed, 30 Oct 2024 21:46:23 GMT').getTime()
+ const randomBytes = Buffer.from(
+ new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23])
+ )
+ const uuid7 = new UUID7(timestamp, randomBytes)
+ expect(uuid7.toString()).toEqual('0192df64-df98-7123-8567-89abcdef0123')
+ })
+ it('can be loaded from a buffer', () => {
+ const str = '0192df64df987123856789abcdef0123'
+ const uuid = new UUID7(new Buffer(str, 'hex'))
+ expect(uuid.toString().replace(/-/g, '')).toEqual(str)
+ })
+ })
+
describe('sanitizeSqlIdentifier', () => {
it('removes all characters that are neither letter, digit or underscore and adds quotes around identifier', () => {
const rawIdentifier = 'some_field"; DROP TABLE actually_an_injection-9;'
diff --git a/plugin-server/tests/worker/ingestion/properties-updater.test.ts b/plugin-server/tests/worker/ingestion/properties-updater.test.ts
index e898294814f90..7d74a77353d56 100644
--- a/plugin-server/tests/worker/ingestion/properties-updater.test.ts
+++ b/plugin-server/tests/worker/ingestion/properties-updater.test.ts
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon'
import { Group, Hub, Team } from '../../../src/types'
import { DB } from '../../../src/utils/db/db'
+import { MessageSizeTooLarge } from '../../../src/utils/db/error'
import { closeHub, createHub } from '../../../src/utils/db/hub'
import { UUIDT } from '../../../src/utils/utils'
import { upsertGroup } from '../../../src/worker/ingestion/properties-updater'
@@ -146,5 +147,14 @@ describe('properties-updater', () => {
expect(group.version).toEqual(2)
expect(group.group_properties).toEqual({ a: 1, b: 2, d: 3 })
})
+
+ it('handles message size too large errors', async () => {
+ jest.spyOn(db, 'upsertGroupClickhouse').mockImplementationOnce((): Promise => {
+ const error = new Error('message size too large')
+ throw new MessageSizeTooLarge(error.message, error)
+ })
+
+ await expect(upsert({ a: 1, b: 2 }, PAST_TIMESTAMP)).resolves.toEqual(undefined)
+ })
})
})
diff --git a/plugin-server/tests/worker/ingestion/timestamps.test.ts b/plugin-server/tests/worker/ingestion/timestamps.test.ts
index a70844a349ae9..742a908aa87f3 100644
--- a/plugin-server/tests/worker/ingestion/timestamps.test.ts
+++ b/plugin-server/tests/worker/ingestion/timestamps.test.ts
@@ -1,7 +1,12 @@
import { PluginEvent } from '@posthog/plugin-scaffold'
import { UUIDT } from '../../../src/utils/utils'
-import { parseDate, parseEventTimestamp } from '../../../src/worker/ingestion/timestamps'
+import {
+ parseDate,
+ parseEventTimestamp,
+ toStartOfDayInTimezone,
+ toYearMonthDayInTimezone,
+} from '../../../src/worker/ingestion/timestamps'
describe('parseDate()', () => {
const timestamps = [
@@ -283,3 +288,87 @@ describe('parseEventTimestamp()', () => {
expect(timestamp.toISO()).toEqual('2021-10-29T01:00:00.000Z')
})
})
+
+describe('toYearMonthDateInTimezone', () => {
+ it('returns the correct date in the correct timezone', () => {
+ expect(toYearMonthDayInTimezone(new Date('2024-12-13T10:00:00.000Z').getTime(), 'Europe/London')).toEqual({
+ year: 2024,
+ month: 12,
+ day: 13,
+ })
+
+ // should be a day ahead due to time zones
+ expect(toYearMonthDayInTimezone(new Date('2024-12-13T23:00:00.000Z').getTime(), 'Asia/Tokyo')).toEqual({
+ year: 2024,
+ month: 12,
+ day: 14,
+ })
+
+ // should be a day behind due to time zones
+ expect(toYearMonthDayInTimezone(new Date('2024-12-13T01:00:00.000Z').getTime(), 'America/Los_Angeles')).toEqual(
+ {
+ year: 2024,
+ month: 12,
+ day: 12,
+ }
+ )
+
+ // should be the same day due to no DST
+ expect(toYearMonthDayInTimezone(new Date('2024-12-13T00:00:00.000Z').getTime(), 'Europe/London')).toEqual({
+ year: 2024,
+ month: 12,
+ day: 13,
+ })
+
+ // should be a different day due to DST (british summer time)
+ expect(toYearMonthDayInTimezone(new Date('2024-06-13T23:00:00.000Z').getTime(), 'Europe/London')).toEqual({
+ year: 2024,
+ month: 6,
+ day: 14,
+ })
+ })
+
+ it('should throw on invalid timezone', () => {
+ expect(() => toYearMonthDayInTimezone(new Date().getTime(), 'Invalid/Timezone')).toThrowError(
+ 'Invalid time zone'
+ )
+ })
+})
+
+describe('toStartOfDayInTimezone', () => {
+ it('returns the start of the day in the correct timezone', () => {
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T10:00:00.000Z').getTime(), 'Europe/London')).toEqual(
+ new Date('2024-12-13T00:00:00Z')
+ )
+
+ // would be the following day in Asia/Tokyo, but should be the same day (just earlier) in UTC
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T23:00:00.000Z').getTime(), 'Asia/Tokyo')).toEqual(
+ new Date('2024-12-13T15:00:00Z')
+ )
+
+ // would be the same day in Asia/Tokyo, but back in UTC time it should be the previous day (but later in the day)
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T01:00:00.000Z').getTime(), 'Asia/Tokyo')).toEqual(
+ new Date('2024-12-12T15:00:00Z')
+ )
+
+ // would be the same day in America/Los_Angeles, but earlier in the day when converted to UTC
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T23:00:00.000Z').getTime(), 'America/Los_Angeles')).toEqual(
+ new Date('2024-12-13T08:00:00Z')
+ )
+
+ // would be the previous day in America/Los_Angeles, and when converted to UTC it should stay the previous day
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T01:00:00.000Z').getTime(), 'America/Los_Angeles')).toEqual(
+ new Date('2024-12-12T08:00:00Z')
+ )
+
+ // should be the same day due to no DST
+ expect(toStartOfDayInTimezone(new Date('2024-12-13T00:00:00.000Z').getTime(), 'Europe/London')).toEqual(
+ new Date('2024-12-13T00:00:00Z')
+ )
+
+ // should be a different day due to DST (british summer time)
+ expect(toStartOfDayInTimezone(new Date('2024-06-13T00:00:00.000Z').getTime(), 'Europe/London')).toEqual(
+ new Date('2024-06-12T23:00:00Z')
+ )
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a1bda4e655675..a8b92c98712d3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,8 +53,8 @@ dependencies:
specifier: 4.6.0
version: 4.6.0(monaco-editor@0.49.0)(react-dom@18.2.0)(react@18.2.0)
'@posthog/hogvm':
- specifier: ^1.0.61
- version: 1.0.61(luxon@3.5.0)
+ specifier: ^1.0.66
+ version: 1.0.66(luxon@3.5.0)
'@posthog/icons':
specifier: 0.9.2
version: 0.9.2(react-dom@18.2.0)(react@18.2.0)
@@ -305,8 +305,8 @@ dependencies:
specifier: ^9.3.0
version: 9.3.0(postcss@8.4.31)
posthog-js:
- specifier: 1.200.2
- version: 1.200.2
+ specifier: 1.203.1
+ version: 1.203.1
posthog-js-lite:
specifier: 3.0.0
version: 3.0.0
@@ -609,8 +609,8 @@ devDependencies:
specifier: ^1.13.0
version: 1.13.8
caniuse-lite:
- specifier: ^1.0.30001687
- version: 1.0.30001687
+ specifier: ^1.0.30001689
+ version: 1.0.30001689
concurrently:
specifier: ^5.3.0
version: 5.3.0
@@ -5446,8 +5446,8 @@ packages:
resolution: {integrity: sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==}
dev: false
- /@posthog/hogvm@1.0.61(luxon@3.5.0):
- resolution: {integrity: sha512-zCwQp6Zn2F2/QNhd1/0IwpndhElVZ2pzsoh2IOZWapZ+jNo6y/taL3128Uwoiec01IYiPXF8EVYOJP7X4Woj+A==}
+ /@posthog/hogvm@1.0.66(luxon@3.5.0):
+ resolution: {integrity: sha512-bczn4tB2rXRJVXihkRHGiNT+6ruYRLRtGRf9xhGlZmdFBL/QSJa5/gQqflp5de+N6UMofkyjdX8yvBwiTt3VHw==}
peerDependencies:
luxon: ^3.4.4
dependencies:
@@ -9638,7 +9638,7 @@ packages:
postcss: ^8.1.0
dependencies:
browserslist: 4.22.2
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -9654,7 +9654,7 @@ packages:
postcss: ^8.1.0
dependencies:
browserslist: 4.22.2
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -10018,7 +10018,7 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
electron-to-chromium: 1.4.492
node-releases: 2.0.13
update-browserslist-db: 1.0.11(browserslist@4.21.10)
@@ -10028,7 +10028,7 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
electron-to-chromium: 1.4.609
node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.22.2)
@@ -10178,13 +10178,13 @@ packages:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
dependencies:
browserslist: 4.22.2
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
lodash.memoize: 4.1.2
lodash.uniq: 4.5.0
dev: false
- /caniuse-lite@1.0.30001687:
- resolution: {integrity: sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==}
+ /caniuse-lite@1.0.30001689:
+ resolution: {integrity: sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==}
/case-anything@2.1.10:
resolution: {integrity: sha512-JczJwVrCP0jPKh05McyVsuOg6AYosrB9XWZKbQzXeDAm2ClE/PJE/BcrrQrVyGYH7Jg8V/LDupmyL4kFlVsVFQ==}
@@ -11799,8 +11799,8 @@ packages:
engines: {node: '>=12'}
dev: true
- /dunder-proto@1.0.0:
- resolution: {integrity: sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==}
+ /dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.1
@@ -12318,7 +12318,7 @@ packages:
'@mdn/browser-compat-data': 5.3.16
ast-metadata-inferer: 0.8.0
browserslist: 4.21.10
- caniuse-lite: 1.0.30001687
+ caniuse-lite: 1.0.30001689
eslint: 8.57.0
find-up: 5.0.0
lodash.memoize: 4.1.2
@@ -13308,7 +13308,7 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
call-bind-apply-helpers: 1.0.1
- dunder-proto: 1.0.0
+ dunder-proto: 1.0.1
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.0.0
@@ -13316,7 +13316,7 @@ packages:
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
- math-intrinsics: 1.0.0
+ math-intrinsics: 1.1.0
dev: true
/get-nonce@1.0.1:
@@ -14073,7 +14073,7 @@ packages:
hogan.js: 3.0.2
htm: 3.1.1
instantsearch-ui-components: 0.3.0
- preact: 10.25.2
+ preact: 10.25.3
qs: 6.9.7
search-insights: 2.13.0
dev: false
@@ -15996,8 +15996,8 @@ packages:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
dev: false
- /math-intrinsics@1.0.0:
- resolution: {integrity: sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==}
+ /math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
dev: true
@@ -16348,8 +16348,8 @@ packages:
object-assign: 4.1.1
thenify-all: 1.6.0
- /nanoid@3.3.6:
- resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
+ /nanoid@3.3.8:
+ resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -17894,7 +17894,7 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
- nanoid: 3.3.6
+ nanoid: 3.3.8
picocolors: 1.0.0
source-map-js: 1.0.2
@@ -17902,12 +17902,12 @@ packages:
resolution: {integrity: sha512-dyajjnfzZD1tht4N7p7iwf7nBnR1MjVaVu+MKr+7gBgA39bn28wizCIJZztZPtHy4PY0YwtSGgwfBCuG/hnHgA==}
dev: false
- /posthog-js@1.200.2:
- resolution: {integrity: sha512-hDdnzn/FWz+lR0qoYn8TJ7UAVzJSH48ceM2rYXrrZZa8EqBKaUKLf1LWK505/s3QVjK972mbF8wjF+pRDSlwOg==}
+ /posthog-js@1.203.1:
+ resolution: {integrity: sha512-r/WiSyz6VNbIKEV/30+aD5gdrYkFtmZwvqNa6h9frl8hG638v098FrXaq3EYzMcCdkQf3phaZTDIAFKegpiTjw==}
dependencies:
core-js: 3.39.0
fflate: 0.4.8
- preact: 10.25.2
+ preact: 10.25.3
web-vitals: 4.2.4
dev: false
@@ -17915,8 +17915,8 @@ packages:
resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==}
dev: false
- /preact@10.25.2:
- resolution: {integrity: sha512-GEts1EH3oMnqdOIeXhlbBSddZ9nrINd070WBOiPO2ous1orrKGUM4SMDbwyjSWD1iMS2dBvaDjAa5qUhz3TXqw==}
+ /preact@10.25.3:
+ resolution: {integrity: sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==}
dev: false
/prelude-ls@1.2.1:
@@ -18520,7 +18520,7 @@ packages:
react: '>=15'
dependencies:
react: 18.2.0
- unlayer-types: 1.182.0
+ unlayer-types: 1.188.0
dev: false
/react-error-boundary@3.1.4(react@18.2.0):
@@ -21107,8 +21107,8 @@ packages:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
engines: {node: '>= 10.0.0'}
- /unlayer-types@1.182.0:
- resolution: {integrity: sha512-x+YSeA7/Wb/znKDtRws8M3Mu6TyKP3d+MddPVX/iUyDPVEOapoPWk0QxjIaNYtWt6troADZdhzgr2EwsZ61HrA==}
+ /unlayer-types@1.188.0:
+ resolution: {integrity: sha512-tnn+FjUZv1qUOoRUYRFxSDz9kHfhy7dLxzMZgnU5+k6GDSBlpa8mA+r4+r0D83M+mUUd/XwuM+gvfRLGzrqZ+g==}
dev: false
/unpipe@1.0.0:
diff --git a/posthog/admin/__init__.py b/posthog/admin/__init__.py
index 5918f8379161c..ac085c63622d8 100644
--- a/posthog/admin/__init__.py
+++ b/posthog/admin/__init__.py
@@ -6,6 +6,7 @@
TeamAdmin,
DashboardAdmin,
DashboardTemplateAdmin,
+ DataColorThemeAdmin,
InsightAdmin,
ExperimentAdmin,
FeatureFlagAdmin,
@@ -31,6 +32,7 @@
DashboardTemplate,
Insight,
Experiment,
+ DataColorTheme,
FeatureFlag,
AsyncDeletion,
InstanceSetting,
@@ -56,6 +58,7 @@
admin.site.register(DashboardTemplate, DashboardTemplateAdmin)
admin.site.register(Insight, InsightAdmin)
admin.site.register(GroupTypeMapping, GroupTypeMappingAdmin)
+admin.site.register(DataColorTheme, DataColorThemeAdmin)
admin.site.register(Experiment, ExperimentAdmin)
admin.site.register(FeatureFlag, FeatureFlagAdmin)
diff --git a/posthog/admin/admins/__init__.py b/posthog/admin/admins/__init__.py
index e9fa4edc8841e..21827217c5845 100644
--- a/posthog/admin/admins/__init__.py
+++ b/posthog/admin/admins/__init__.py
@@ -2,6 +2,7 @@
from .cohort_admin import CohortAdmin
from .dashboard_admin import DashboardAdmin
from .dashboard_template_admin import DashboardTemplateAdmin
+from .data_color_theme_admin import DataColorThemeAdmin
from .data_warehouse_table_admin import DataWarehouseTableAdmin
from .experiment_admin import ExperimentAdmin
from .feature_flag_admin import FeatureFlagAdmin
diff --git a/posthog/admin/admins/data_color_theme_admin.py b/posthog/admin/admins/data_color_theme_admin.py
new file mode 100644
index 0000000000000..abe2764dd7e38
--- /dev/null
+++ b/posthog/admin/admins/data_color_theme_admin.py
@@ -0,0 +1,23 @@
+from django.contrib import admin
+from django.utils.html import format_html
+
+from posthog.models import DataColorTheme
+
+
+class DataColorThemeAdmin(admin.ModelAdmin):
+ list_display = (
+ "id",
+ "name",
+ "team_link",
+ )
+ readonly_fields = ("team",)
+
+ @admin.display(description="Team")
+ def team_link(self, theme: DataColorTheme):
+ if theme.team is None:
+ return None
+ return format_html(
+ '{} ',
+ theme.team.pk,
+ theme.team.name,
+ )
diff --git a/posthog/api/__init__.py b/posthog/api/__init__.py
index 72342189f2764..358f00f826694 100644
--- a/posthog/api/__init__.py
+++ b/posthog/api/__init__.py
@@ -2,7 +2,7 @@
from rest_framework_extensions.routers import NestedRegistryItem
-from posthog.api import metalytics, project
+from posthog.api import data_color_theme, metalytics, project
from posthog.api.routing import DefaultRouterPlusPlus
from posthog.batch_exports import http as batch_exports
from posthog.settings import EE_AVAILABLE
@@ -40,7 +40,6 @@
instance_settings,
instance_status,
integration,
- kafka_inspector,
notebook,
organization,
organization_domain,
@@ -378,7 +377,6 @@ def register_grandfathered_environment_nested_viewset(
router.register(r"dead_letter_queue", dead_letter_queue.DeadLetterQueueViewSet, "dead_letter_queue")
router.register(r"async_migrations", async_migration.AsyncMigrationsViewset, "async_migrations")
router.register(r"instance_settings", instance_settings.InstanceSettingsViewset, "instance_settings")
-router.register(r"kafka_inspector", kafka_inspector.KafkaInspectorViewSet, "kafka_inspector")
router.register("debug_ch_queries/", debug_ch_queries.DebugCHQueries, "debug_ch_queries")
from posthog.api.action import ActionViewSet # noqa: E402
@@ -579,3 +577,7 @@ def register_grandfathered_environment_nested_viewset(
)
projects_router.register(r"search", search.SearchViewSet, "project_search", ["project_id"])
+
+register_grandfathered_environment_nested_viewset(
+ r"data_color_themes", data_color_theme.DataColorThemeViewSet, "environment_data_color_themes", ["team_id"]
+)
diff --git a/posthog/api/alert.py b/posthog/api/alert.py
index df4d2b9791ca9..6ff9bd1e49dad 100644
--- a/posthog/api/alert.py
+++ b/posthog/api/alert.py
@@ -115,6 +115,7 @@ class Meta:
"config",
"calculation_interval",
"snoozed_until",
+ "skip_weekend",
]
read_only_fields = [
"id",
diff --git a/posthog/api/data_color_theme.py b/posthog/api/data_color_theme.py
new file mode 100644
index 0000000000000..3eb37e0959a69
--- /dev/null
+++ b/posthog/api/data_color_theme.py
@@ -0,0 +1,75 @@
+from rest_framework import serializers, viewsets
+from rest_framework.permissions import SAFE_METHODS, BasePermission
+from rest_framework.response import Response
+from django.db.models import Q
+
+from posthog.api.routing import TeamAndOrgViewSetMixin
+from posthog.api.shared import UserBasicSerializer
+from posthog.auth import SharingAccessTokenAuthentication
+from posthog.models import DataColorTheme
+
+
+class GlobalThemePermission(BasePermission):
+ message = "Only staff users can edit global themes."
+
+ def has_object_permission(self, request, view, obj) -> bool:
+ if request.method in SAFE_METHODS:
+ return True
+ elif view.team == obj.team:
+ return True
+ return request.user.is_staff
+
+
+class PublicDataColorThemeSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = DataColorTheme
+ fields = ["id", "name", "colors"]
+ read_only_fields = ["id", "name", "colors"]
+
+
+class DataColorThemeSerializer(PublicDataColorThemeSerializer):
+ is_global = serializers.SerializerMethodField()
+ created_by = UserBasicSerializer(read_only=True)
+
+ class Meta:
+ model = DataColorTheme
+ fields = ["id", "name", "colors", "is_global", "created_at", "created_by"]
+ read_only_fields = [
+ "id",
+ "is_global",
+ "created_at",
+ "created_by",
+ ]
+
+ def create(self, validated_data: dict, *args, **kwargs) -> DataColorTheme:
+ validated_data["team_id"] = self.context["team_id"]
+ validated_data["created_by"] = self.context["request"].user
+ return super().create(validated_data, *args, **kwargs)
+
+ def get_is_global(self, obj):
+ return obj.team_id is None
+
+
+class DataColorThemeViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
+ scope_object = "INTERNAL"
+ queryset = DataColorTheme.objects.all().order_by("-created_at")
+ serializer_class = DataColorThemeSerializer
+ permission_classes = [GlobalThemePermission]
+ sharing_enabled_actions = ["retrieve", "list"]
+
+ # override the team scope queryset to also include global themes
+ def dangerously_get_queryset(self):
+ query_condition = Q(team_id=self.team_id) | Q(team_id=None)
+
+ return DataColorTheme.objects.filter(query_condition)
+
+ def get_serializer_class(self):
+ if isinstance(self.request.successful_authenticator, SharingAccessTokenAuthentication):
+ return PublicDataColorThemeSerializer
+ return DataColorThemeSerializer
+
+ def list(self, request, *args, **kwargs):
+ queryset = self.filter_queryset(self.get_queryset())
+
+ serializer = self.get_serializer(queryset, many=True)
+ return Response(serializer.data)
diff --git a/posthog/api/decide.py b/posthog/api/decide.py
index 98b331de0472d..b3b9cbf0dde0c 100644
--- a/posthog/api/decide.py
+++ b/posthog/api/decide.py
@@ -45,12 +45,26 @@
labelnames=[LABEL_TEAM_ID, "errors_computing", "has_hash_key_override"],
)
+REMOTE_CONFIG_CACHE_COUNTER = Counter(
+ "posthog_remote_config_for_decide",
+ "Metric tracking whether Remote Config was used for decide",
+ labelnames=["result"],
+)
+
def get_base_config(token: str, team: Team, request: HttpRequest, skip_db: bool = False) -> dict:
- # Check for query param "use_remote_config"
- use_remote_config = request.GET.get("use_remote_config") == "true" or token in (
- settings.DECIDE_TOKENS_FOR_REMOTE_CONFIG or []
- )
+ use_remote_config = False
+
+ # Explicitly set via query param for testing otherwise rollout percentage
+ if request.GET.get("use_remote_config") == "true":
+ use_remote_config = True
+ elif request.GET.get("use_remote_config") == "false":
+ use_remote_config = False
+ elif settings.REMOTE_CONFIG_DECIDE_ROLLOUT_PERCENTAGE > 0:
+ if random() < settings.REMOTE_CONFIG_DECIDE_ROLLOUT_PERCENTAGE:
+ use_remote_config = True
+
+ REMOTE_CONFIG_CACHE_COUNTER.labels(result=use_remote_config).inc()
if use_remote_config:
response = RemoteConfig.get_config_via_token(token, request=request)
diff --git a/posthog/api/hog_function.py b/posthog/api/hog_function.py
index 4549f4f3a8bb5..b112919b5870c 100644
--- a/posthog/api/hog_function.py
+++ b/posthog/api/hog_function.py
@@ -32,6 +32,7 @@
TYPES_WITH_COMPILED_FILTERS,
TYPES_WITH_TRANSPILED_FILTERS,
TYPES_WITH_JAVASCRIPT_SOURCE,
+ HogFunctionType,
)
from posthog.models.plugin import TranspilerError
from posthog.plugins.plugin_server_api import create_hog_invocation_test
@@ -79,7 +80,7 @@ class HogFunctionMaskingSerializer(serializers.Serializer):
bytecode = serializers.JSONField(required=False, allow_null=True)
def validate(self, attrs):
- attrs["bytecode"] = generate_template_bytecode(attrs["hash"])
+ attrs["bytecode"] = generate_template_bytecode(attrs["hash"], input_collector=set())
return super().validate(attrs)
@@ -88,6 +89,8 @@ class HogFunctionSerializer(HogFunctionMinimalSerializer):
template = HogFunctionTemplateSerializer(read_only=True)
masking = HogFunctionMaskingSerializer(required=False, allow_null=True)
+ type = serializers.ChoiceField(choices=HogFunctionType.choices, required=False, allow_null=True)
+
class Meta:
model = HogFunction
fields = [
@@ -292,6 +295,10 @@ def get_serializer_class(self) -> type[BaseSerializer]:
return HogFunctionMinimalSerializer if self.action == "list" else HogFunctionSerializer
def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
+ if not (self.action == "partial_update" and self.request.data.get("deleted") is False):
+ # We only want to include deleted functions if we are un-deleting them
+ queryset = queryset.filter(deleted=False)
+
if self.action == "list":
if "type" in self.request.GET:
types = [self.request.GET.get("type", "destination")]
@@ -299,7 +306,7 @@ def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
types = self.request.GET.get("types", "destination").split(",")
else:
types = ["destination"]
- queryset = queryset.filter(deleted=False, type__in=types)
+ queryset = queryset.filter(type__in=types)
if self.request.GET.get("filters"):
try:
@@ -356,13 +363,13 @@ def invocations(self, request: Request, *args, **kwargs):
# Remove the team from the config
configuration.pop("team")
- globals = serializer.validated_data["globals"]
+ hog_globals = serializer.validated_data["globals"]
mock_async_functions = serializer.validated_data["mock_async_functions"]
res = create_hog_invocation_test(
team_id=hog_function.team_id,
hog_function_id=hog_function.id,
- globals=globals,
+ globals=hog_globals,
configuration=configuration,
mock_async_functions=mock_async_functions,
)
diff --git a/posthog/api/hog_function_template.py b/posthog/api/hog_function_template.py
index 38641031167ad..1f8151e161bec 100644
--- a/posthog/api/hog_function_template.py
+++ b/posthog/api/hog_function_template.py
@@ -5,7 +5,7 @@
from rest_framework.response import Response
from rest_framework.exceptions import NotFound
-from posthog.cdp.templates import HOG_FUNCTION_TEMPLATES
+from posthog.cdp.templates import HOG_FUNCTION_SUB_TEMPLATES, HOG_FUNCTION_TEMPLATES, ALL_HOG_FUNCTION_TEMPLATES_BY_ID
from posthog.cdp.templates.hog_function_template import (
HogFunctionMapping,
HogFunctionMappingTemplate,
@@ -51,17 +51,33 @@ class PublicHogFunctionTemplateViewSet(viewsets.GenericViewSet):
def list(self, request: Request, *args, **kwargs):
types = ["destination"]
+
+ sub_template_id = request.GET.get("sub_template_id")
+
if "type" in request.GET:
types = [self.request.GET.get("type", "destination")]
elif "types" in request.GET:
types = self.request.GET.get("types", "destination").split(",")
- templates = [item for item in HOG_FUNCTION_TEMPLATES if item.type in types]
- page = self.paginate_queryset(templates)
+
+ templates_list = HOG_FUNCTION_SUB_TEMPLATES if sub_template_id else HOG_FUNCTION_TEMPLATES
+
+ matching_templates = []
+
+ for template in templates_list:
+ if template.type not in types:
+ continue
+
+ if sub_template_id and sub_template_id not in template.id:
+ continue
+
+ matching_templates.append(template)
+
+ page = self.paginate_queryset(matching_templates)
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
def retrieve(self, request: Request, *args, **kwargs):
- item = next((item for item in HOG_FUNCTION_TEMPLATES if item.id == kwargs["pk"]), None)
+ item = ALL_HOG_FUNCTION_TEMPLATES_BY_ID.get(kwargs["pk"], None)
if not item:
raise NotFound(f"Template with id {kwargs['pk']} not found.")
diff --git a/posthog/api/kafka_inspector.py b/posthog/api/kafka_inspector.py
deleted file mode 100644
index e966c3e374394..0000000000000
--- a/posthog/api/kafka_inspector.py
+++ /dev/null
@@ -1,89 +0,0 @@
-from typing import Union
-
-from kafka import TopicPartition
-from rest_framework import serializers, viewsets
-from posthog.api.utils import action
-from rest_framework.response import Response
-
-from posthog.kafka_client.client import build_kafka_consumer
-from posthog.permissions import IsStaffUser
-
-KAFKA_CONSUMER_TIMEOUT = 1000
-
-
-# the kafka package doesn't expose ConsumerRecord
-class KafkaConsumerRecord:
- topic: str
- partition: int
- offset: int
- timestamp: int
- key: str
- value: Union[dict, str]
-
- def __init__(self, topic, partition, offset, timestamp, key, value):
- self.topic = topic
- self.partition = partition
- self.offset = offset
- self.value = value
- self.timestamp = timestamp
- self.key = key
-
-
-class KafkaMessageSerializer(serializers.Serializer):
- topic = serializers.CharField(read_only=True)
- partition = serializers.IntegerField(read_only=True)
- offset = serializers.IntegerField(read_only=True)
- timestamp = serializers.IntegerField(read_only=True)
- key = serializers.CharField(read_only=True)
- value = serializers.JSONField(read_only=True)
-
-
-class KafkaInspectorViewSet(viewsets.ViewSet):
- permission_classes = [IsStaffUser]
-
- @action(methods=["POST"], detail=False)
- def fetch_message(self, request):
- topic = request.data.get("topic", None)
- partition = request.data.get("partition", None)
- offset = request.data.get("offset", None)
-
- if not isinstance(topic, str):
- return Response({"error": "Invalid topic."}, status=400)
-
- if not isinstance(partition, int):
- return Response({"error": "Invalid partition."}, status=400)
-
- if not isinstance(offset, int):
- return Response({"error": "Invalid offset."}, status=400)
-
- try:
- message = get_kafka_message(topic, partition, offset)
- serializer = KafkaMessageSerializer(message, context={"request": request})
- return Response(serializer.data)
- except AssertionError:
- return Response({"error": "Invalid partition/offset pair."}, status=400)
- except StopIteration:
- return Response(
- {
- "error": f"Error reading message, most likely the consumer timed out after {KAFKA_CONSUMER_TIMEOUT}ms."
- },
- status=400,
- )
- except Exception as e:
- return Response({"error": e.__str__()}, status=500)
-
-
-def get_kafka_message(topic: str, partition: int, offset: int) -> KafkaConsumerRecord:
- consumer = build_kafka_consumer(
- topic=None,
- auto_offset_reset="earliest",
- group_id="kafka-inspector",
- consumer_timeout_ms=KAFKA_CONSUMER_TIMEOUT,
- )
-
- consumer.assign([TopicPartition(topic, partition)])
- consumer.seek(partition=TopicPartition(topic, partition), offset=offset)
-
- message = next(consumer)
-
- return message
diff --git a/posthog/api/query.py b/posthog/api/query.py
index be36aafc3e789..51e8d2450def9 100644
--- a/posthog/api/query.py
+++ b/posthog/api/query.py
@@ -1,19 +1,15 @@
import re
import uuid
-from typing import cast
-from django.http import JsonResponse, StreamingHttpResponse
+from django.http import JsonResponse
from drf_spectacular.utils import OpenApiResponse
from pydantic import BaseModel
from rest_framework import status, viewsets
from rest_framework.exceptions import NotAuthenticated, ValidationError
-from rest_framework.renderers import BaseRenderer
from rest_framework.request import Request
from rest_framework.response import Response
from sentry_sdk import capture_exception, set_tag
-from ee.hogai.assistant import Assistant
-from ee.hogai.utils import Conversation
from posthog.api.documentation import extend_schema
from posthog.api.mixins import PydanticModelMixin
from posthog.api.monitoring import Feature, monitor
@@ -48,14 +44,6 @@
)
-class ServerSentEventRenderer(BaseRenderer):
- media_type = "text/event-stream"
- format = "txt"
-
- def render(self, data, accepted_media_type=None, renderer_context=None):
- return data
-
-
class QueryViewSet(TeamAndOrgViewSetMixin, PydanticModelMixin, viewsets.ViewSet):
# NOTE: Do we need to override the scopes for the "create"
scope_object = "query"
@@ -65,7 +53,7 @@ class QueryViewSet(TeamAndOrgViewSetMixin, PydanticModelMixin, viewsets.ViewSet)
sharing_enabled_actions = ["retrieve"]
def get_throttles(self):
- if self.action in ("draft_sql", "chat"):
+ if self.action == "draft_sql":
return [AIBurstRateThrottle(), AISustainedRateThrottle()]
if query := self.request.data.get("query"):
if isinstance(query, dict) and query.get("kind") == "HogQLQuery":
@@ -179,13 +167,6 @@ def draft_sql(self, request: Request, *args, **kwargs) -> Response:
raise ValidationError({"prompt": [str(e)]}, code="unclear")
return Response({"sql": result})
- @action(detail=False, methods=["POST"], renderer_classes=[ServerSentEventRenderer])
- def chat(self, request: Request, *args, **kwargs):
- assert request.user is not None
- validated_body = Conversation.model_validate(request.data)
- assistant = Assistant(self.team, validated_body, cast(User, request.user))
- return StreamingHttpResponse(assistant.stream(), content_type=ServerSentEventRenderer.media_type)
-
def handle_column_ch_error(self, error):
if getattr(error, "message", None):
match = re.search(r"There's no column.*in table", error.message)
diff --git a/posthog/api/shared.py b/posthog/api/shared.py
index 2515017d00df1..a3419c6dda293 100644
--- a/posthog/api/shared.py
+++ b/posthog/api/shared.py
@@ -199,13 +199,7 @@ class TeamPublicSerializer(serializers.ModelSerializer):
class Meta:
model = Team
- fields = (
- "id",
- "project_id",
- "uuid",
- "name",
- "timezone",
- )
+ fields = ("id", "project_id", "uuid", "name", "timezone", "default_data_theme")
read_only_fields = fields
diff --git a/posthog/api/sharing.py b/posthog/api/sharing.py
index ca931c526248d..08f8067d85b0a 100644
--- a/posthog/api/sharing.py
+++ b/posthog/api/sharing.py
@@ -4,6 +4,7 @@
from urllib.parse import urlparse, urlunparse
from django.core.serializers.json import DjangoJSONEncoder
+from django.db.models import Q
from django.utils.timezone import now
from django.views.decorators.clickjacking import xframe_options_exempt
from loginas.utils import is_impersonated_session
@@ -13,6 +14,7 @@
from rest_framework.request import Request
from posthog.api.dashboards.dashboard import DashboardSerializer
+from posthog.api.data_color_theme import DataColorTheme, DataColorThemeSerializer
from posthog.api.exports import ExportedAssetSerializer
from posthog.api.insight import InsightSerializer
from posthog.api.routing import TeamAndOrgViewSetMixin
@@ -72,6 +74,12 @@ def export_asset_for_opengraph(resource: SharingConfiguration) -> ExportedAsset
return export_asset
+def get_themes_for_team(team: Team):
+ global_and_team_themes = DataColorTheme.objects.filter(Q(team_id=team.pk) | Q(team_id=None))
+ themes = DataColorThemeSerializer(global_and_team_themes, many=True).data
+ return themes
+
+
class SharingConfigurationSerializer(serializers.ModelSerializer):
class Meta:
model = SharingConfiguration
@@ -276,6 +284,7 @@ def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Any:
)
insight_data = InsightSerializer(resource.insight, many=False, context=context).data
exported_data.update({"insight": insight_data})
+ exported_data.update({"themes": get_themes_for_team(resource.team)})
elif resource.dashboard and not resource.dashboard.deleted:
asset_title = resource.dashboard.name
asset_description = resource.dashboard.description or ""
@@ -285,6 +294,7 @@ def retrieve(self, request: Request, *args: Any, **kwargs: Any) -> Any:
dashboard_data = DashboardSerializer(resource.dashboard, context=context).data
# We don't want the dashboard to be accidentally loaded via the shared endpoint
exported_data.update({"dashboard": dashboard_data})
+ exported_data.update({"themes": get_themes_for_team(resource.team)})
elif isinstance(resource, SharingConfiguration) and resource.recording and not resource.recording.deleted:
asset_title = "Session Recording"
recording_data = SessionRecordingSerializer(resource.recording, context=context).data
diff --git a/posthog/api/team.py b/posthog/api/team.py
index d2b9ca018dbdf..3aa1338ce46a6 100644
--- a/posthog/api/team.py
+++ b/posthog/api/team.py
@@ -201,6 +201,7 @@ class Meta:
"primary_dashboard",
"live_events_columns",
"recording_domains",
+ "cookieless_server_hash_mode",
"person_on_events_querying_enabled",
"inject_web_apps",
"extra_settings",
@@ -213,6 +214,7 @@ class Meta:
"product_intents",
"capture_dead_clicks",
"user_access_level",
+ "default_data_theme",
)
read_only_fields = (
"id",
diff --git a/posthog/api/test/__snapshots__/test_action.ambr b/posthog/api/test/__snapshots__/test_action.ambr
index 8ac1823a033c1..eb4628373a9c8 100644
--- a/posthog/api/test/__snapshots__/test_action.ambr
+++ b/posthog/api/test/__snapshots__/test_action.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -385,7 +387,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -896,7 +900,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_annotation.ambr b/posthog/api/test/__snapshots__/test_annotation.ambr
index 9340e03a2a4d8..f746ffb3f3dc2 100644
--- a/posthog/api/test/__snapshots__/test_annotation.ambr
+++ b/posthog/api/test/__snapshots__/test_annotation.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -380,7 +382,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -824,7 +828,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_api_docs.ambr b/posthog/api/test/__snapshots__/test_api_docs.ambr
index 54c41fff888b5..862d8507dbb75 100644
--- a/posthog/api/test/__snapshots__/test_api_docs.ambr
+++ b/posthog/api/test/__snapshots__/test_api_docs.ambr
@@ -61,7 +61,6 @@
"/home/runner/work/posthog/posthog/posthog/api/property_definition.py: Error [PropertyDefinitionViewSet]: exception raised while getting serializer. Hint: Is get_serializer_class() returning None or is get_queryset() not working without a request? Ignoring the view for now. (Exception: 'AnonymousUser' object has no attribute 'organization')",
'/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/proxy_record.py: Warning [ProxyRecordViewset]: could not derive type of path parameter "organization_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".',
- '/home/runner/work/posthog/posthog/posthog/api/query.py: Error [QueryViewSet]: unable to guess serializer. This is graceful fallback handling for APIViews. Consider using GenericAPIView as view base class, if view is under your control. Either way you may want to add a serializer_class (or method). Ignoring view for now.',
'/home/runner/work/posthog/posthog/posthog/api/query.py: Warning [QueryViewSet]: could not derive type of path parameter "id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/query.py: Warning [QueryViewSet]: could not derive type of path parameter "project_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".',
'/home/runner/work/posthog/posthog/posthog/api/session.py: Warning [SessionViewSet]: could not derive type of path parameter "project_id" because it is untyped and obtaining queryset from the viewset failed. Consider adding a type to the path (e.g. ) or annotating the parameter type with @extend_schema. Defaulting to "string".',
diff --git a/posthog/api/test/__snapshots__/test_decide.ambr b/posthog/api/test/__snapshots__/test_decide.ambr
index 049ef77b360b5..aa8e0f8a2d7f9 100644
--- a/posthog/api/test/__snapshots__/test_decide.ambr
+++ b/posthog/api/test/__snapshots__/test_decide.ambr
@@ -317,7 +317,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -388,7 +390,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -472,7 +476,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -688,7 +694,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -704,7 +712,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -769,7 +778,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -856,7 +867,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1224,7 +1237,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1295,7 +1310,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1381,7 +1398,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1456,7 +1475,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1656,7 +1677,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1672,7 +1695,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1864,7 +1888,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2064,7 +2090,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2080,7 +2108,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -2169,7 +2198,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2260,7 +2291,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2331,7 +2364,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2417,7 +2452,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2492,7 +2529,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2692,7 +2731,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2708,7 +2749,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -2888,7 +2930,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3088,7 +3132,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3104,7 +3150,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -3193,7 +3240,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3263,7 +3312,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3386,7 +3437,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3402,7 +3455,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -3459,7 +3513,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3534,7 +3590,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3750,7 +3808,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3766,7 +3826,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -3924,7 +3985,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3995,7 +4058,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4070,7 +4135,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4516,7 +4583,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4587,7 +4656,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4671,7 +4742,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4887,7 +4960,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4903,7 +4978,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -4968,7 +5044,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5055,7 +5133,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5169,7 +5249,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5385,7 +5467,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5401,7 +5485,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -5527,7 +5612,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5743,7 +5830,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5759,7 +5848,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -6070,7 +6160,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6141,7 +6233,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6227,7 +6321,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6302,7 +6398,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6502,7 +6600,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6518,7 +6618,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -6587,7 +6688,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6799,7 +6902,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6815,7 +6920,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -6925,7 +7031,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7088,7 +7196,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7195,7 +7305,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7211,7 +7323,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -7541,7 +7654,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7557,7 +7672,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -7646,7 +7762,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7737,7 +7855,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7808,7 +7928,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7894,7 +8016,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7969,7 +8093,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8169,7 +8295,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8185,7 +8313,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -8254,7 +8383,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8466,7 +8597,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8482,7 +8615,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -8588,7 +8722,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8751,7 +8887,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8858,7 +8996,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8874,7 +9014,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -9196,7 +9337,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9212,7 +9355,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -9301,7 +9445,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_early_access_feature.ambr b/posthog/api/test/__snapshots__/test_early_access_feature.ambr
index c7820616d0cfd..fcfee36ed4ace 100644
--- a/posthog/api/test/__snapshots__/test_early_access_feature.ambr
+++ b/posthog/api/test/__snapshots__/test_early_access_feature.ambr
@@ -50,7 +50,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -143,7 +145,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -218,7 +222,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -418,7 +424,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -434,7 +442,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -523,7 +532,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -618,7 +629,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -788,7 +801,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -988,7 +1003,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1004,7 +1021,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1084,7 +1102,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1154,7 +1174,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1336,7 +1358,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1411,7 +1435,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1611,7 +1637,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1627,7 +1655,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1684,7 +1713,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1841,7 +1872,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1916,7 +1949,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2116,7 +2151,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2132,7 +2169,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
diff --git a/posthog/api/test/__snapshots__/test_element.ambr b/posthog/api/test/__snapshots__/test_element.ambr
index e3ce7d60cebca..d58b57cd97468 100644
--- a/posthog/api/test/__snapshots__/test_element.ambr
+++ b/posthog/api/test/__snapshots__/test_element.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_feature_flag.ambr b/posthog/api/test/__snapshots__/test_feature_flag.ambr
index a00efc8ba764b..63dd2c1ae434a 100644
--- a/posthog/api/test/__snapshots__/test_feature_flag.ambr
+++ b/posthog/api/test/__snapshots__/test_feature_flag.ambr
@@ -1361,7 +1361,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1528,7 +1530,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_insight.ambr b/posthog/api/test/__snapshots__/test_insight.ambr
index 01390b5f4b341..9ff000b4d7a64 100644
--- a/posthog/api/test/__snapshots__/test_insight.ambr
+++ b/posthog/api/test/__snapshots__/test_insight.ambr
@@ -721,7 +721,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -784,7 +786,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -854,7 +858,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -924,7 +930,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1064,7 +1072,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1318,7 +1328,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1602,7 +1614,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1747,7 +1761,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1871,7 +1887,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2044,7 +2062,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2142,7 +2162,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_organization_feature_flag.ambr b/posthog/api/test/__snapshots__/test_organization_feature_flag.ambr
index 5b8721a2cd48f..23980835406eb 100644
--- a/posthog/api/test/__snapshots__/test_organization_feature_flag.ambr
+++ b/posthog/api/test/__snapshots__/test_organization_feature_flag.ambr
@@ -131,7 +131,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -206,7 +208,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -406,7 +410,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -422,7 +428,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -479,7 +486,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -606,7 +615,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -704,7 +715,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -774,7 +787,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -914,7 +929,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1008,7 +1025,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1106,7 +1125,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1176,7 +1197,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1316,7 +1339,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1409,7 +1434,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1510,7 +1537,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1710,7 +1739,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1726,7 +1757,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1783,7 +1815,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1928,7 +1962,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2728,7 +2764,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_preflight.ambr b/posthog/api/test/__snapshots__/test_preflight.ambr
index bbb5b5662471e..19b1ae0472e77 100644
--- a/posthog/api/test/__snapshots__/test_preflight.ambr
+++ b/posthog/api/test/__snapshots__/test_preflight.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/__snapshots__/test_survey.ambr b/posthog/api/test/__snapshots__/test_survey.ambr
index f4e08a30e1622..42a59d41db143 100644
--- a/posthog/api/test/__snapshots__/test_survey.ambr
+++ b/posthog/api/test/__snapshots__/test_survey.ambr
@@ -86,7 +86,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -149,7 +151,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -224,7 +228,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -449,7 +455,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -465,7 +473,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -545,7 +554,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -632,7 +643,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -857,7 +870,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -873,7 +888,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -953,7 +969,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1028,7 +1046,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1098,7 +1118,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1323,7 +1345,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1339,7 +1363,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1408,7 +1433,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1483,7 +1510,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1717,7 +1746,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1733,7 +1764,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
@@ -1822,7 +1854,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2140,7 +2174,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2156,7 +2192,8 @@
"posthog_team"."external_data_workspace_last_synced_at"
FROM "posthog_hogfunction"
INNER JOIN "posthog_team" ON ("posthog_hogfunction"."team_id" = "posthog_team"."id")
- WHERE ("posthog_hogfunction"."enabled"
+ WHERE (NOT "posthog_hogfunction"."deleted"
+ AND "posthog_hogfunction"."enabled"
AND "posthog_hogfunction"."team_id" = 99999
AND "posthog_hogfunction"."type" IN ('site_destination',
'site_app'))
diff --git a/posthog/api/test/batch_exports/fixtures.py b/posthog/api/test/batch_exports/fixtures.py
new file mode 100644
index 0000000000000..1c13b43d22db7
--- /dev/null
+++ b/posthog/api/test/batch_exports/fixtures.py
@@ -0,0 +1,13 @@
+from posthog.api.test.test_organization import create_organization as create_organization_base
+from posthog.constants import AvailableFeature
+from posthog.models import Organization
+
+
+def create_organization(name: str, has_data_pipelines_feature: bool = True) -> Organization:
+ organization = create_organization_base(name)
+ if has_data_pipelines_feature:
+ organization.available_product_features = [
+ {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES}
+ ]
+ organization.save()
+ return organization
diff --git a/posthog/api/test/batch_exports/test_backfill.py b/posthog/api/test/batch_exports/test_backfill.py
index e8dd4a29e81db..797db7ea93fd1 100644
--- a/posthog/api/test/batch_exports/test_backfill.py
+++ b/posthog/api/test/batch_exports/test_backfill.py
@@ -10,7 +10,7 @@
backfill_batch_export,
create_batch_export_ok,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.temporal.common.client import sync_connect
diff --git a/posthog/api/test/batch_exports/test_create.py b/posthog/api/test/batch_exports/test_create.py
index 28524c7d99513..676935e56b597 100644
--- a/posthog/api/test/batch_exports/test_create.py
+++ b/posthog/api/test/batch_exports/test_create.py
@@ -9,8 +9,8 @@
from rest_framework import status
from posthog.api.test.batch_exports.conftest import describe_schedule, start_test_worker
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.batch_exports.operations import create_batch_export
-from posthog.api.test.test_organization import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.batch_exports.models import BatchExport
diff --git a/posthog/api/test/batch_exports/test_delete.py b/posthog/api/test/batch_exports/test_delete.py
index 697415a4525cb..f5b303538707a 100644
--- a/posthog/api/test/batch_exports/test_delete.py
+++ b/posthog/api/test/batch_exports/test_delete.py
@@ -8,6 +8,7 @@
from temporalio.service import RPCError
from posthog.api.test.batch_exports.conftest import start_test_worker
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.batch_exports.operations import (
backfill_batch_export_ok,
create_batch_export_ok,
@@ -15,7 +16,6 @@
delete_batch_export_ok,
get_batch_export,
)
-from posthog.api.test.test_organization import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.temporal.common.client import sync_connect
diff --git a/posthog/api/test/batch_exports/test_get.py b/posthog/api/test/batch_exports/test_get.py
index f5e0060bc67b5..4e5adfd5a5d63 100644
--- a/posthog/api/test/batch_exports/test_get.py
+++ b/posthog/api/test/batch_exports/test_get.py
@@ -7,7 +7,7 @@
create_batch_export_ok,
get_batch_export,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.temporal.common.client import sync_connect
diff --git a/posthog/api/test/batch_exports/test_list.py b/posthog/api/test/batch_exports/test_list.py
index 7796964228fe5..579e3469b5b16 100644
--- a/posthog/api/test/batch_exports/test_list.py
+++ b/posthog/api/test/batch_exports/test_list.py
@@ -6,7 +6,7 @@
delete_batch_export_ok,
list_batch_exports_ok,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
diff --git a/posthog/api/test/batch_exports/test_pause.py b/posthog/api/test/batch_exports/test_pause.py
index 33c32f1a200bc..97eb8f90a1809 100644
--- a/posthog/api/test/batch_exports/test_pause.py
+++ b/posthog/api/test/batch_exports/test_pause.py
@@ -14,7 +14,7 @@
unpause_batch_export,
unpause_batch_export_ok,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.batch_exports.service import batch_export_delete_schedule
diff --git a/posthog/api/test/batch_exports/test_runs.py b/posthog/api/test/batch_exports/test_runs.py
index 0c58b717be6f2..75be430e4b07c 100644
--- a/posthog/api/test/batch_exports/test_runs.py
+++ b/posthog/api/test/batch_exports/test_runs.py
@@ -15,7 +15,7 @@
get_batch_export_runs,
get_batch_export_runs_ok,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.temporal.common.client import sync_connect
diff --git a/posthog/api/test/batch_exports/test_update.py b/posthog/api/test/batch_exports/test_update.py
index ec794d85484ee..80860ab153057 100644
--- a/posthog/api/test/batch_exports/test_update.py
+++ b/posthog/api/test/batch_exports/test_update.py
@@ -14,7 +14,7 @@
patch_batch_export,
put_batch_export,
)
-from posthog.api.test.test_organization import create_organization
+from posthog.api.test.batch_exports.fixtures import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
from posthog.batch_exports.service import sync_batch_export
diff --git a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr
index dfd916657a89b..c35ba96a70ce0 100644
--- a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr
+++ b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -270,7 +272,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -590,7 +594,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -886,7 +892,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1359,7 +1367,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1429,7 +1439,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1557,7 +1569,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1620,7 +1634,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1690,7 +1706,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1760,7 +1778,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -1900,7 +1920,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2022,7 +2044,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2236,7 +2260,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2688,7 +2714,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2786,7 +2814,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2916,7 +2946,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -2979,7 +3011,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3073,7 +3107,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3195,7 +3231,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3397,7 +3435,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3597,7 +3637,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3695,7 +3737,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3832,7 +3876,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -3974,7 +4020,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4044,7 +4092,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4114,7 +4164,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4254,7 +4306,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4376,7 +4430,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4572,7 +4628,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -4890,7 +4948,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5186,7 +5246,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5392,7 +5454,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5807,7 +5871,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -5930,7 +5996,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6112,7 +6180,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6383,7 +6453,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6481,7 +6553,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6579,7 +6653,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6681,7 +6757,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6751,7 +6829,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6891,7 +6971,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -6961,7 +7043,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7031,7 +7115,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7182,7 +7268,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7304,7 +7392,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7518,7 +7608,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7718,7 +7810,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7816,7 +7910,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7926,7 +8022,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -7996,7 +8094,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8066,7 +8166,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8206,7 +8308,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8328,7 +8432,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8530,7 +8636,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8737,7 +8845,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8835,7 +8945,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -8933,7 +9045,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9003,7 +9117,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9073,7 +9189,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9266,7 +9384,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9552,7 +9672,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9738,7 +9860,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9918,7 +10042,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -9981,7 +10107,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -10104,7 +10232,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -10286,7 +10416,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -10565,7 +10697,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -10688,7 +10822,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -10870,7 +11006,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr
index f585776717839..49cd5c39fed8b 100644
--- a/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr
+++ b/posthog/api/test/notebooks/__snapshots__/test_notebook.ambr
@@ -82,7 +82,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -235,7 +237,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -466,7 +470,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
@@ -864,7 +870,9 @@
"posthog_team"."person_display_name_properties",
"posthog_team"."live_events_columns",
"posthog_team"."recording_domains",
+ "posthog_team"."cookieless_server_hash_mode",
"posthog_team"."primary_dashboard_id",
+ "posthog_team"."default_data_theme",
"posthog_team"."extra_settings",
"posthog_team"."modifiers",
"posthog_team"."correlation_config",
diff --git a/posthog/api/test/test_alert.py b/posthog/api/test/test_alert.py
index 707114e420cab..5f980f671d737 100644
--- a/posthog/api/test/test_alert.py
+++ b/posthog/api/test/test_alert.py
@@ -64,6 +64,7 @@ def test_create_and_delete_alert(self) -> None:
"last_checked_at": None,
"next_check_at": None,
"snoozed_until": None,
+ "skip_weekend": False,
}
assert response.status_code == status.HTTP_201_CREATED, response.content
assert response.json() == expected_alert_json
diff --git a/posthog/api/test/test_app_metrics.py b/posthog/api/test/test_app_metrics.py
index 67b9a0a42eaa5..d11a27a394b76 100644
--- a/posthog/api/test/test_app_metrics.py
+++ b/posthog/api/test/test_app_metrics.py
@@ -8,6 +8,7 @@
from posthog.api.test.batch_exports.conftest import start_test_worker
from posthog.api.test.batch_exports.operations import create_batch_export_ok
from posthog.batch_exports.models import BatchExportRun
+from posthog.constants import AvailableFeature
from posthog.models.activity_logging.activity_log import Detail, Trigger, log_activity
from posthog.models.plugin import Plugin, PluginConfig
from posthog.models.utils import UUIDT
@@ -27,6 +28,11 @@ def setUp(self):
self.plugin = Plugin.objects.create(organization=self.organization)
self.plugin_config = PluginConfig.objects.create(plugin=self.plugin, team=self.team, enabled=True, order=1)
+ self.organization.available_product_features = [
+ {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES}
+ ]
+ self.organization.save()
+
def test_retrieve(self):
create_app_metric(
team_id=self.team.pk,
diff --git a/posthog/api/test/test_data_color_theme.py b/posthog/api/test/test_data_color_theme.py
new file mode 100644
index 0000000000000..94543450f584f
--- /dev/null
+++ b/posthog/api/test/test_data_color_theme.py
@@ -0,0 +1,60 @@
+from rest_framework import status
+
+from posthog.api.data_color_theme import DataColorTheme
+from posthog.models.organization import Organization
+from posthog.models.team.team import Team
+from posthog.test.base import APIBaseTest
+
+
+class TestDataColorTheme(APIBaseTest):
+ def test_can_fetch_public_themes(self) -> None:
+ response = self.client.get(f"/api/environments/{self.team.pk}/data_color_themes")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.data[0]["is_global"]
+
+ def test_can_fetch_own_themes(self) -> None:
+ other_org = Organization.objects.create(name="other org")
+ other_team = Team.objects.create(organization=other_org, name="other project")
+ DataColorTheme.objects.create(name="Custom theme 1", colors=[], team=self.team)
+ DataColorTheme.objects.create(name="Custom theme 2", colors=[], team=other_team)
+
+ response = self.client.get(f"/api/environments/{self.team.pk}/data_color_themes")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data) == 2
+ assert response.data[1]["name"] == "Custom theme 1"
+
+ def test_can_edit_own_themes(self) -> None:
+ theme = DataColorTheme.objects.create(name="Original name", colors=[], team=self.team)
+
+ response = self.client.patch(
+ f"/api/environments/{self.team.pk}/data_color_themes/{theme.pk}", {"name": "New name"}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert DataColorTheme.objects.get(pk=theme.pk).name == "New name"
+
+ def test_can_not_edit_public_themes(self) -> None:
+ theme = DataColorTheme.objects.first()
+ assert theme
+
+ response = self.client.patch(
+ f"/api/environments/{self.team.pk}/data_color_themes/{theme.pk}", {"name": "New name"}
+ )
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert DataColorTheme.objects.get(pk=theme.pk).name == "Default Theme"
+
+ def test_can_edit_public_themes_as_staff(self) -> None:
+ self.user.is_staff = True
+ self.user.save()
+ theme = DataColorTheme.objects.first()
+ assert theme
+
+ response = self.client.patch(
+ f"/api/environments/{self.team.pk}/data_color_themes/{theme.pk}", {"name": "New name"}
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert DataColorTheme.objects.get(pk=theme.pk).name == "New name"
diff --git a/posthog/api/test/test_decide.py b/posthog/api/test/test_decide.py
index bbf74ee1ecb72..5de31b701d950 100644
--- a/posthog/api/test/test_decide.py
+++ b/posthog/api/test/test_decide.py
@@ -123,7 +123,8 @@ def _post_decide(
if self.use_remote_config:
# We test a lot with settings changes so the idea is to refresh the remote config
remote_config = RemoteConfig.objects.get(team=self.team)
- remote_config.sync()
+ # Force as sync as lots of the tests are clearing redis purposefully which messes with things
+ remote_config.sync(force=True)
if groups is None:
groups = {}
diff --git a/posthog/api/test/test_feature_flag.py b/posthog/api/test/test_feature_flag.py
index efc67f79e3f40..4ae2d364d1f03 100644
--- a/posthog/api/test/test_feature_flag.py
+++ b/posthog/api/test/test_feature_flag.py
@@ -2,7 +2,6 @@
import json
from typing import Optional
from unittest.mock import call, patch
-from dateutil.relativedelta import relativedelta
from django.core.cache import cache
from django.db import connection
@@ -41,7 +40,6 @@
ClickhouseTestMixin,
FuzzyInt,
QueryMatchingTest,
- _create_event,
_create_person,
flush_persons_and_events,
snapshot_clickhouse_queries,
@@ -6130,18 +6128,6 @@ def assert_expected_response(
if expected_reason is not None:
self.assertEqual(response_data.get("reason"), expected_reason)
- def create_feature_flag_called_event(
- self, feature_flag_key: str, response: Optional[bool] = True, datetime: Optional[datetime.datetime] = None
- ):
- timestamp = datetime or now() - relativedelta(hours=12)
- _create_event(
- event="$feature_flag_called",
- distinct_id="person1",
- properties={"$feature_flag": feature_flag_key, "$feature_flag_response": response},
- team=self.team,
- timestamp=timestamp,
- )
-
def test_flag_status_reasons(self):
FeatureFlag.objects.all().delete()
@@ -6165,7 +6151,7 @@ def test_flag_status_reasons(self):
team=self.team,
active=False,
)
- self.create_feature_flag_called_event(disabled_flag.key)
+
self.assert_expected_response(disabled_flag.id, FeatureFlagStatus.ACTIVE)
# Request status for flag that has super group rolled out to <100%
@@ -6176,7 +6162,7 @@ def test_flag_status_reasons(self):
active=True,
filters={"super_groups": [{"rollout_percentage": 50, "properties": []}]},
)
- self.create_feature_flag_called_event(fifty_percent_super_group_flag.key)
+
self.assert_expected_response(fifty_percent_super_group_flag.id, FeatureFlagStatus.ACTIVE)
# Request status for flag that has super group rolled out to 100% and specific properties
@@ -6201,7 +6187,7 @@ def test_flag_status_reasons(self):
]
},
)
- self.create_feature_flag_called_event(fully_rolled_out_super_group_flag_with_properties.key)
+
self.assert_expected_response(fully_rolled_out_super_group_flag_with_properties.id, FeatureFlagStatus.ACTIVE)
# Request status for flag that has super group rolled out to 100% and has no specific properties
@@ -6231,7 +6217,7 @@ def test_flag_status_reasons(self):
active=True,
filters={"holdout_groups": [{"rollout_percentage": 50, "properties": []}]},
)
- self.create_feature_flag_called_event(fifty_percent_holdout_group_flag.key)
+
self.assert_expected_response(fifty_percent_holdout_group_flag.id, FeatureFlagStatus.ACTIVE)
# Request status for flag that has holdout group rolled out to 100% and specific properties
@@ -6256,7 +6242,7 @@ def test_flag_status_reasons(self):
]
},
)
- self.create_feature_flag_called_event(fully_rolled_out_holdout_group_flag_with_properties.key)
+
self.assert_expected_response(fully_rolled_out_holdout_group_flag_with_properties.id, FeatureFlagStatus.ACTIVE)
# Request status for flag that has holdout group rolled out to 100% and has no specific properties
@@ -6293,7 +6279,7 @@ def test_flag_status_reasons(self):
}
},
)
- self.create_feature_flag_called_event(multivariate_flag_no_rolled_out_variants.key)
+
self.assert_expected_response(multivariate_flag_no_rolled_out_variants.id, FeatureFlagStatus.ACTIVE)
# Request status for multivariate flag with no variants set to 100%
@@ -6337,7 +6323,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(multivariate_flag_rolled_out_variant_no_rolled_out_release.key)
+
self.assert_expected_response(
multivariate_flag_rolled_out_variant_no_rolled_out_release.id,
FeatureFlagStatus.ACTIVE,
@@ -6361,7 +6347,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(multivariate_flag_rolled_out_release_condition_half_variant.key)
+
self.assert_expected_response(
multivariate_flag_rolled_out_release_condition_half_variant.id,
FeatureFlagStatus.ACTIVE,
@@ -6396,7 +6382,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(multivariate_flag_rolled_out_variant_rolled_out_filtered_release.key)
+
self.assert_expected_response(
multivariate_flag_rolled_out_variant_rolled_out_filtered_release.id,
FeatureFlagStatus.ACTIVE,
@@ -6431,7 +6417,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(multivariate_flag_filtered_rolled_out_release_with_override.key)
+
self.assert_expected_response(
multivariate_flag_filtered_rolled_out_release_with_override.id,
FeatureFlagStatus.ACTIVE,
@@ -6509,7 +6495,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(boolean_flag_no_rolled_out_release_conditions.key)
+
self.assert_expected_response(
boolean_flag_no_rolled_out_release_conditions.id,
FeatureFlagStatus.ACTIVE,
@@ -6570,39 +6556,7 @@ def test_flag_status_reasons(self):
],
},
)
- self.create_feature_flag_called_event(boolean_flag_no_rolled_out_release_condition_recently_evaluated.key)
- self.assert_expected_response(
- boolean_flag_no_rolled_out_release_condition_recently_evaluated.id, FeatureFlagStatus.ACTIVE
- )
- # Request status for a boolean flag with no rolled out release conditions, and has
- # been called, but not recently
- boolean_flag_rolled_out_release_condition_not_recently_evaluated = FeatureFlag.objects.create(
- name="Boolean flag with a release condition set to 100%",
- key="boolean-not-recently-evaluated-flag",
- team=self.team,
- active=True,
- filters={
- "groups": [
- {
- "properties": [
- {
- "key": "name",
- "type": "person",
- "value": ["Smith"],
- "operator": "contains",
- }
- ],
- "rollout_percentage": 50,
- },
- ],
- },
- )
- self.create_feature_flag_called_event(
- boolean_flag_rolled_out_release_condition_not_recently_evaluated.key, True, now() - relativedelta(days=31)
- )
self.assert_expected_response(
- boolean_flag_rolled_out_release_condition_not_recently_evaluated.id,
- FeatureFlagStatus.INACTIVE,
- "Flag has not been evaluated recently",
+ boolean_flag_no_rolled_out_release_condition_recently_evaluated.id, FeatureFlagStatus.ACTIVE
)
diff --git a/posthog/api/test/test_hog_function.py b/posthog/api/test/test_hog_function.py
index b988b53fdbbfb..bd68c2ce66506 100644
--- a/posthog/api/test/test_hog_function.py
+++ b/posthog/api/test/test_hog_function.py
@@ -367,6 +367,29 @@ def test_deletes_via_update(self, *args):
]
assert filtered_actual_activities == expected_activities
+ def test_can_undelete_hog_function(self, *args):
+ response = self.client.post(
+ f"/api/projects/{self.team.id}/hog_functions/",
+ data={**EXAMPLE_FULL},
+ )
+ id = response.json()["id"]
+
+ response = self.client.patch(
+ f"/api/projects/{self.team.id}/hog_functions/{id}/",
+ data={"deleted": True},
+ )
+ assert response.status_code == status.HTTP_200_OK, response.json()
+ assert (
+ self.client.get(f"/api/projects/{self.team.id}/hog_functions/{id}").status_code == status.HTTP_404_NOT_FOUND
+ )
+
+ response = self.client.patch(
+ f"/api/projects/{self.team.id}/hog_functions/{id}/",
+ data={"deleted": False},
+ )
+ assert response.status_code == status.HTTP_200_OK, response.json()
+ assert self.client.get(f"/api/projects/{self.team.id}/hog_functions/{id}").status_code == status.HTTP_200_OK
+
def test_inputs_required(self, *args):
payload = {
"name": "Fetch URL",
@@ -386,6 +409,25 @@ def test_inputs_required(self, *args):
"attr": "inputs__url",
}
+ def test_validation_error_on_invalid_type(self, *args):
+ payload = {
+ "name": "Fetch URL",
+ "hog": "fetch(inputs.url);",
+ "inputs_schema": [
+ {"key": "url", "type": "string", "label": "Webhook URL", "required": True},
+ ],
+ "type": "invalid_type",
+ }
+ # Check required
+ res = self.client.post(f"/api/projects/{self.team.id}/hog_functions/", data={**payload})
+ assert res.status_code == status.HTTP_400_BAD_REQUEST, res.json()
+ assert res.json() == {
+ "type": "validation_error",
+ "code": "invalid_choice",
+ "detail": '"invalid_type" is not a valid choice.',
+ "attr": "type",
+ }
+
def test_inputs_mismatch_type(self, *args):
payload = {
"name": "Fetch URL",
@@ -456,6 +498,7 @@ def test_secret_inputs_not_returned(self, *args):
"I AM SECRET",
],
"value": "I AM SECRET",
+ "order": 0,
},
}
@@ -463,7 +506,7 @@ def test_secret_inputs_not_returned(self, *args):
assert (
raw_encrypted_inputs
- == "gAAAAABlkgC8AAAAAAAAAAAAAAAAAAAAAKvzDjuLG689YjjVhmmbXAtZSRoucXuT8VtokVrCotIx3ttPcVufoVt76dyr2phbuotMldKMVv_Y6uzMDZFjX1WLE6eeZEhBJqFv8fQacoHXhDbDh5fvL7DTr1sc2R_DmTwvPQDiSss790vZ6d_vm1Q="
+ == "gAAAAABlkgC8AAAAAAAAAAAAAAAAAAAAAKvzDjuLG689YjjVhmmbXAtZSRoucXuT8VtokVrCotIx3ttPcVufoVt76dyr2phbuotMldKMVv_Y6uzMDZFjX1Uvej4GHsYRbsTN_txcQHNnU7zvLee83DhHIrThEjceoq8i7hbfKrvqjEi7GCGc_k_Gi3V5KFxDOfLKnke4KM4s"
)
def test_secret_inputs_not_updated_if_not_changed(self, *args):
@@ -619,6 +662,7 @@ def test_generates_inputs_bytecode(self, *args):
32,
"http://localhost:2080/0e02d917-563f-4050-9725-aad881b69937",
],
+ "order": 0,
},
"payload": {
"value": {
@@ -628,6 +672,7 @@ def test_generates_inputs_bytecode(self, *args):
"person": "{person}",
"event_url": "{f'{event.url}-test'}",
},
+ "order": 1,
"bytecode": {
"event": ["_H", HOGQL_BYTECODE_VERSION, 32, "event", 1, 1],
"groups": ["_H", HOGQL_BYTECODE_VERSION, 32, "groups", 1, 1],
@@ -650,7 +695,7 @@ def test_generates_inputs_bytecode(self, *args):
],
},
},
- "method": {"value": "POST"},
+ "method": {"value": "POST", "order": 2},
"headers": {
"value": {"version": "v={event.properties.$lib_version}"},
"bytecode": {
@@ -672,6 +717,7 @@ def test_generates_inputs_bytecode(self, *args):
2,
]
},
+ "order": 3,
},
}
@@ -1141,6 +1187,7 @@ def test_create_typescript_destination_with_inputs(self):
inputs["message"]["transpiled"]["stl"].sort()
assert result["inputs"] == {
"message": {
+ "order": 0,
"transpiled": {
"code": 'concat("Hello, TypeScript ", arrayMap(__lambda((a) => a), [1, 2, 3]), "!")',
"lang": "ts",
diff --git a/posthog/api/test/test_hog_function_templates.py b/posthog/api/test/test_hog_function_templates.py
index 7a9b5150f5acd..4a34e36f88235 100644
--- a/posthog/api/test/test_hog_function_templates.py
+++ b/posthog/api/test/test_hog_function_templates.py
@@ -1,6 +1,8 @@
from unittest.mock import ANY
+from inline_snapshot import snapshot
from rest_framework import status
+from posthog.cdp.templates.hog_function_template import derive_sub_templates
from posthog.test.base import APIBaseTest, ClickhouseTestMixin, QueryMatchingTest
from posthog.cdp.templates.slack.template_slack import template
@@ -23,6 +25,22 @@
}
+class TestHogFunctionTemplatesMixin(APIBaseTest):
+ def test_derive_sub_templates(self):
+ # One sanity check test (rather than all of them)
+ sub_templates = derive_sub_templates([template])
+
+ # check overridden params
+ assert sub_templates[0].inputs_schema[-1]["key"] == "text"
+ assert sub_templates[0].inputs_schema[-1]["default"] == snapshot(
+ "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'"
+ )
+ assert sub_templates[0].filters == snapshot(
+ {"events": [{"id": "$feature_enrollment_update", "type": "events"}]}
+ )
+ assert sub_templates[0].type == "destination"
+
+
class TestHogFunctionTemplates(ClickhouseTestMixin, APIBaseTest, QueryMatchingTest):
def test_list_function_templates(self):
response = self.client.get("/api/projects/@current/hog_function_templates/")
@@ -48,6 +66,29 @@ def test_filter_function_templates(self):
response5 = self.client.get("/api/projects/@current/hog_function_templates/?types=site_destination,destination")
assert len(response5.json()["results"]) > 0
+ def test_filter_sub_templates(self):
+ response1 = self.client.get(
+ "/api/projects/@current/hog_function_templates/?type=internal_destination&sub_template_id=activity-log"
+ )
+ assert response1.status_code == status.HTTP_200_OK, response1.json()
+ assert len(response1.json()["results"]) > 0
+
+ template = response1.json()["results"][0]
+
+ assert template["sub_templates"] is None
+ assert template["type"] == "internal_destination"
+ assert template["id"] == "template-slack-activity-log"
+
+ def test_retrieve_function_template(self):
+ response = self.client.get("/api/projects/@current/hog_function_templates/template-slack")
+ assert response.status_code == status.HTTP_200_OK, response.json()
+ assert response.json()["id"] == "template-slack"
+
+ def test_retrieve_function_sub_template(self):
+ response = self.client.get("/api/projects/@current/hog_function_templates/template-slack-activity-log")
+ assert response.status_code == status.HTTP_200_OK, response.json()
+ assert response.json()["id"] == "template-slack-activity-log"
+
def test_public_list_function_templates(self):
self.client.logout()
response = self.client.get("/api/public_hog_function_templates/")
diff --git a/posthog/api/test/test_kafka_inspector.py b/posthog/api/test/test_kafka_inspector.py
deleted file mode 100644
index b9a02d0464e14..0000000000000
--- a/posthog/api/test/test_kafka_inspector.py
+++ /dev/null
@@ -1,62 +0,0 @@
-import json
-from typing import Union
-from unittest.mock import patch
-
-from rest_framework import status
-
-from posthog.api.kafka_inspector import KafkaConsumerRecord
-from posthog.test.base import APIBaseTest
-
-
-class TestKafkaInspector(APIBaseTest):
- def setUp(self):
- super().setUp()
- self.user.is_staff = True
- self.user.save()
-
- def _to_json(self, data: Union[dict, list]) -> str:
- return json.dumps(data)
-
- @patch(
- "posthog.api.kafka_inspector.get_kafka_message",
- side_effect=lambda _, __, ___: KafkaConsumerRecord("foo", 0, 0, 1650375470233, "k", "v"),
- )
- def test_fetch_message(self, _):
- response = self.client.post(
- "/api/kafka_inspector/fetch_message",
- data={"topic": "foo", "partition": 1, "offset": 0},
- )
- self.assertEqual(response.status_code, status.HTTP_200_OK)
- self.assertEqual(
- response.json(),
- {
- "key": "k",
- "offset": 0,
- "partition": 0,
- "timestamp": 1650375470233,
- "topic": "foo",
- "value": "v",
- },
- )
-
- def test_fetch_message_invalid_params(self):
- response = self.client.post(
- "/api/kafka_inspector/fetch_message",
- data={"topic": "foo", "partition": "1", "offset": 0},
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(response.json(), {"error": "Invalid partition."})
-
- response = self.client.post(
- "/api/kafka_inspector/fetch_message",
- data={"topic": 42, "partition": 1, "offset": 0},
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(response.json(), {"error": "Invalid topic."})
-
- response = self.client.post(
- "/api/kafka_inspector/fetch_message",
- data={"topic": "foo", "partition": 1, "offset": "0"},
- )
- self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- self.assertEqual(response.json(), {"error": "Invalid offset."})
diff --git a/posthog/api/test/test_remote_config.py b/posthog/api/test/test_remote_config.py
index c5fb3a53a1173..637030f5fe474 100644
--- a/posthog/api/test/test_remote_config.py
+++ b/posthog/api/test/test_remote_config.py
@@ -111,7 +111,7 @@ def test_valid_config_js(self):
assert response.headers["Content-Type"] == "application/javascript"
assert response.content == snapshot(
- b'(function() {\n window._POSTHOG_CONFIG = {"token": "token123", "supportedCompression": ["gzip", "gzip-js"], "hasFeatureFlags": false, "captureDeadClicks": false, "capturePerformance": {"network_timing": true, "web_vitals": false, "web_vitals_allowed_metrics": null}, "autocapture_opt_out": false, "autocaptureExceptions": false, "analytics": {"endpoint": "/i/v0/e/"}, "elementsChainAsString": true, "sessionRecording": {"endpoint": "/s/", "consoleLogRecordingEnabled": true, "recorderVersion": "v2", "sampleRate": null, "minimumDurationMilliseconds": null, "linkedFlag": null, "networkPayloadCapture": null, "urlTriggers": [], "urlBlocklist": [], "eventTriggers": [], "scriptConfig": null}, "heatmaps": false, "surveys": [], "defaultIdentifiedOnly": true};\n window._POSTHOG_JS_APPS = [];\n})();'
+ b'(function() {\n window._POSTHOG_REMOTE_CONFIG = window._POSTHOG_REMOTE_CONFIG || {};\n window._POSTHOG_REMOTE_CONFIG[\'token123\'] = {\n config: {"token": "token123", "supportedCompression": ["gzip", "gzip-js"], "hasFeatureFlags": false, "captureDeadClicks": false, "capturePerformance": {"network_timing": true, "web_vitals": false, "web_vitals_allowed_metrics": null}, "autocapture_opt_out": false, "autocaptureExceptions": false, "analytics": {"endpoint": "/i/v0/e/"}, "elementsChainAsString": true, "sessionRecording": {"endpoint": "/s/", "consoleLogRecordingEnabled": true, "recorderVersion": "v2", "sampleRate": null, "minimumDurationMilliseconds": null, "linkedFlag": null, "networkPayloadCapture": null, "urlTriggers": [], "urlBlocklist": [], "eventTriggers": [], "scriptConfig": null}, "heatmaps": false, "surveys": [], "defaultIdentifiedOnly": true},\n siteApps: []\n }\n})();'
)
@patch("posthog.models.remote_config.get_array_js_content", return_value="[MOCKED_ARRAY_JS_CONTENT]")
@@ -126,7 +126,7 @@ def test_valid_array_js(self, mock_get_array_js_content):
assert response.content
assert response.content == snapshot(
- b'[MOCKED_ARRAY_JS_CONTENT]\n\n(function() {\n window._POSTHOG_CONFIG = {"token": "token123", "supportedCompression": ["gzip", "gzip-js"], "hasFeatureFlags": false, "captureDeadClicks": false, "capturePerformance": {"network_timing": true, "web_vitals": false, "web_vitals_allowed_metrics": null}, "autocapture_opt_out": false, "autocaptureExceptions": false, "analytics": {"endpoint": "/i/v0/e/"}, "elementsChainAsString": true, "sessionRecording": {"endpoint": "/s/", "consoleLogRecordingEnabled": true, "recorderVersion": "v2", "sampleRate": null, "minimumDurationMilliseconds": null, "linkedFlag": null, "networkPayloadCapture": null, "urlTriggers": [], "urlBlocklist": [], "eventTriggers": [], "scriptConfig": null}, "heatmaps": false, "surveys": [], "defaultIdentifiedOnly": true};\n window._POSTHOG_JS_APPS = [];\n})();'
+ b'[MOCKED_ARRAY_JS_CONTENT]\n\n(function() {\n window._POSTHOG_REMOTE_CONFIG = window._POSTHOG_REMOTE_CONFIG || {};\n window._POSTHOG_REMOTE_CONFIG[\'token123\'] = {\n config: {"token": "token123", "supportedCompression": ["gzip", "gzip-js"], "hasFeatureFlags": false, "captureDeadClicks": false, "capturePerformance": {"network_timing": true, "web_vitals": false, "web_vitals_allowed_metrics": null}, "autocapture_opt_out": false, "autocaptureExceptions": false, "analytics": {"endpoint": "/i/v0/e/"}, "elementsChainAsString": true, "sessionRecording": {"endpoint": "/s/", "consoleLogRecordingEnabled": true, "recorderVersion": "v2", "sampleRate": null, "minimumDurationMilliseconds": null, "linkedFlag": null, "networkPayloadCapture": null, "urlTriggers": [], "urlBlocklist": [], "eventTriggers": [], "scriptConfig": null}, "heatmaps": false, "surveys": [], "defaultIdentifiedOnly": true},\n siteApps: []\n }\n})();'
)
# NOT actually testing the content here as it will change dynamically
diff --git a/posthog/api/test/test_signup.py b/posthog/api/test/test_signup.py
index 4d832a42791ec..a84a4cd9555b0 100644
--- a/posthog/api/test/test_signup.py
+++ b/posthog/api/test/test_signup.py
@@ -655,6 +655,7 @@ def run_test_for_allowed_domain(
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "jane@hogflix.posthog.com",
+ "sub": "123",
}
response = self.client.get(url, follow=True)
@@ -789,6 +790,7 @@ def test_social_signup_with_allowed_domain_on_cloud_reverse(self, mock_sso_provi
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "jane@hogflix.posthog.com",
+ "sub": "123",
}
response = self.client.get(url, follow=True)
@@ -828,6 +830,7 @@ def test_cannot_social_signup_with_allowed_but_jit_provisioning_disabled(self, m
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "alice@posthog.net",
+ "sub": "123",
}
response = self.client.get(url, follow=True)
@@ -857,6 +860,7 @@ def test_cannot_social_signup_with_allowed_but_unverified_domain(self, mock_sso_
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "alice@posthog.net",
+ "sub": "123",
}
response = self.client.get(url, follow=True)
@@ -886,6 +890,7 @@ def test_api_cannot_use_allow_list_for_different_domain(self, mock_sso_providers
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "alice@evil.com",
+ "sub": "123",
} # note evil.com
response = self.client.get(url, follow=True)
@@ -911,6 +916,7 @@ def test_social_signup_to_existing_org_without_allowed_domain_on_cloud(self, moc
mock_request.return_value.json.return_value = {
"access_token": "123",
"email": "jane@hogflix.posthog.com",
+ "sub": "123",
}
response = self.client.get(url, follow=True)
diff --git a/posthog/api/test/test_team.py b/posthog/api/test/test_team.py
index 1da488504f57d..8d4509f930584 100644
--- a/posthog/api/test/test_team.py
+++ b/posthog/api/test/test_team.py
@@ -458,7 +458,10 @@ def test_delete_bulky_postgres_data(self):
def test_delete_batch_exports(self):
self.organization_membership.level = OrganizationMembership.Level.ADMIN
self.organization_membership.save()
-
+ self.organization.available_product_features = [
+ {"key": AvailableFeature.DATA_PIPELINES, "name": AvailableFeature.DATA_PIPELINES}
+ ]
+ self.organization.save()
team: Team = Team.objects.create_with_data(initiating_user=self.user, organization=self.organization)
destination_data = {
@@ -486,16 +489,16 @@ def test_delete_batch_exports(self):
json.dumps(batch_export_data),
content_type="application/json",
)
- self.assertEqual(response.status_code, 201)
+ assert response.status_code == 201, response.json()
batch_export = response.json()
batch_export_id = batch_export["id"]
response = self.client.delete(f"/api/environments/{team.id}")
- self.assertEqual(response.status_code, 204)
+ assert response.status_code == 204, response.json()
response = self.client.get(f"/api/environments/{team.id}/batch_exports/{batch_export_id}")
- self.assertEqual(response.status_code, 404)
+ assert response.status_code == 404, response.json()
with self.assertRaises(RPCError):
describe_schedule(temporal, batch_export_id)
diff --git a/posthog/batch_exports/http.py b/posthog/batch_exports/http.py
index 72bc94adf03bd..c3929503635b2 100644
--- a/posthog/batch_exports/http.py
+++ b/posthog/batch_exports/http.py
@@ -1,5 +1,6 @@
import datetime as dt
from typing import Any, TypedDict, cast
+from loginas.utils import is_impersonated_session
import posthoganalytics
import structlog
@@ -31,6 +32,7 @@
sync_batch_export,
unpause_batch_export,
)
+from posthog.constants import AvailableFeature
from posthog.hogql import ast, errors
from posthog.hogql.hogql import HogQLContext
from posthog.hogql.parser import parse_select
@@ -245,6 +247,20 @@ class Meta:
]
read_only_fields = ["id", "team_id", "created_at", "last_updated_at", "latest_runs", "schema"]
+ def validate(self, attrs: dict) -> dict:
+ team = self.context["get_team"]()
+ attrs["team"] = team
+
+ has_addon = team.organization.is_feature_available(AvailableFeature.DATA_PIPELINES)
+
+ if not has_addon:
+ # Check if the user is impersonated - if so we allow changes as it could be an admin user fixing things
+
+ if not is_impersonated_session(self.context["request"]):
+ raise serializers.ValidationError("The Data Pipelines addon is required for batch exports.")
+
+ return attrs
+
def create(self, validated_data: dict) -> BatchExport:
"""Create a BatchExport."""
destination_data = validated_data.pop("destination")
diff --git a/posthog/batch_exports/service.py b/posthog/batch_exports/service.py
index d17bb3b1b69c3..c7e47003a4e5b 100644
--- a/posthog/batch_exports/service.py
+++ b/posthog/batch_exports/service.py
@@ -810,3 +810,27 @@ async def aupdate_records_total_count(
data_interval_end=interval_end,
).aupdate(records_total_count=count)
return rows_updated
+
+
+async def afetch_batch_export_runs_in_range(
+ batch_export_id: UUID,
+ interval_start: dt.datetime,
+ interval_end: dt.datetime,
+) -> list[BatchExportRun]:
+ """Async fetch all BatchExportRuns for a given batch export within a time interval.
+
+ Arguments:
+ batch_export_id: The UUID of the BatchExport to fetch runs for.
+ interval_start: The start of the time interval to fetch runs from.
+ interval_end: The end of the time interval to fetch runs until.
+
+ Returns:
+ A list of BatchExportRun objects within the given interval, ordered by data_interval_start.
+ """
+ queryset = BatchExportRun.objects.filter(
+ batch_export_id=batch_export_id,
+ data_interval_start__gte=interval_start,
+ data_interval_end__lte=interval_end,
+ ).order_by("data_interval_start")
+
+ return [run async for run in queryset]
diff --git a/posthog/cdp/internal_events.py b/posthog/cdp/internal_events.py
new file mode 100644
index 0000000000000..ba945ede3f2ff
--- /dev/null
+++ b/posthog/cdp/internal_events.py
@@ -0,0 +1,72 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Optional
+import uuid
+
+import structlog
+from posthog.kafka_client.client import KafkaProducer
+from posthog.kafka_client.topics import KAFKA_CDP_INTERNAL_EVENTS
+from rest_framework_dataclasses.serializers import DataclassSerializer
+
+logger = structlog.get_logger(__name__)
+
+
+@dataclass
+class InternalEventEvent:
+ event: str
+ distinct_id: str
+ properties: dict
+ timestamp: Optional[str] = None
+ url: Optional[str] = None
+ uuid: Optional[str] = None
+
+
+@dataclass
+class InternalEventPerson:
+ id: str
+ properties: dict
+ name: Optional[str] = None
+ url: Optional[str] = None
+
+
+@dataclass
+class InternalEvent:
+ team_id: int
+ event: InternalEventEvent
+ person: Optional[InternalEventPerson] = None
+
+
+class InternalEventSerializer(DataclassSerializer):
+ class Meta:
+ dataclass = InternalEvent
+
+
+def internal_event_to_dict(data: InternalEvent) -> dict:
+ return InternalEventSerializer(data).data
+
+
+def create_internal_event(
+ team_id: int, event: InternalEventEvent, person: Optional[InternalEventPerson] = None
+) -> InternalEvent:
+ data = InternalEvent(team_id=team_id, event=event, person=person)
+
+ if data.event.uuid is None:
+ data.event.uuid = str(uuid.uuid4())
+ if data.event.timestamp is None:
+ data.event.timestamp = datetime.now().isoformat()
+
+ return data
+
+
+def produce_internal_event(team_id: int, event: InternalEventEvent, person: Optional[InternalEventPerson] = None):
+ data = create_internal_event(team_id, event, person)
+ serialized_data = internal_event_to_dict(data)
+ kafka_topic = KAFKA_CDP_INTERNAL_EVENTS
+
+ try:
+ producer = KafkaProducer()
+ future = producer.produce(topic=kafka_topic, data=serialized_data, key=data.event.uuid)
+ future.get()
+ except Exception as e:
+ logger.exception("Failed to produce internal event", data=serialized_data, error=e)
+ raise
diff --git a/posthog/cdp/site_functions.py b/posthog/cdp/site_functions.py
index 690dc136ea577..e02895f39d99a 100644
--- a/posthog/cdp/site_functions.py
+++ b/posthog/cdp/site_functions.py
@@ -20,8 +20,9 @@ def get_transpiled_function(hog_function: HogFunction) -> str:
compiler = JavaScriptCompiler()
- # TODO: reorder inputs to make dependencies work
- for key, input in (hog_function.inputs or {}).items():
+ all_inputs = hog_function.inputs or {}
+ all_inputs = sorted(all_inputs.items(), key=lambda x: x[1].get("order", -1))
+ for key, input in all_inputs:
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):
@@ -92,7 +93,7 @@ def get_transpiled_function(hog_function: HogFunction) -> str:
"""
let processEvent = undefined;
if ('onEvent' in source) {
- processEvent = function processEvent(globals) {
+ processEvent = function processEvent(globals, posthog) {
if (!('onEvent' in source)) { 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 } };
@@ -122,9 +123,13 @@ def get_transpiled_function(hog_function: HogFunction) -> str:
callback(true);
}
- return {
- processEvent: processEvent
+ const response = {}
+
+ if (processEvent) {
+ response.processEvent = (globals) => processEvent(globals, posthog)
}
+
+ return response
}
return { init: init };"""
diff --git a/posthog/cdp/templates/__init__.py b/posthog/cdp/templates/__init__.py
index 57855bb7ca96f..6d4a24e6d1a27 100644
--- a/posthog/cdp/templates/__init__.py
+++ b/posthog/cdp/templates/__init__.py
@@ -1,3 +1,4 @@
+from posthog.cdp.templates.hog_function_template import derive_sub_templates
from .webhook.template_webhook import template as webhook
from .slack.template_slack import template as slack
from .hubspot.template_hubspot import template_event as hubspot_event, template as hubspot, TemplateHubspotMigrator
@@ -47,8 +48,11 @@
from ._siteapps.template_debug_posthog import template as debug_posthog
from ._internal.template_broadcast import template_new_broadcast as _broadcast
from ._internal.template_blank import blank_site_destination, blank_site_app
+from .snapchat_ads.template_snapchat_ads import template as snapchat_ads
+from .snapchat_ads.template_pixel import template_snapchat_pixel as snapchat_pixel
from ._transformations.template_pass_through import template as pass_through_transformation
+
HOG_FUNCTION_TEMPLATES = [
_broadcast,
blank_site_destination,
@@ -92,6 +96,8 @@
salesforce_create,
salesforce_update,
sendgrid,
+ snapchat_ads,
+ snapchat_pixel,
zapier,
zendesk,
early_access_features,
@@ -103,7 +109,12 @@
]
+# This is a list of sub templates that are generated by merging the subtemplate with it's template
+HOG_FUNCTION_SUB_TEMPLATES = derive_sub_templates(HOG_FUNCTION_TEMPLATES)
+
HOG_FUNCTION_TEMPLATES_BY_ID = {template.id: template for template in HOG_FUNCTION_TEMPLATES}
+HOG_FUNCTION_SUB_TEMPLATES_BY_ID = {template.id: template for template in HOG_FUNCTION_SUB_TEMPLATES}
+ALL_HOG_FUNCTION_TEMPLATES_BY_ID = {**HOG_FUNCTION_TEMPLATES_BY_ID, **HOG_FUNCTION_SUB_TEMPLATES_BY_ID}
HOG_FUNCTION_MIGRATORS = {
TemplateCustomerioMigrator.plugin_url: TemplateCustomerioMigrator,
@@ -119,4 +130,4 @@
TemplateAvoMigrator.plugin_url: TemplateAvoMigrator,
}
-__all__ = ["HOG_FUNCTION_TEMPLATES", "HOG_FUNCTION_TEMPLATES_BY_ID"]
+__all__ = ["HOG_FUNCTION_TEMPLATES", "HOG_FUNCTION_TEMPLATES_BY_ID", "ALL_HOG_FUNCTION_TEMPLATES_BY_ID"]
diff --git a/posthog/cdp/templates/discord/template_discord.py b/posthog/cdp/templates/discord/template_discord.py
index fb8cb2bf50c64..9e3111ec88817 100644
--- a/posthog/cdp/templates/discord/template_discord.py
+++ b/posthog/cdp/templates/discord/template_discord.py
@@ -1,5 +1,16 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON
+COMMON_INPUTS_SCHEMA = [
+ {
+ "key": "webhookUrl",
+ "type": "string",
+ "label": "Webhook URL",
+ "description": "See this page on how to generate a Webhook URL: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks",
+ "secret": False,
+ "required": True,
+ },
+]
+
template: HogFunctionTemplate = HogFunctionTemplate(
status="free",
type="destination",
@@ -48,20 +59,37 @@
],
sub_templates=[
HogFunctionSubTemplate(
- id="early_access_feature_enrollment",
+ id="early-access-feature-enrollment",
name="Post to Discord on feature enrollment",
description="Posts a message to Discord when a user enrolls or un-enrolls in an early access feature",
- filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters,
- inputs={
- "content": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'"
+ filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters,
+ input_schema_overrides={
+ "content": {
+ "default": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
+ }
},
),
HogFunctionSubTemplate(
- id="survey_response",
+ id="survey-response",
name="Post to Discord on survey response",
description="Posts a message to Discord when a user responds to a survey",
- filters=SUB_TEMPLATE_COMMON["survey_response"].filters,
- inputs={"content": "**{person.name}** responded to survey **{event.properties.$survey_name}**"},
+ filters=SUB_TEMPLATE_COMMON["survey-response"].filters,
+ input_schema_overrides={
+ "content": {
+ "default": "**{person.name}** responded to survey **{event.properties.$survey_name}**",
+ }
+ },
+ ),
+ HogFunctionSubTemplate(
+ id="activity-log",
+ type="internal_destination",
+ name="Post to Discord on team activity",
+ filters=SUB_TEMPLATE_COMMON["activity-log"].filters,
+ input_schema_overrides={
+ "content": {
+ "default": "**{person.name}** {event.properties.activity} {event.properties.scope} {event.properties.item_id}",
+ }
+ },
),
],
)
diff --git a/posthog/cdp/templates/hog_function_template.py b/posthog/cdp/templates/hog_function_template.py
index 0ebfc1f1c37dc..f76deacc3d4e4 100644
--- a/posthog/cdp/templates/hog_function_template.py
+++ b/posthog/cdp/templates/hog_function_template.py
@@ -8,10 +8,25 @@
PluginConfig = None
-SubTemplateId = Literal["early_access_feature_enrollment", "survey_response"]
+SubTemplateId = Literal["early-access-feature-enrollment", "survey-response", "activity-log"]
SUB_TEMPLATE_ID: tuple[SubTemplateId, ...] = get_args(SubTemplateId)
+HogFunctionTemplateType = Literal[
+ "destination",
+ "internal_destination",
+ "site_destination",
+ "site_app",
+ "transformation",
+ "shared",
+ "email",
+ "sms",
+ "push",
+ "broadcast",
+ "activity",
+ "alert",
+]
+
@dataclasses.dataclass(frozen=True)
class HogFunctionSubTemplate:
@@ -20,7 +35,8 @@ class HogFunctionSubTemplate:
description: Optional[str] = None
filters: Optional[dict] = None
masking: Optional[dict] = None
- inputs: Optional[dict] = None
+ input_schema_overrides: Optional[dict[str, dict]] = None
+ type: Optional[HogFunctionTemplateType] = None
@dataclasses.dataclass(frozen=True)
@@ -42,19 +58,7 @@ class HogFunctionMappingTemplate:
@dataclasses.dataclass(frozen=True)
class HogFunctionTemplate:
status: Literal["alpha", "beta", "stable", "free", "client-side"]
- type: Literal[
- "destination",
- "site_destination",
- "site_app",
- "transformation",
- "shared",
- "email",
- "sms",
- "push",
- "broadcast",
- "activity",
- "alert",
- ]
+ type: HogFunctionTemplateType
id: str
name: str
description: str
@@ -78,9 +82,41 @@ def migrate(cls, obj: PluginConfig) -> dict:
raise NotImplementedError()
+def derive_sub_templates(templates: list[HogFunctionTemplate]) -> list[HogFunctionTemplate]:
+ sub_templates = []
+ for template in templates:
+ for sub_template in template.sub_templates or []:
+ merged_id = f"{template.id}-{sub_template.id}"
+ template_params = dataclasses.asdict(template)
+ sub_template_params = dataclasses.asdict(sub_template)
+
+ # Override inputs_schema if set
+ input_schema_overrides = sub_template_params.pop("input_schema_overrides")
+ if input_schema_overrides:
+ new_input_schema = []
+ for schema in template_params["inputs_schema"]:
+ if schema["key"] in input_schema_overrides:
+ schema.update(input_schema_overrides[schema["key"]])
+ new_input_schema.append(schema)
+ template_params["inputs_schema"] = new_input_schema
+
+ # Get rid of the sub_templates from the template
+ template_params.pop("sub_templates")
+ # Update with the sub template params if not none
+ for key, value in sub_template_params.items():
+ if value is not None:
+ template_params[key] = value
+
+ template_params["id"] = merged_id
+ merged_template = HogFunctionTemplate(**template_params)
+ sub_templates.append(merged_template)
+
+ return sub_templates
+
+
SUB_TEMPLATE_COMMON: dict[SubTemplateId, HogFunctionSubTemplate] = {
- "survey_response": HogFunctionSubTemplate(
- id="survey_response",
+ "survey-response": HogFunctionSubTemplate(
+ id="survey-response",
name="Survey Response",
filters={
"events": [
@@ -99,9 +135,15 @@ def migrate(cls, obj: PluginConfig) -> dict:
]
},
),
- "early_access_feature_enrollment": HogFunctionSubTemplate(
- id="early_access_feature_enrollment",
+ "early-access-feature-enrollment": HogFunctionSubTemplate(
+ id="early-access-feature-enrollment",
name="Early Access Feature Enrollment",
filters={"events": [{"id": "$feature_enrollment_update", "type": "events"}]},
),
+ "activity-log": HogFunctionSubTemplate(
+ id="activity-log",
+ name="Team Activity",
+ type="internal_destination",
+ filters={"events": [{"id": "$activity_log_entry_created", "type": "events"}]},
+ ),
}
diff --git a/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py b/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py
index e647dde19f411..a6eb7063a52e6 100644
--- a/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py
+++ b/posthog/cdp/templates/microsoft_teams/template_microsoft_teams.py
@@ -1,5 +1,6 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON
+
template: HogFunctionTemplate = HogFunctionTemplate(
status="free",
type="destination",
@@ -66,20 +67,37 @@
],
sub_templates=[
HogFunctionSubTemplate(
- id="early_access_feature_enrollment",
+ id="early-access-feature-enrollment",
name="Post to Microsoft Teams on feature enrollment",
description="Posts a message to Microsoft Teams when a user enrolls or un-enrolls in an early access feature",
- filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters,
- inputs={
- "text": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'"
+ filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters,
+ input_schema_overrides={
+ "text": {
+ "default": "**{person.name}** {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
+ }
},
),
HogFunctionSubTemplate(
- id="survey_response",
+ id="survey-response",
name="Post to Microsoft Teams on survey response",
description="Posts a message to Microsoft Teams when a user responds to a survey",
- filters=SUB_TEMPLATE_COMMON["survey_response"].filters,
- inputs={"text": "**{person.name}** responded to survey **{event.properties.$survey_name}**"},
+ filters=SUB_TEMPLATE_COMMON["survey-response"].filters,
+ input_schema_overrides={
+ "text": {
+ "default": "**{person.name}** responded to survey **{event.properties.$survey_name}**",
+ }
+ },
+ ),
+ HogFunctionSubTemplate(
+ id="activity-log",
+ type="internal_destination",
+ name="Post to Microsoft Teams on team activity",
+ filters=SUB_TEMPLATE_COMMON["activity-log"].filters,
+ input_schema_overrides={
+ "text": {
+ "default": "**{person.name}** {event.properties.activity} {event.properties.scope} {event.properties.item_id}",
+ }
+ },
),
],
)
diff --git a/posthog/cdp/templates/slack/template_slack.py b/posthog/cdp/templates/slack/template_slack.py
index 8cfb5a84101de..3454c18381797 100644
--- a/posthog/cdp/templates/slack/template_slack.py
+++ b/posthog/cdp/templates/slack/template_slack.py
@@ -1,5 +1,6 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate, HogFunctionSubTemplate, SUB_TEMPLATE_COMMON
+
template: HogFunctionTemplate = HogFunctionTemplate(
status="free",
type="destination",
@@ -108,65 +109,95 @@
],
sub_templates=[
HogFunctionSubTemplate(
- id="early_access_feature_enrollment",
+ id="early-access-feature-enrollment",
name="Post to Slack on feature enrollment",
- description="Posts a message to Slack when a user enrolls or un-enrolls in an early access feature",
- filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters,
- inputs={
- "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
- "blocks": [
- {
- "text": {
- "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
- "type": "mrkdwn",
- },
- "type": "section",
- },
- {
- "type": "actions",
- "elements": [
- {
- "url": "{person.url}",
- "text": {"text": "View Person in PostHog", "type": "plain_text"},
- "type": "button",
+ # description="Posts a message to Slack when a user enrolls or un-enrolls in an early access feature",
+ filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters,
+ input_schema_overrides={
+ "blocks": {
+ "default": [
+ {
+ "text": {
+ "text": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
+ "type": "mrkdwn",
},
- # NOTE: It would be nice to have a link to the EAF but the event needs more info
- ],
- },
- ],
+ "type": "section",
+ },
+ {
+ "type": "actions",
+ "elements": [
+ {
+ "url": "{person.url}",
+ "text": {"text": "View Person in PostHog", "type": "plain_text"},
+ "type": "button",
+ },
+ ],
+ },
+ ],
+ },
+ "text": {
+ "default": "*{person.name}* {event.properties.$feature_enrollment ? 'enrolled in' : 'un-enrolled from'} the early access feature for '{event.properties.$feature_flag}'",
+ },
},
),
HogFunctionSubTemplate(
- id="survey_response",
+ id="survey-response",
name="Post to Slack on survey response",
description="Posts a message to Slack when a user responds to a survey",
- filters=SUB_TEMPLATE_COMMON["survey_response"].filters,
- inputs={
- "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*",
- "blocks": [
- {
- "text": {
- "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*",
- "type": "mrkdwn",
- },
- "type": "section",
- },
- {
- "type": "actions",
- "elements": [
- {
- "url": "{project.url}/surveys/{event.properties.$survey_id}",
- "text": {"text": "View Survey", "type": "plain_text"},
- "type": "button",
+ filters=SUB_TEMPLATE_COMMON["survey-response"].filters,
+ input_schema_overrides={
+ "blocks": {
+ "default": [
+ {
+ "text": {
+ "text": "*{person.name}* responded to survey *{event.properties.$survey_name}*",
+ "type": "mrkdwn",
},
- {
- "url": "{person.url}",
- "text": {"text": "View Person", "type": "plain_text"},
- "type": "button",
+ "type": "section",
+ },
+ {
+ "type": "actions",
+ "elements": [
+ {
+ "url": "{project.url}/surveys/{event.properties.$survey_id}",
+ "text": {"text": "View Survey", "type": "plain_text"},
+ "type": "button",
+ },
+ {
+ "url": "{person.url}",
+ "text": {"text": "View Person", "type": "plain_text"},
+ "type": "button",
+ },
+ ],
+ },
+ ],
+ },
+ "text": {
+ "default": "*{person.name}* responded to survey *{event.properties.$survey_name}*",
+ },
+ },
+ ),
+ HogFunctionSubTemplate(
+ id="activity-log",
+ name="Post to Slack on team activity",
+ description="",
+ filters=SUB_TEMPLATE_COMMON["activity-log"].filters,
+ type="internal_destination",
+ input_schema_overrides={
+ "blocks": {
+ "default": [
+ {
+ "text": {
+ "text": "*{person.properties.email}* {event.properties.activity} {event.properties.scope} {event.properties.item_id} ",
+ "type": "mrkdwn",
},
- ],
- },
- ],
+ "type": "section",
+ }
+ ],
+ },
+ "text": {
+ "default": "*{person.properties.email}* {event.properties.activity} {event.properties.scope} {event.properties.item_id}",
+ },
},
),
],
diff --git a/posthog/cdp/templates/snapchat_ads/template_pixel.py b/posthog/cdp/templates/snapchat_ads/template_pixel.py
new file mode 100644
index 0000000000000..24091ca16f7b8
--- /dev/null
+++ b/posthog/cdp/templates/snapchat_ads/template_pixel.py
@@ -0,0 +1,269 @@
+from posthog.cdp.templates.hog_function_template import HogFunctionMappingTemplate, HogFunctionTemplate
+
+common_inputs = [
+ {
+ "key": "eventProperties",
+ "type": "dictionary",
+ "description": "Map of Snapchat event attributes and their values. Check out this page for more details: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "label": "Event parameters",
+ "default": {
+ "price": "{toFloat(event.properties.price ?? event.properties.value ?? event.properties.revenue)}",
+ "currency": "{event.properties.currency}",
+ "item_ids": "{event.properties.item_ids}",
+ "item_category": "{event.properties.category}",
+ "description": "{event.properties.description}",
+ "search_string": "{event.properties.search_string}",
+ "number_items": "{toInt(event.properties.number_items ?? event.properties.quantity)}",
+ "payment_info_available": "{toInt(event.properties.payment_info_available)}",
+ "sign_up_method": "{event.properties.sign_up_method}",
+ "brands": "{event.properties.brands}",
+ "success": "{toInt(event.properties.success) in (0, 1) ? toInt(event.properties.success) : null}",
+ "transaction_id": "{event.properties.orderId ?? event.properties.transactionId ?? event.properties.transaction_id}",
+ "client_dedup_id": "{event.uuid}",
+ },
+ "secret": False,
+ "required": False,
+ },
+]
+
+template_snapchat_pixel: HogFunctionTemplate = HogFunctionTemplate(
+ status="client-side",
+ type="site_destination",
+ id="template-snapchat-pixel",
+ name="Snapchat Pixel",
+ description="Track how many Snapchat users interact with your website.",
+ icon_url="/static/services/snapchat.png",
+ category=["Advertisement"],
+ hog="""
+// Adds window.snaptr and lazily loads the Snapchat Pixel script
+function initSnippet() {
+ (function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function()
+ {a.handleRequest?a.handleRequest.apply(a,arguments):a.queue.push(arguments)};
+ a.queue=[];var s='script';r=t.createElement(s);r.async=!0;
+ r.src=n;var u=t.getElementsByTagName(s)[0];
+ u.parentNode.insertBefore(r,u);})(window,document,
+ 'https://sc-static.net/scevent.min.js');
+}
+
+export function onLoad({ inputs }) {
+ initSnippet();
+ let userProperties = {};
+ for (const [key, value] of Object.entries(inputs.userProperties)) {
+ if (value) {
+ userProperties[key] = value;
+ }
+ };
+ snaptr('init', inputs.pixelId, userProperties);
+}
+export function onEvent({ inputs }) {
+ let eventProperties = {};
+ for (const [key, value] of Object.entries(inputs.eventProperties)) {
+ if (value) {
+ eventProperties[key] = value;
+ }
+ };
+ snaptr('track', inputs.eventType, eventProperties);
+}
+""".strip(),
+ inputs_schema=[
+ {
+ "key": "pixelId",
+ "type": "string",
+ "label": "Pixel ID",
+ "description": "You must obtain a Pixel ID to use the Snapchat Pixel. If you've already set up a Pixel for your website, we recommend that you use the same Pixel ID for your browser and server events.",
+ "default": "",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "userProperties",
+ "type": "dictionary",
+ "description": "Map of Snapchat user parameters and their values. Check out this page for more details: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "label": "User parameters",
+ "default": {
+ "user_email": "{person.properties.email}",
+ "user_phone_number": "{person.properties.phone}",
+ },
+ "secret": False,
+ "required": False,
+ },
+ ],
+ mapping_templates=[
+ HogFunctionMappingTemplate(
+ name="Page Viewed",
+ include_by_default=True,
+ filters={"events": [{"id": "$pageview", "name": "Pageview", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "PAGE_VIEW",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Order Completed",
+ include_by_default=True,
+ filters={"events": [{"id": "Order Completed", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "PURCHASE",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Checkout Started",
+ include_by_default=True,
+ filters={"events": [{"id": "Checkout Started", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "START_CHECKOUT",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Product Added",
+ include_by_default=True,
+ filters={"events": [{"id": "Product Added", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "ADD_CART",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Payment Info Entered",
+ include_by_default=True,
+ filters={"events": [{"id": "Payment Info Entered", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "ADD_BILLING",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Promotion Clicked",
+ include_by_default=True,
+ filters={"events": [{"id": "Promotion Clicked", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "AD_CLICK",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Promotion Viewed",
+ include_by_default=True,
+ filters={"events": [{"id": "Promotion Viewed", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "AD_VIEW",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Product Added to Wishlist",
+ include_by_default=True,
+ filters={"events": [{"id": "Product Added to Wishlist", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "ADD_TO_WISHLIST",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Product Viewed",
+ include_by_default=True,
+ filters={"events": [{"id": "Product Viewed", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "VIEW_CONTENT",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Product List Viewed",
+ include_by_default=True,
+ filters={"events": [{"id": "Product List Viewed", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "VIEW_CONTENT",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ HogFunctionMappingTemplate(
+ name="Products Searched",
+ include_by_default=True,
+ filters={"events": [{"id": "Products Searched", "type": "events"}]},
+ inputs_schema=[
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "SEARCH",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ ),
+ ],
+)
diff --git a/posthog/cdp/templates/snapchat_ads/template_snapchat_ads.py b/posthog/cdp/templates/snapchat_ads/template_snapchat_ads.py
new file mode 100644
index 0000000000000..be7d394c41dae
--- /dev/null
+++ b/posthog/cdp/templates/snapchat_ads/template_snapchat_ads.py
@@ -0,0 +1,368 @@
+from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
+
+common_inputs = [
+ {
+ "key": "eventId",
+ "type": "string",
+ "label": "Event ID",
+ "description": "This field represents a unique identifier chosen to represent an event",
+ "default": "{event.uuid}",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "eventTime",
+ "type": "string",
+ "label": "Event time",
+ "description": "A Unix timestamp in seconds indicating when the actual event occurred. You must send this date in GMT time zone.",
+ "default": "{toUnixTimestampMilli(event.timestamp)}",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "eventSourceUrl",
+ "type": "string",
+ "label": "Event source URL",
+ "description": "The URL of the web page where the event took place.",
+ "default": "{event.properties.$current_url}",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "actionSource",
+ "label": "Action source",
+ "type": "choice",
+ "choices": [
+ {
+ "label": "WEB - Conversion was made on your website.",
+ "value": "WEB",
+ },
+ {
+ "label": "MOBILE_APP - Conversion was made on your mobile app.",
+ "value": "MOBILE_APP",
+ },
+ {
+ "label": "OFFLINE - Conversion happened in a way that is not listed.",
+ "value": "OFFLINE",
+ },
+ ],
+ "description": "This field allows you to specify where your conversions occurred. Knowing where your events took place helps ensure your ads go to the right people.",
+ "default": "WEB",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "customData",
+ "type": "dictionary",
+ "label": "Custom data",
+ "description": "A map that contains custom data. See this page for options: https://developers.snap.com/api/marketing-api/Conversions-API/Parameters#custom-data-parameters",
+ "default": {
+ "value": "{toFloat(event.properties.price ?? event.properties.value ?? event.properties.revenue)}",
+ "currency": "{event.properties.currency}",
+ "content_ids": "{event.properties.item_ids}",
+ "content_category": "{event.properties.category}",
+ "search_string": "{event.properties.search_string ?? event.properties.query}",
+ "num_items": "{toInt(event.properties.number_items ?? event.properties.quantity)}",
+ "order_id": "{event.properties.orderId ?? event.properties.transactionId ?? event.properties.transaction_id}",
+ "event_id": "{event.uuid}",
+ },
+ "secret": False,
+ "required": True,
+ },
+]
+
+template: HogFunctionTemplate = HogFunctionTemplate(
+ status="alpha",
+ type="destination",
+ id="template-snapchat-ads",
+ name="Snapchat Ads Conversions",
+ description="Send conversion events to Snapchat Ads",
+ icon_url="/static/services/snapchat.png",
+ category=["Advertisement"],
+ hog="""
+let body := {
+ 'data': [
+ {
+ 'event_name': inputs.eventType,
+ 'action_source': inputs.actionSource,
+ 'event_time': inputs.eventTime,
+ 'event_source_url': inputs.eventSourceUrl,
+ 'user_data': {},
+ 'custom_data': {}
+ }
+ ]
+}
+
+for (let key, value in inputs.userData) {
+ if (not empty(value)) {
+ body.data.1.user_data[key] := value
+ }
+}
+
+for (let key, value in inputs.customData) {
+ if (not empty(value)) {
+ body.data.1.custom_data[key] := value
+ }
+}
+
+let res := fetch(f'https://tr.snapchat.com/v3/{inputs.pixelId}/events?access_token={inputs.oauth.access_token}', {
+ 'method': 'POST',
+ 'headers': {
+ 'Content-Type': 'application/json',
+ },
+ 'body': body
+})
+if (res.status >= 400) {
+ throw Error(f'Error from tr.snapchat.com (status {res.status}): {res.body}')
+}
+""".strip(),
+ inputs_schema=[
+ {
+ "key": "oauth",
+ "type": "integration",
+ "integration": "snapchat",
+ "label": "Snapchat account",
+ "requiredScopes": "snapchat-offline-conversions-api snapchat-marketing-api",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "pixelId",
+ "type": "string",
+ "label": "Pixel ID",
+ "description": "You must obtain a Pixel ID to use the Conversions API. If youāve already set up a Pixel for your website, we recommend that you use the same Pixel ID for your browser and server events.",
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "userData",
+ "type": "dictionary",
+ "label": "User data",
+ "description": "A map that contains customer information data. See this page for options: https://developers.snap.com/api/marketing-api/Conversions-API/Parameters#user-data-parameters",
+ "default": {
+ "em": "{sha256Hex(person.properties.email)}",
+ "ph": "{sha256Hex(person.properties.phone)}",
+ "sc_click_id": "{person.properties.sccid ?? person.properties.$initial_sccid}",
+ },
+ "secret": False,
+ "required": True,
+ },
+ {
+ "key": "eventType",
+ "type": "string",
+ "label": "Event Type",
+ "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ "default": "{"
+ "event.event == '$pageview' ? 'PAGE_VIEW'"
+ ": event.event == 'Order Completed' ? 'PURCHASE'"
+ ": event.event == 'Checkout Started' ? 'START_CHECKOUT'"
+ ": event.event == 'Product Added' ? 'ADD_CART'"
+ ": event.event == 'Payment Info Entered' ? 'ADD_BILLING'"
+ ": event.event == 'Promotion Clicked' ? 'AD_CLICK'"
+ ": event.event == 'Promotion Viewed' ? 'AD_VIEW'"
+ ": event.event == 'Product Added to Wishlist' ? 'ADD_TO_WISHLIST'"
+ ": event.event == 'Product Viewed' ? 'VIEW_CONTENT'"
+ ": event.event == 'Product List Viewed' ? 'VIEW_CONTENT'"
+ ": event.event == 'Products Searched' ? 'SEARCH'"
+ ": event.event"
+ "}",
+ "required": True,
+ },
+ *common_inputs,
+ ],
+ # mapping_templates=[
+ # HogFunctionMappingTemplate(
+ # name="Page viewed",
+ # include_by_default=True,
+ # filters={"events": [{"id": "$pageview", "name": "Pageview", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "PAGE_VIEW",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Order Completed",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Order Completed", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "PURCHASE",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Checkout Started",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Checkout Started", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "START_CHECKOUT",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Product Added",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Product Added", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "ADD_CART",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Payment Info Entered",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Payment Info Entered", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "ADD_BILLING",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Promotion Clicked",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Promotion Clicked", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "AD_CLICK",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Promotion Viewed",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Promotion Viewed", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "AD_VIEW",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Product Added to Wishlist",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Product Added to Wishlist", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "ADD_TO_WISHLIST",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Product Viewed",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Product Viewed", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "VIEW_CONTENT",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Product List Viewed",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Product List Viewed", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "VIEW_CONTENT",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # HogFunctionMappingTemplate(
+ # name="Products Searched",
+ # include_by_default=True,
+ # filters={"events": [{"id": "Products Searched", "type": "events"}]},
+ # inputs_schema=[
+ # {
+ # "key": "eventType",
+ # "type": "string",
+ # "label": "Event Type",
+ # "description": "Check out this page for possible event types: https://businesshelp.snapchat.com/s/article/pixel-direct-implementation",
+ # "default": "SEARCH",
+ # "required": True,
+ # },
+ # *common_inputs
+ # ],
+ # ),
+ # ],
+ filters={
+ "events": [
+ {"id": "$pageview", "name": "Pageview", "type": "events"},
+ {"id": "Order Completed", "type": "events"},
+ {"id": "Checkout Started", "type": "events"},
+ {"id": "Product Added", "type": "events"},
+ {"id": "Payment Info Entered", "type": "events"},
+ {"id": "Promotion Clicked", "type": "events"},
+ {"id": "Promotion Viewed", "type": "events"},
+ {"id": "Product Added to Wishlist", "type": "events"},
+ {"id": "Product Viewed", "type": "events"},
+ {"id": "Product List Viewed", "type": "events"},
+ {"id": "Products Searched", "type": "events"},
+ ],
+ "actions": [],
+ "filter_test_accounts": True,
+ },
+)
diff --git a/posthog/cdp/templates/snapchat_ads/test_template_snapchat_ads.py b/posthog/cdp/templates/snapchat_ads/test_template_snapchat_ads.py
new file mode 100644
index 0000000000000..373e784e91228
--- /dev/null
+++ b/posthog/cdp/templates/snapchat_ads/test_template_snapchat_ads.py
@@ -0,0 +1,73 @@
+from inline_snapshot import snapshot
+from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
+from posthog.cdp.templates.snapchat_ads.template_snapchat_ads import (
+ template as template_snapchat_ads,
+)
+
+
+class TestTemplateSnapchatAds(BaseHogFunctionTemplateTest):
+ template = template_snapchat_ads
+
+ def _inputs(self, **kwargs):
+ inputs = {
+ "oauth": {
+ "access_token": "oauth-1234",
+ },
+ "pixelId": "pixel12345",
+ "eventType": "PAGE_VIEW",
+ "eventSourceUrl": "https://posthog.com/cdp",
+ "eventTime": "1728812163",
+ "actionSource": "WEB",
+ "userData": {
+ "em": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
+ },
+ "customData": {
+ "currency": "USD",
+ "price": "1500",
+ "event_id": "49ff3d7c-359d-4f45-960e-6cda29f1beea",
+ },
+ }
+ inputs.update(kwargs)
+ return inputs
+
+ def test_function_works(self):
+ self.run_function(
+ self._inputs(),
+ globals={
+ "event": {
+ "uuid": "49ff3d7c-359d-4f45-960e-6cda29f1beea",
+ "properties": {
+ "$current_url": "https://posthog.com/cdp",
+ },
+ "event": "$pageview",
+ },
+ },
+ )
+
+ assert self.get_mock_fetch_calls()[0] == snapshot(
+ (
+ "https://tr.snapchat.com/v3/pixel12345/events?access_token=oauth-1234",
+ {
+ "method": "POST",
+ "headers": {
+ "Content-Type": "application/json",
+ },
+ "body": {
+ "data": [
+ {
+ "event_name": "PAGE_VIEW",
+ "action_source": "WEB",
+ "event_time": "1728812163",
+ "event_source_url": "https://posthog.com/cdp",
+ "user_data": {"em": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98"},
+ "custom_data": {
+ "currency": "USD",
+ "price": "1500",
+ "event_id": "49ff3d7c-359d-4f45-960e-6cda29f1beea",
+ },
+ }
+ ]
+ },
+ },
+ )
+ )
diff --git a/posthog/cdp/templates/webhook/template_webhook.py b/posthog/cdp/templates/webhook/template_webhook.py
index f27822c5e0c24..45789df2b9fac 100644
--- a/posthog/cdp/templates/webhook/template_webhook.py
+++ b/posthog/cdp/templates/webhook/template_webhook.py
@@ -78,6 +78,7 @@
"label": "Headers",
"secret": False,
"required": False,
+ "default": {"Content-Type": "application/json"},
},
{
"key": "debug",
@@ -91,14 +92,20 @@
],
sub_templates=[
HogFunctionSubTemplate(
- id="early_access_feature_enrollment",
+ id="early-access-feature-enrollment",
name="HTTP Webhook on feature enrollment",
- filters=SUB_TEMPLATE_COMMON["early_access_feature_enrollment"].filters,
+ filters=SUB_TEMPLATE_COMMON["early-access-feature-enrollment"].filters,
),
HogFunctionSubTemplate(
- id="survey_response",
+ id="survey-response",
name="HTTP Webhook on survey response",
- filters=SUB_TEMPLATE_COMMON["survey_response"].filters,
+ filters=SUB_TEMPLATE_COMMON["survey-response"].filters,
+ ),
+ HogFunctionSubTemplate(
+ id="activity-log",
+ name="HTTP Webhook on team activity",
+ filters=SUB_TEMPLATE_COMMON["activity-log"].filters,
+ type="internal_destination",
),
],
)
diff --git a/posthog/cdp/templates/zapier/template_zapier.py b/posthog/cdp/templates/zapier/template_zapier.py
index 6f47b444ea2ac..bdd41ffa2a904 100644
--- a/posthog/cdp/templates/zapier/template_zapier.py
+++ b/posthog/cdp/templates/zapier/template_zapier.py
@@ -37,7 +37,7 @@
"hook": {
"id": "{source.url}",
"event": "{event}",
- "target": "https://hooks.zapier.com/{inputs.hook}",
+ "target": "https://hooks.zapier.com",
},
"data": {
"eventUuid": "{event.uuid}",
diff --git a/posthog/cdp/test/test_site_functions.py b/posthog/cdp/test/test_site_functions.py
index 9370cb7266740..c9821435d848c 100644
--- a/posthog/cdp/test/test_site_functions.py
+++ b/posthog/cdp/test/test_site_functions.py
@@ -1,3 +1,4 @@
+import json
import subprocess
import tempfile
from inline_snapshot import snapshot
@@ -45,6 +46,12 @@ def compile_and_run(self):
return result
+ def _execute_javascript(self, js) -> str:
+ with tempfile.NamedTemporaryFile(delete=False) as f:
+ f.write(js.encode("utf-8"))
+ f.flush()
+ return subprocess.check_output(["node", f.name]).decode("utf-8")
+
def test_get_transpiled_function_basic(self):
result = self.compile_and_run()
assert isinstance(result, str)
@@ -71,7 +78,7 @@ def test_get_transpiled_function_basic(self):
};return exports;})();
let processEvent = undefined;
if ('onEvent' in source) {
- processEvent = function processEvent(globals) {
+ processEvent = function processEvent(globals, posthog) {
if (!('onEvent' in source)) { 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 } };
@@ -97,9 +104,13 @@ def test_get_transpiled_function_basic(self):
callback(true);
}
- return {
- processEvent: processEvent
+ const response = {}
+
+ if (processEvent) {
+ response.processEvent = (globals) => processEvent(globals, posthog)
}
+
+ return response
}
return { init: init };
@@ -129,12 +140,12 @@ def test_get_transpiled_function_with_template_input(self):
assert '__getGlobal("person")' in result
def test_get_transpiled_function_with_filters(self):
- self.hog_function.hog = "export function onEvent(event) { console.log(event.event); }"
+ self.hog_function.hog = "export function onEvent(globals) { console.log(globals); }"
self.hog_function.filters = {"events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]}
result = self.compile_and_run()
- assert "console.log(event.event);" in result
+ assert "console.log(globals);" in result
assert "const filterMatches = " in result
assert '__getGlobal("event") == "$pageview"' in result
assert "const filterMatches = !!(!!((__getGlobal" in result
@@ -249,7 +260,7 @@ def test_get_transpiled_function_with_complex_filters(self):
action.steps = [{"event": "$pageview", "url": "https://example.com"}] # type: ignore
action.save()
- self.hog_function.hog = "export function onEvent(event) { console.log(event.event); }"
+ self.hog_function.hog = "export function onEvent(globals) { console.log(globals); }"
self.hog_function.filters = {
"events": [{"id": "$pageview", "name": "$pageview", "type": "events"}],
"actions": [{"id": str(action.pk), "name": "Test Action", "type": "actions"}],
@@ -258,7 +269,7 @@ def test_get_transpiled_function_with_complex_filters(self):
result = self.compile_and_run()
- assert "console.log(event.event);" in result
+ assert "console.log(globals);" in result
assert "const filterMatches = " in result
assert '__getGlobal("event") == "$pageview"' in result
assert "https://example.com" in result
@@ -283,3 +294,106 @@ def test_get_transpiled_function_with_mappings(self):
assert 'if (!!(!!((__getGlobal("event") == "$autocapture")))) {' in result
assert "const newInputs = structuredClone(inputs);" in result
assert 'newInputs["greeting"] = concat("Hallo, ", __getProperty' in result
+
+ def test_run_function_onload(self):
+ self.hog_function.hog = "export function onLoad({ inputs, posthog }) { console.log(inputs.message); }"
+ self.hog_function.filters = {"events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]}
+ self.hog_function.inputs = {"message": {"value": "Hello World {person.properties.name}"}}
+
+ result = self.compile_and_run()
+ assert "Hello World" in result
+
+ response = self._execute_javascript(
+ result
+ + "().init({ posthog: { get_property: () => ({name: 'Bob'}) }, callback: () => { console.log('Loaded') } })"
+ )
+ assert "Hello World Bob\nLoaded" == response.strip()
+
+ def test_run_function_onevent(self):
+ self.hog_function.hog = "export function onEvent({ inputs }) { console.log(inputs.message); }"
+ # self.hog_function.filters = {"events": [{"id": "$pageview", "name": "$pageview", "type": "events", "order": 0}]}
+ self.hog_function.inputs = {"message": {"value": "Hello World {event.properties.id}"}}
+ self.hog_function.mappings = [
+ {
+ "inputs": {"greeting": {"value": "Hallo, {person.properties.nonexistent_property}!"}},
+ "filters": {"events": [{"id": "$pageview", "name": "$pageview", "type": "events"}]},
+ }
+ ]
+
+ result = self.compile_and_run()
+ assert "Hello World" in result
+
+ globals = {
+ "event": {"event": "$pageview", "properties": {"id": "banana"}},
+ "groups": {},
+ "person": {"properties": {"name": "Bob"}},
+ }
+ response = self._execute_javascript(
+ result
+ + "().init({ posthog: { get_property: () => ({name: 'Bob'}) }, callback: () => { console.log('Loaded') } }).processEvent("
+ + json.dumps(globals)
+ + ")"
+ )
+ assert "Loaded\nHello World banana" == response.strip()
+
+ globals = {
+ "event": {"event": "$autocapture", "properties": {"id": "banana"}},
+ "groups": {},
+ "person": {"properties": {"name": "Bob"}},
+ }
+ response = self._execute_javascript(
+ result
+ + "().init({ posthog: { get_property: () => ({name: 'Bob'}) }, callback: () => { console.log('Loaded') } }).processEvent("
+ + json.dumps(globals)
+ + ")"
+ )
+ assert "Loaded" == response.strip()
+
+ def test_get_transpiled_function_with_ordered_inputs(self):
+ self.hog_function.hog = "export function onLoad() { console.log(inputs); }"
+ self.hog_function.inputs = {
+ "first": {"value": "I am first", "order": 0},
+ "second": {"value": "{person.properties.name}", "order": 1},
+ "third": {"value": "{event.properties.url}", "order": 2},
+ }
+
+ result = self.compile_and_run()
+
+ assert '"first": "I am first"' in result
+ idx_first = result.index('"first": "I am first"')
+ idx_second = result.index('inputs["second"] = getInputsKey("second");')
+ idx_third = result.index('inputs["third"] = getInputsKey("third");')
+
+ assert idx_first < idx_second < idx_third
+
+ def test_get_transpiled_function_without_order(self):
+ self.hog_function.hog = "export function onLoad() { console.log(inputs); }"
+ self.hog_function.inputs = {
+ "noOrder": {"value": "I have no order"},
+ "alsoNoOrder": {"value": "{person.properties.name}"},
+ "withOrder": {"value": "{event.properties.url}", "order": 10},
+ }
+
+ result = self.compile_and_run()
+
+ idx_noOrder = result.index('"noOrder": "I have no order"')
+ idx_alsoNoOrder = result.index('inputs["alsoNoOrder"] = getInputsKey("alsoNoOrder");')
+ idx_withOrder = result.index('inputs["withOrder"] = getInputsKey("withOrder");')
+
+ assert idx_noOrder < idx_alsoNoOrder < idx_withOrder
+
+ def test_get_transpiled_function_with_duplicate_orders(self):
+ self.hog_function.hog = "export function onLoad() { console.log(inputs); }"
+ self.hog_function.inputs = {
+ "alpha": {"value": "{person.properties.alpha}", "order": 1},
+ "beta": {"value": "{person.properties.beta}", "order": 1},
+ "gamma": {"value": "Just gamma", "order": 1},
+ }
+
+ result = self.compile_and_run()
+
+ idx_alpha = result.index('inputs["alpha"] = getInputsKey("alpha");')
+ idx_beta = result.index('inputs["beta"] = getInputsKey("beta");')
+ idx_gamma = result.index('"gamma": "Just gamma"')
+
+ assert idx_alpha is not None and idx_beta is not None and idx_gamma is not None
diff --git a/posthog/cdp/test/test_validation.py b/posthog/cdp/test/test_validation.py
index 90a41f8cca653..15f6dbb879cbd 100644
--- a/posthog/cdp/test/test_validation.py
+++ b/posthog/cdp/test/test_validation.py
@@ -85,6 +85,7 @@ def test_validate_inputs(self):
32,
"http://localhost:2080/0e02d917-563f-4050-9725-aad881b69937",
],
+ "order": 0, # Now that we have ordering, url should have some order assigned
},
"payload": {
"value": {
@@ -115,8 +116,12 @@ def test_validate_inputs(self):
2,
],
},
+ "order": 1,
+ },
+ "method": {
+ "value": "POST",
+ "order": 2,
},
- "method": {"value": "POST"},
"headers": {
"value": {"version": "v={event.properties.$lib_version}"},
"bytecode": {
@@ -138,6 +143,7 @@ def test_validate_inputs(self):
2,
]
},
+ "order": 3,
},
}
)
@@ -180,6 +186,109 @@ def test_validate_inputs_creates_bytecode_for_html(self):
3,
],
"value": '\n\n