diff --git a/frontend/src/lib/lemon-ui/LemonWidget/LemonWidget.tsx b/frontend/src/lib/lemon-ui/LemonWidget/LemonWidget.tsx index 8a02b8ac7ba2b..290e6717c1971 100644 --- a/frontend/src/lib/lemon-ui/LemonWidget/LemonWidget.tsx +++ b/frontend/src/lib/lemon-ui/LemonWidget/LemonWidget.tsx @@ -1,47 +1,26 @@ -import { useState } from 'react' import { LemonButton } from '../LemonButton' -import { IconClose, IconUnfoldLess, IconUnfoldMore } from '../icons' +import { IconClose } from '../icons' import './LemonWidget.scss' import clsx from 'clsx' export interface LemonWidgetProps { title: string - collapsible?: boolean onClose?: () => void actions?: React.ReactNode - children: React.ReactChild + children: React.ReactNode + className?: string } -export function LemonWidget({ title, collapsible = true, onClose, actions, children }: LemonWidgetProps): JSX.Element { - const [isExpanded, setIsExpanded] = useState(true) - +export function LemonWidget({ title, onClose, actions, children, className }: LemonWidgetProps): JSX.Element { return ( - +
- {collapsible ? ( - <> - setIsExpanded(!isExpanded)} - size="small" - status="primary-alt" - className="flex-1" - > - {title} - - setIsExpanded(!isExpanded)} - size="small" - icon={isExpanded ? : } - /> - - ) : ( - {title} - )} + {title} {actions} {onClose && } />}
- {isExpanded && {children}} + {children}
) } @@ -55,5 +34,5 @@ const Header = ({ children, className }: { children: React.ReactNode; className? } const Content = ({ children }: { children: React.ReactNode }): JSX.Element => { - return
{children}
+ return
{children}
} diff --git a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx index db3a26d62aba0..04236854e30f9 100644 --- a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx @@ -212,7 +212,7 @@ export function DataTableExport({ query }: DataTableExportProps): JSX.Element | ? [ startDownload(query, false)} actor={isPersonsNode(query.source) ? 'persons' : 'events'} limit={EXPORT_MAX_LIMIT} diff --git a/frontend/src/queries/nodes/DataTable/ExportWithConfirmation.tsx b/frontend/src/queries/nodes/DataTable/ExportWithConfirmation.tsx index 895de70414497..dccc0aeefdc1d 100644 --- a/frontend/src/queries/nodes/DataTable/ExportWithConfirmation.tsx +++ b/frontend/src/queries/nodes/DataTable/ExportWithConfirmation.tsx @@ -18,6 +18,7 @@ export function ExportWithConfirmation({ }: ExportWithConfirmationProps): JSX.Element { return ( diff --git a/frontend/src/scenes/batch_exports/utils.ts b/frontend/src/scenes/batch_exports/utils.ts index 4302f2dc65298..16ebf9d3176ef 100644 --- a/frontend/src/scenes/batch_exports/utils.ts +++ b/frontend/src/scenes/batch_exports/utils.ts @@ -4,6 +4,7 @@ export function intervalToFrequency(interval: BatchExportConfiguration['interval return { day: 'daily', hour: 'hourly', + 'every 5 minutes': 'every 5 minutes', }[interval] } diff --git a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx index f9c97954e9a3f..c1d0042c319b7 100644 --- a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx @@ -32,8 +32,8 @@ import { posthogNodePasteRule, useSyncedAttributes } from './utils' import { NotebookNodeAttributes, NotebookNodeViewProps, - NotebookNodeWidget, CustomNotebookNodeAttributes, + NotebookNodeSettings, } from '../Notebook/utils' export interface NodeWrapperProps { @@ -54,7 +54,7 @@ export interface NodeWrapperProps { autoHideMetadata?: boolean /** Expand the node if the component is clicked */ expandOnClick?: boolean - widgets?: NotebookNodeWidget[] + settings?: NotebookNodeSettings } function NodeWrapper({ @@ -74,7 +74,7 @@ function NodeWrapper({ getPos, attributes, updateAttributes, - widgets = [], + settings = null, }: NodeWrapperProps & NotebookNodeViewProps): JSX.Element { const mountedNotebookLogic = useMountedLogic(notebookLogic) const { isEditable, editingNodeId } = useValues(notebookLogic) @@ -91,7 +91,7 @@ function NodeWrapper({ notebookLogic: mountedNotebookLogic, getPos, resizeable: resizeableOrGenerator, - widgets, + settings, startExpanded, } const nodeLogic = useMountedLogic(notebookNodeLogic(nodeLogicProps)) @@ -183,7 +183,7 @@ function NodeWrapper({ {isEditable ? ( <> - {widgets.length > 0 ? ( + {settings ? ( setEditingNodeId( @@ -259,7 +259,7 @@ export type CreatePostHogWidgetNodeOptions Promise | T | null | undefined } attributes: Record> - widgets?: NotebookNodeWidget[] + settings?: NotebookNodeSettings serializedText?: (attributes: NotebookNodeAttributes) => string } diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx index 70700c8228a86..36dbdd4d79d1b 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx @@ -166,13 +166,7 @@ export const NotebookNodePlaylist = createPostHogWidgetNode): JSONContent { diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeQuery.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeQuery.tsx index f9db62d891313..9548ca06bc4fc 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeQuery.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeQuery.tsx @@ -205,12 +205,7 @@ export const NotebookNodeQuery = createPostHogWidgetNode attrs.query.kind === NodeKind.SavedInsightNode ? urls.insightView(attrs.query.shortId) : undefined, - widgets: [ - { - key: 'settings', - Component: Settings, - }, - ], + settings: Settings, pasteOptions: { find: urls.insightView('(.+)' as InsightShortId), getAttributes: async (match) => { diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeRecording.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeRecording.tsx index 61e26de5e52d1..8652a5df8d032 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeRecording.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeRecording.tsx @@ -156,13 +156,7 @@ export const NotebookNodeRecording = createPostHogWidgetNode { return attrs.id }, diff --git a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts index d4c637fed3923..f1cc6867a8c66 100644 --- a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts +++ b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts @@ -23,7 +23,7 @@ import { NotebookNodeAction, NotebookNodeAttributeProperties, NotebookNodeAttributes, - NotebookNodeWidget, + NotebookNodeSettings, } from '../Notebook/utils' import { NotebookNodeType } from '~/types' import posthog from 'posthog-js' @@ -36,7 +36,7 @@ export type NotebookNodeLogicProps = { notebookLogic: BuiltLogic getPos: () => number resizeable: boolean | ((attributes: CustomNotebookNodeAttributes) => boolean) - widgets: NotebookNodeWidget[] + settings: NotebookNodeSettings messageListeners?: NotebookNodeMessagesListeners startExpanded: boolean } & NotebookNodeAttributeProperties @@ -116,7 +116,7 @@ export const notebookNodeLogic = kea([ selectors({ notebookLogic: [(_, p) => [p.notebookLogic], (notebookLogic) => notebookLogic], nodeAttributes: [(_, p) => [p.attributes], (nodeAttributes) => nodeAttributes], - widgets: [(_, p) => [p.widgets], (widgets) => widgets], + settings: [(_, p) => [p.settings], (settings) => settings], sendMessage: [ (s) => [s.messageListeners], diff --git a/frontend/src/scenes/notebooks/Notebook/Notebook.scss b/frontend/src/scenes/notebooks/Notebook/Notebook.scss index d3f39f3a727e6..ea68bfdbd7cf2 100644 --- a/frontend/src/scenes/notebooks/Notebook/Notebook.scss +++ b/frontend/src/scenes/notebooks/Notebook/Notebook.scss @@ -181,8 +181,8 @@ } } - .NotebookNodeSettings__widgets { - &__content { + .NotebookSidebar__widget { + > .LemonWidget__content { max-height: calc(100vh - 220px); overflow: auto; } diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx index 66c123ae3819b..5837493dc6c91 100644 --- a/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx +++ b/frontend/src/scenes/notebooks/Notebook/NotebookHistory.tsx @@ -106,7 +106,7 @@ export function NotebookHistory(): JSX.Element { } return ( - setShowHistory(false)}> + setShowHistory(false)}>

Below is the history of all persisted changes. You can select any version to view how it was at that diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookPopover.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookPopover.tsx index 1e63357eb209a..aed37034404a6 100644 --- a/frontend/src/scenes/notebooks/Notebook/NotebookPopover.tsx +++ b/frontend/src/scenes/notebooks/Notebook/NotebookPopover.tsx @@ -5,7 +5,7 @@ import { Notebook } from './Notebook' import { notebookPopoverLogic } from 'scenes/notebooks/Notebook/notebookPopoverLogic' import { LemonButton } from '@posthog/lemon-ui' import { IconFullScreen, IconChevronRight, IconLink } from 'lib/lemon-ui/icons' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { useKeyboardHotkeys } from 'lib/hooks/useKeyboardHotkeys' import { NotebookListMini } from './NotebookListMini' import { notebooksModel } from '~/models/notebooksModel' @@ -13,6 +13,7 @@ import { NotebookExpandButton, NotebookSyncInfo } from './NotebookMeta' import { notebookLogic } from './notebookLogic' import { urls } from 'scenes/urls' import { NotebookPopoverDropzone } from './NotebookPopoverDropzone' +import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' export function NotebookPopoverCard(): JSX.Element | null { const { visibility, shownAtLeastOnce, fullScreen, selectedNotebook, initialAutofocus, droppedResource } = @@ -26,8 +27,16 @@ export function NotebookPopoverCard(): JSX.Element | null { if (droppedResource) { return null } + + const { ref, size } = useResizeBreakpoints({ + 0: 'small', + 832: 'medium', + }) + + const contentWidthHasEffect = useMemo(() => fullScreen && size === 'medium', [fullScreen, size]) + return ( -

+
- + {contentWidthHasEffect && } { ) } -const Widgets = ({ logic }: { logic: BuiltLogic }): JSX.Element | null => { +const Widgets = ({ logic }: { logic: BuiltLogic }): JSX.Element => { const { setEditingNodeId } = useActions(notebookLogic) - const { widgets, nodeAttributes } = useValues(logic) + const { settings: Settings, nodeAttributes } = useValues(logic) const { updateAttributes, selectNode } = useActions(logic) return ( -
- {widgets.map(({ key, label, Component }) => ( - - } - size="small" - status="primary" - onClick={() => selectNode()} - /> - setEditingNodeId(null)}> - Done - - - } - > -
- -
-
- ))} -
+ + } size="small" status="primary" onClick={() => selectNode()} /> + setEditingNodeId(null)}> + Done + + + } + > + {Settings ? : null} + ) } diff --git a/frontend/src/scenes/notebooks/Notebook/utils.ts b/frontend/src/scenes/notebooks/Notebook/utils.ts index e0ad5b2396566..d244540fe5673 100644 --- a/frontend/src/scenes/notebooks/Notebook/utils.ts +++ b/frontend/src/scenes/notebooks/Notebook/utils.ts @@ -46,12 +46,9 @@ export type NotebookNodeViewProps = Omit node: NotebookNode } -export type NotebookNodeWidget = { - key: string - label?: string +export type NotebookNodeSettings = // using 'any' here shouldn't be necessary but, I couldn't figure out how to set a generic on the notebookNodeLogic props - Component: ({ attributes, updateAttributes }: NotebookNodeAttributeProperties) => JSX.Element -} + (({ attributes, updateAttributes }: NotebookNodeAttributeProperties) => JSX.Element) | null export type NotebookNodeAction = Pick & { text: string diff --git a/frontend/src/scenes/onboarding/Onboarding.tsx b/frontend/src/scenes/onboarding/Onboarding.tsx index c3520d90bea01..cb1c980f06923 100644 --- a/frontend/src/scenes/onboarding/Onboarding.tsx +++ b/frontend/src/scenes/onboarding/Onboarding.tsx @@ -4,15 +4,13 @@ import { useEffect, useState } from 'react' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { FEATURE_FLAGS } from 'lib/constants' import { urls } from 'scenes/urls' -import { onboardingLogic } from './onboardingLogic' +import { OnboardingStepKey, onboardingLogic } from './onboardingLogic' import { SDKs } from './sdks/SDKs' -import { OnboardingProductIntro } from './OnboardingProductIntro' import { ProductKey } from '~/types' import { ProductAnalyticsSDKInstructions } from './sdks/product-analytics/ProductAnalyticsSDKInstructions' import { SessionReplaySDKInstructions } from './sdks/session-replay/SessionReplaySDKInstructions' import { OnboardingBillingStep } from './OnboardingBillingStep' import { OnboardingOtherProductsStep } from './OnboardingOtherProductsStep' -import { teamLogic } from 'scenes/teamLogic' import { OnboardingVerificationStep } from './OnboardingVerificationStep' import { FeatureFlagsSDKInstructions } from './sdks/feature-flags/FeatureFlagsSDKInstructions' @@ -24,8 +22,8 @@ export const scene: SceneExport = { /** * Wrapper for custom onboarding content. This automatically includes the product intro and billing step. */ -const OnboardingWrapper = ({ children, onStart }: { children: React.ReactNode; onStart?: () => void }): JSX.Element => { - const { currentOnboardingStepNumber, shouldShowBillingStep } = useValues(onboardingLogic) +const OnboardingWrapper = ({ children }: { children: React.ReactNode }): JSX.Element => { + const { currentOnboardingStep, shouldShowBillingStep, shouldShowOtherProductsStep } = useValues(onboardingLogic) const { setAllOnboardingSteps } = useActions(onboardingLogic) const { product } = useValues(onboardingLogic) const [allSteps, setAllSteps] = useState([]) @@ -46,49 +44,50 @@ const OnboardingWrapper = ({ children, onStart }: { children: React.ReactNode; o } const createAllSteps = (): void => { - const ProductIntro = - const OtherProductsStep = let steps = [] if (Array.isArray(children)) { - steps = [ProductIntro, ...children] + steps = [...children] } else { - steps = [ProductIntro, children as JSX.Element] + steps = [children as JSX.Element] } if (shouldShowBillingStep) { - const BillingStep = + const BillingStep = steps = [...steps, BillingStep] } - steps = [...steps, OtherProductsStep] + if (shouldShowOtherProductsStep) { + const OtherProductsStep = + steps = [...steps, OtherProductsStep] + } setAllSteps(steps) } - return (allSteps[currentOnboardingStepNumber - 1] as JSX.Element) || <> + return (currentOnboardingStep as JSX.Element) || <> } const ProductAnalyticsOnboarding = (): JSX.Element => { return ( - - + + ) } const SessionReplayOnboarding = (): JSX.Element => { - const { updateCurrentTeam } = useActions(teamLogic) return ( - { - updateCurrentTeam({ - session_recording_opt_in: true, - capture_console_log_opt_in: true, - capture_performance_opt_in: true, - }) - }} - > + ) @@ -100,6 +99,7 @@ const FeatureFlagsOnboarding = (): JSX.Element => { usersAction="loading flags" sdkInstructionMap={FeatureFlagsSDKInstructions} subtitle="Choose the framework where you want to use feature flags, or use our all-purpose JavaScript library. If you already have the snippet installed, you can skip this step!" + stepKey={OnboardingStepKey.SDKS} /> ) diff --git a/frontend/src/scenes/onboarding/OnboardingBillingStep.tsx b/frontend/src/scenes/onboarding/OnboardingBillingStep.tsx index 6daba12e33fe6..c6901e9d7b829 100644 --- a/frontend/src/scenes/onboarding/OnboardingBillingStep.tsx +++ b/frontend/src/scenes/onboarding/OnboardingBillingStep.tsx @@ -2,7 +2,7 @@ import { OnboardingStep } from './OnboardingStep' import { PlanComparison } from 'scenes/billing/PlanComparison' import { useActions, useValues } from 'kea' import { billingLogic } from 'scenes/billing/billingLogic' -import { onboardingLogic } from './onboardingLogic' +import { OnboardingStepKey, onboardingLogic } from './onboardingLogic' import { BillingProductV2Type } from '~/types' import { Spinner } from 'lib/lemon-ui/Spinner' import { BillingHero } from 'scenes/billing/BillingHero' @@ -13,7 +13,13 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { IconCheckCircleOutline } from 'lib/lemon-ui/icons' import { StarHog } from 'lib/components/hedgehogs' -export const OnboardingBillingStep = ({ product }: { product: BillingProductV2Type }): JSX.Element => { +export const OnboardingBillingStep = ({ + product, + stepKey = OnboardingStepKey.BILLING, +}: { + product: BillingProductV2Type + stepKey?: OnboardingStepKey +}): JSX.Element => { const { billing, redirectPath } = useValues(billingLogic) const { productKey } = useValues(onboardingLogic) const { currentAndUpgradePlans } = useValues(billingProductLogic({ product })) @@ -24,6 +30,7 @@ export const OnboardingBillingStep = ({ product }: { product: BillingProductV2Ty { +export const OnboardingOtherProductsStep = ({ + stepKey = OnboardingStepKey.OTHER_PRODUCTS, +}: { + stepKey?: OnboardingStepKey +}): JSX.Element => { const { product, suggestedProducts } = useValues(onboardingLogic) const { completeOnboarding } = useActions(onboardingLogic) - if (suggestedProducts.length === 0) { - completeOnboarding() - } return ( { subtitle="The magic in PostHog is having everyting all in one place. Get started with our other products to unlock your product and data superpowers." showSkip continueOverride={<>} + stepKey={stepKey} >
{suggestedProducts?.map((suggestedProduct) => ( diff --git a/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx b/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx deleted file mode 100644 index 3119e70f76106..0000000000000 --- a/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useActions, useValues } from 'kea' -import { LemonButton, Link } from '@posthog/lemon-ui' -import { onboardingLogic } from './onboardingLogic' -import { billingProductLogic } from 'scenes/billing/billingProductLogic' -import { convertLargeNumberToWords } from 'scenes/billing/billing-utils' -import { BillingProductV2Type } from '~/types' -import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' -import { ProductPricingModal } from 'scenes/billing/ProductPricingModal' -import { IconArrowLeft, IconCheckCircleOutline, IconOpenInNew } from 'lib/lemon-ui/icons' -import { urls } from 'scenes/urls' -import { PlanComparisonModal } from 'scenes/billing/PlanComparison' - -export const OnboardingProductIntro = ({ - product, - onStart, -}: { - product: BillingProductV2Type - onStart?: () => void -}): JSX.Element => { - const { currentAndUpgradePlans, isPricingModalOpen, isPlanComparisonModalOpen } = useValues( - billingProductLogic({ product }) - ) - const { toggleIsPricingModalOpen, toggleIsPlanComparisonModalOpen } = useActions(billingProductLogic({ product })) - const { setCurrentOnboardingStepNumber } = useActions(onboardingLogic) - const { currentOnboardingStepNumber } = useValues(onboardingLogic) - - const pricingBenefits = [ - 'Only pay for what you use', - 'Control spend with billing limits as low as $0/mo', - 'Generous free volume every month, forever', - ] - - const productWebsiteKey = product.type.replace('_', '-') - const communityUrl = 'https://posthog.com/questions/topic/' + productWebsiteKey - const tutorialsUrl = 'https://posthog.com/tutorials/categories/' + productWebsiteKey - const productPageUrl = 'https://posthog.com/' + productWebsiteKey - const productImageUrl = `https://posthog.com/images/product/${productWebsiteKey}-product.png` - - const upgradePlan = currentAndUpgradePlans?.upgradePlan - const plan = upgradePlan ? upgradePlan : currentAndUpgradePlans?.currentPlan - const freePlan = currentAndUpgradePlans?.downgradePlan || currentAndUpgradePlans?.currentPlan - - return ( -
-
-
-
-
- } - type="tertiary" - status="muted" - noPadding - size="small" - > - All products - -
-

{product.name}

-

{product.description}

-
- { - onStart && onStart() - setCurrentOnboardingStepNumber(currentOnboardingStepNumber + 1) - }} - > - Get started - - {product.docs_url && ( - - Learn more - - )} -
-
-
- -
-
-
-
-
-

Features

-
- {plan?.features?.map((feature, i) => ( -
  • -
    - -
    -
    -

    {feature.name}

    -

    {feature.description}

    -
    -
  • - ))} -
    -
    -
    - -

    Pricing

    - {plan?.tiers?.[0].unit_amount_usd && parseInt(plan?.tiers?.[0].unit_amount_usd) === 0 && ( -

    - - First {convertLargeNumberToWords(plan?.tiers?.[0].up_to, null)} {product.unit}s free - - , then ${plan?.tiers?.[1].unit_amount_usd} - /{product.unit}.{' '} - { - toggleIsPricingModalOpen() - }} - > - Volume discounts - {' '} - after {convertLargeNumberToWords(plan?.tiers?.[1].up_to, null)}/mo. -

    - )} -
      - {pricingBenefits.map((benefit, i) => ( -
    • - - {benefit} -
    • - ))} -
    - {!product.subscribed && freePlan.free_allocation && ( -

    - Or stick with our generous free plan and get{' '} - {convertLargeNumberToWords(freePlan.free_allocation, null)} {product.unit}s free every - month, forever.{' '} - { - toggleIsPlanComparisonModalOpen() - }} - > - View plan comparison. - - toggleIsPlanComparisonModalOpen()} - /> -

    - )} -
    - -

    Resources

    - {product.docs_url && ( -

    - - Documentation - -

    - )} -

    - - Community forum - -

    -

    - - Tutorials - -

    -
    -
    -
    - -
    - ) -} diff --git a/frontend/src/scenes/onboarding/OnboardingStep.tsx b/frontend/src/scenes/onboarding/OnboardingStep.tsx index b32c9fdc13a3d..a4bfdd92cbf42 100644 --- a/frontend/src/scenes/onboarding/OnboardingStep.tsx +++ b/frontend/src/scenes/onboarding/OnboardingStep.tsx @@ -1,10 +1,13 @@ import { LemonButton } from '@posthog/lemon-ui' import { BridgePage } from 'lib/components/BridgePage/BridgePage' -import { onboardingLogic } from './onboardingLogic' +import { OnboardingStepKey, onboardingLogic } from './onboardingLogic' import { useActions, useValues } from 'kea' import { IconArrowLeft, IconArrowRight } from 'lib/lemon-ui/icons' +import { router } from 'kea-router' +import { urls } from 'scenes/urls' export const OnboardingStep = ({ + stepKey, title, subtitle, children, @@ -12,6 +15,7 @@ export const OnboardingStep = ({ onSkip, continueOverride, }: { + stepKey: OnboardingStepKey title: string subtitle?: string children: React.ReactNode @@ -19,9 +23,12 @@ export const OnboardingStep = ({ onSkip?: () => void continueOverride?: JSX.Element }): JSX.Element => { - const { currentOnboardingStepNumber, totalOnboardingSteps } = useValues(onboardingLogic) - const { setCurrentOnboardingStepNumber, completeOnboarding } = useActions(onboardingLogic) - const isLastStep = currentOnboardingStepNumber == totalOnboardingSteps + const { hasNextStep, hasPreviousStep } = useValues(onboardingLogic) + const { completeOnboarding, goToNextStep, goToPreviousStep } = useActions(onboardingLogic) + if (!stepKey) { + throw new Error('stepKey is required in any OnboardingStep') + } + return ( 1 && ( -
    - } - onClick={() => setCurrentOnboardingStepNumber(currentOnboardingStepNumber - 1)} - > - Back - -
    - ) +
    + } + onClick={() => (hasPreviousStep ? goToPreviousStep() : router.actions.push(urls.products()))} + > + Back + +
    } >
    @@ -51,13 +56,11 @@ export const OnboardingStep = ({ type="tertiary" onClick={() => { onSkip && onSkip() - isLastStep - ? completeOnboarding() - : setCurrentOnboardingStepNumber(currentOnboardingStepNumber + 1) + !hasNextStep ? completeOnboarding() : goToNextStep() }} status="muted" > - Skip {isLastStep ? 'and finish' : 'for now'} + Skip {!hasNextStep ? 'and finish' : 'for now'} )} {continueOverride ? ( @@ -65,14 +68,10 @@ export const OnboardingStep = ({ ) : ( - currentOnboardingStepNumber == totalOnboardingSteps - ? completeOnboarding() - : setCurrentOnboardingStepNumber(currentOnboardingStepNumber + 1) - } - sideIcon={currentOnboardingStepNumber !== totalOnboardingSteps ? : null} + onClick={() => (!hasNextStep ? completeOnboarding() : goToNextStep())} + sideIcon={hasNextStep ? : null} > - {currentOnboardingStepNumber == totalOnboardingSteps ? 'Finish' : 'Continue'} + {!hasNextStep ? 'Finish' : 'Continue'} )}
    diff --git a/frontend/src/scenes/onboarding/OnboardingVerificationStep.tsx b/frontend/src/scenes/onboarding/OnboardingVerificationStep.tsx index 7b55f2f139bd2..11ba1dc4fd065 100644 --- a/frontend/src/scenes/onboarding/OnboardingVerificationStep.tsx +++ b/frontend/src/scenes/onboarding/OnboardingVerificationStep.tsx @@ -6,13 +6,16 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { useInterval } from 'lib/hooks/useInterval' import { BlushingHog } from 'lib/components/hedgehogs' import { capitalizeFirstLetter } from 'lib/utils' +import { OnboardingStepKey } from './onboardingLogic' export const OnboardingVerificationStep = ({ listeningForName, teamPropertyToVerify, + stepKey = OnboardingStepKey.VERIFY, }: { listeningForName: string teamPropertyToVerify: string + stepKey?: OnboardingStepKey }): JSX.Element => { const { loadCurrentTeam } = useActions(teamLogic) const { currentTeam } = useValues(teamLogic) @@ -29,6 +32,7 @@ export const OnboardingVerificationStep = ({ title={`Listening for ${listeningForName}s...`} subtitle={`Once you have integrated the snippet, we will verify the ${listeningForName} was properly received. It can take up to 2 minutes to recieve the ${listeningForName}.`} showSkip={true} + stepKey={stepKey} onSkip={() => { reportIngestionContinueWithoutVerifying() }} @@ -42,6 +46,7 @@ export const OnboardingVerificationStep = ({
    diff --git a/frontend/src/scenes/onboarding/onboardingLogic.tsx b/frontend/src/scenes/onboarding/onboardingLogic.tsx index 2e980cf9f3574..a83f33d5b51fd 100644 --- a/frontend/src/scenes/onboarding/onboardingLogic.tsx +++ b/frontend/src/scenes/onboarding/onboardingLogic.tsx @@ -21,17 +21,9 @@ export enum OnboardingStepKey { VERIFY = 'verify', } -export type OnboardingStepMap = Record - -const onboardingStepMap: OnboardingStepMap = { - [OnboardingStepKey.PRODUCT_INTRO]: 'OnboardingProductIntro', - [OnboardingStepKey.SDKS]: 'SDKs', - [OnboardingStepKey.BILLING]: 'OnboardingBillingStep', - [OnboardingStepKey.OTHER_PRODUCTS]: 'OnboardingOtherProductsStep', - [OnboardingStepKey.VERIFY]: 'OnboardingVerificationStep', -} - -export type AllOnboardingSteps = JSX.Element[] +// These types have to be set like this, so that kea typegen is happy +export type AllOnboardingSteps = OnboardingStep[] +export type OnboardingStep = JSX.Element export const getProductUri = (productKey: ProductKey): string => { switch (productKey) { @@ -56,11 +48,13 @@ export const onboardingLogic = kea({ actions: { setProduct: (product: BillingProductV2Type | null) => ({ product }), setProductKey: (productKey: string | null) => ({ productKey }), - setCurrentOnboardingStepNumber: (currentOnboardingStepNumber: number) => ({ currentOnboardingStepNumber }), completeOnboarding: (nextProductKey?: string) => ({ nextProductKey }), setAllOnboardingSteps: (allOnboardingSteps: AllOnboardingSteps) => ({ allOnboardingSteps }), setStepKey: (stepKey: string) => ({ stepKey }), setSubscribedDuringOnboarding: (subscribedDuringOnboarding: boolean) => ({ subscribedDuringOnboarding }), + goToNextStep: true, + goToPreviousStep: true, + resetStepKey: true, }, reducers: () => ({ productKey: [ @@ -75,12 +69,6 @@ export const onboardingLogic = kea({ setProduct: (_, { product }) => product, }, ], - currentOnboardingStepNumber: [ - 1, - { - setCurrentOnboardingStepNumber: (_, { currentOnboardingStepNumber }) => currentOnboardingStepNumber, - }, - ], allOnboardingSteps: [ [] as AllOnboardingSteps, { @@ -93,7 +81,7 @@ export const onboardingLogic = kea({ setStepKey: (_, { stepKey }) => stepKey, }, ], - onCompleteOnbardingRedirectUrl: [ + onCompleteOnboardingRedirectUrl: [ urls.default() as string, { setProductKey: (_, { productKey }) => { @@ -113,6 +101,25 @@ export const onboardingLogic = kea({ (s) => [s.allOnboardingSteps], (allOnboardingSteps: AllOnboardingSteps) => allOnboardingSteps.length, ], + currentOnboardingStep: [ + (s) => [s.allOnboardingSteps, s.stepKey], + (allOnboardingSteps: AllOnboardingSteps, stepKey: OnboardingStepKey): OnboardingStep | null => + allOnboardingSteps.find((step) => step.props.stepKey === stepKey) || null, + ], + hasNextStep: [ + (s) => [s.allOnboardingSteps, s.stepKey], + (allOnboardingSteps: AllOnboardingSteps, stepKey: OnboardingStepKey) => { + const currentStepIndex = allOnboardingSteps.findIndex((step) => step.props.stepKey === stepKey) + return currentStepIndex < allOnboardingSteps.length - 1 + }, + ], + hasPreviousStep: [ + (s) => [s.allOnboardingSteps, s.stepKey], + (allOnboardingSteps: AllOnboardingSteps, stepKey: OnboardingStepKey) => { + const currentStepIndex = allOnboardingSteps.findIndex((step) => step.props.stepKey === stepKey) + return currentStepIndex > 0 + }, + ], shouldShowBillingStep: [ (s) => [s.product, s.subscribedDuringOnboarding], (product: BillingProductV2Type | null, subscribedDuringOnboarding: boolean) => { @@ -120,6 +127,10 @@ export const onboardingLogic = kea({ return !product?.subscribed || !hasAllAddons || subscribedDuringOnboarding }, ], + shouldShowOtherProductsStep: [ + (s) => [s.suggestedProducts], + (suggestedProducts: BillingProductV2Type[]) => suggestedProducts.length > 0, + ], suggestedProducts: [ (s) => [s.billing, s.product, s.currentTeam], (billing, product, currentTeam) => @@ -131,6 +142,12 @@ export const onboardingLogic = kea({ !currentTeam?.has_completed_onboarding_for?.[p.type] ) || [], ], + isStepKeyInvalid: [ + (s) => [s.stepKey, s.allOnboardingSteps, s.currentOnboardingStep], + (stepKey: string, allOnboardingSteps: AllOnboardingSteps, currentOnboardingStep: React.ReactNode | null) => + (stepKey && allOnboardingSteps.length > 0 && !currentOnboardingStep) || + (!stepKey && allOnboardingSteps.length > 0), + ], }, listeners: ({ actions, values }) => ({ loadBillingSuccess: () => { @@ -175,74 +192,53 @@ export const onboardingLogic = kea({ }) } }, - setAllOnboardingSteps: ({ allOnboardingSteps }) => { - // once we have the onboarding steps we need to make sure the step key is valid, - // and if so use it to set the step number. if not valid, remove it from the state. - // valid step keys are either numbers (used for unnamed steps) or keys from the onboardingStepMap. - // if it's a number, we try to convert it to a named step key using the onboardingStepMap. - let stepKey = values.stepKey - if (values.stepKey) { - if (parseInt(values.stepKey) > 0) { - // try to convert the step number to a step key - const stepName = allOnboardingSteps[parseInt(values.stepKey) - 1]?.type?.name - const newStepKey = Object.keys(onboardingStepMap).find((key) => onboardingStepMap[key] === stepName) - if (stepName && stepKey) { - stepKey = newStepKey || stepKey - actions.setStepKey(stepKey) - } - } - if (stepKey in onboardingStepMap) { - const stepIndex = allOnboardingSteps - .map((step) => step.type.name) - .indexOf(onboardingStepMap[stepKey as OnboardingStepKey]) - if (stepIndex > -1) { - actions.setCurrentOnboardingStepNumber(stepIndex + 1) - } else { - actions.setStepKey('') - actions.setCurrentOnboardingStepNumber(1) - } - } else if ( - // if it's a number, just use that and set the correct onboarding step number - parseInt(stepKey) > 1 && - allOnboardingSteps.length > 0 && - allOnboardingSteps[parseInt(stepKey) - 1] - ) { - actions.setCurrentOnboardingStepNumber(parseInt(stepKey)) - } + setAllOnboardingSteps: () => { + if (values.isStepKeyInvalid) { + actions.resetStepKey() } }, - setStepKey: ({ stepKey }) => { - // if the step key is invalid (doesn't exist in the onboardingStepMap or the allOnboardingSteps array) - // remove it from the state. Numeric step keys are also allowed, as long as they are a valid - // index for the allOnboardingSteps array. - if ( - stepKey && - values.allOnboardingSteps.length > 0 && - (!values.allOnboardingSteps.find( - (step) => step.type.name === onboardingStepMap[stepKey as OnboardingStepKey] - ) || - !values.allOnboardingSteps[parseInt(stepKey) - 1]) - ) { - actions.setStepKey('') + setStepKey: () => { + if (values.isStepKeyInvalid) { + actions.resetStepKey() } }, + resetStepKey: () => { + actions.setStepKey(values.allOnboardingSteps[0].props.stepKey) + }, }), actionToUrl: ({ values }) => ({ - setCurrentOnboardingStepNumber: () => { - // when the current step number changes, update the url to reflect the new step - const stepName = values.allOnboardingSteps[values.currentOnboardingStepNumber - 1]?.type?.name - const stepKey = - Object.keys(onboardingStepMap).find((key) => onboardingStepMap[key] === stepName) || - values.currentOnboardingStepNumber.toString() + setStepKey: ({ stepKey }) => { if (stepKey) { return [`/onboarding/${values.productKey}`, { step: stepKey }] } else { return [`/onboarding/${values.productKey}`] } }, + goToNextStep: () => { + const currentStepIndex = values.allOnboardingSteps.findIndex( + (step) => step.props.stepKey === values.stepKey + ) + const nextStep = values.allOnboardingSteps[currentStepIndex + 1] + if (nextStep) { + return [`/onboarding/${values.productKey}`, { step: nextStep.props.stepKey }] + } else { + return [`/onboarding/${values.productKey}`] + } + }, + goToPreviousStep: () => { + const currentStepIndex = values.allOnboardingSteps.findIndex( + (step) => step.props.stepKey === values.stepKey + ) + const previousStep = values.allOnboardingSteps[currentStepIndex - 1] + if (previousStep) { + return [`/onboarding/${values.productKey}`, { step: previousStep.props.stepKey }] + } else { + return [`/onboarding/${values.productKey}`] + } + }, updateCurrentTeamSuccess(val) { if (values.productKey && val.payload?.has_completed_onboarding_for?.[values.productKey]) { - return [values.onCompleteOnbardingRedirectUrl] + return [values.onCompleteOnboardingRedirectUrl] } }, }), @@ -258,10 +254,10 @@ export const onboardingLogic = kea({ if (productKey !== values.productKey) { actions.setProductKey(productKey) } - if (step && (step in onboardingStepMap || parseInt(step) > 0)) { + if (step) { actions.setStepKey(step) } else { - actions.setCurrentOnboardingStepNumber(1) + actions.resetStepKey() } }, }), diff --git a/frontend/src/scenes/onboarding/sdks/SDKs.tsx b/frontend/src/scenes/onboarding/sdks/SDKs.tsx index 9ac4884dd5d40..fda0e0d52e486 100644 --- a/frontend/src/scenes/onboarding/sdks/SDKs.tsx +++ b/frontend/src/scenes/onboarding/sdks/SDKs.tsx @@ -3,7 +3,7 @@ import { sdksLogic } from './sdksLogic' import { useActions, useValues } from 'kea' import { OnboardingStep } from '../OnboardingStep' import { SDKSnippet } from './SDKSnippet' -import { onboardingLogic } from '../onboardingLogic' +import { OnboardingStepKey, onboardingLogic } from '../onboardingLogic' import { useEffect } from 'react' import React from 'react' import { SDKInstructionsMap } from '~/types' @@ -13,10 +13,12 @@ export function SDKs({ usersAction, sdkInstructionMap, subtitle, + stepKey = OnboardingStepKey.SDKS, }: { usersAction?: string sdkInstructionMap: SDKInstructionsMap subtitle?: string + stepKey?: OnboardingStepKey }): JSX.Element { const { setSourceFilter, setSelectedSDK, setAvailableSDKInstructionsMap } = useActions(sdksLogic) const { sourceFilter, sdks, selectedSDK, sourceOptions, showSourceOptionsSelect } = useValues(sdksLogic) @@ -30,6 +32,7 @@ export function SDKs({
    diff --git a/frontend/src/scenes/products/Products.tsx b/frontend/src/scenes/products/Products.tsx index 922665ecdf730..0ed69a30e48b0 100644 --- a/frontend/src/scenes/products/Products.tsx +++ b/frontend/src/scenes/products/Products.tsx @@ -13,7 +13,7 @@ import { Spinner } from 'lib/lemon-ui/Spinner' import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' import { router } from 'kea-router' import { getProductUri } from 'scenes/onboarding/onboardingLogic' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { productsLogic } from './productsLogic' export const scene: SceneExport = { component: Products, @@ -29,7 +29,7 @@ function OnboardingCompletedButton({ onboardingUrl: string productKey: ProductKey }): JSX.Element { - const { reportOnboardingProductSelected } = useActions(eventUsageLogic) + const { onSelectProduct } = useActions(productsLogic) return ( <> @@ -39,7 +39,7 @@ function OnboardingCompletedButton({ type="tertiary" status="muted" onClick={() => { - reportOnboardingProductSelected(productKey) + onSelectProduct(productKey) router.actions.push(onboardingUrl) }} > @@ -50,12 +50,12 @@ function OnboardingCompletedButton({ } function OnboardingNotCompletedButton({ url, productKey }: { url: string; productKey: ProductKey }): JSX.Element { - const { reportOnboardingProductSelected } = useActions(eventUsageLogic) + const { onSelectProduct } = useActions(productsLogic) return ( { - reportOnboardingProductSelected(productKey) + onSelectProduct(productKey) router.actions.push(url) }} > diff --git a/frontend/src/scenes/products/productsLogic.tsx b/frontend/src/scenes/products/productsLogic.tsx new file mode 100644 index 0000000000000..5c199fd3f3fc1 --- /dev/null +++ b/frontend/src/scenes/products/productsLogic.tsx @@ -0,0 +1,34 @@ +import { kea } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { ProductKey } from '~/types' + +import type { productsLogicType } from './productsLogicType' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' + +export const productsLogic = kea({ + path: () => ['scenes', 'products', 'productsLogic'], + actions: () => ({ + onSelectProduct: (product: ProductKey) => ({ product }), + }), + listeners: () => ({ + onSelectProduct: ({ product }) => { + eventUsageLogic.actions.reportOnboardingProductSelected(product) + + switch (product) { + case ProductKey.PRODUCT_ANALYTICS: + return + case ProductKey.SESSION_REPLAY: + teamLogic.actions.updateCurrentTeam({ + session_recording_opt_in: true, + capture_console_log_opt_in: true, + capture_performance_opt_in: true, + }) + return + case ProductKey.FEATURE_FLAGS: + return + default: + return + } + }, + }), +}) diff --git a/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts b/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts index 42ec60f4642d5..ff68b6cea6a50 100644 --- a/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts +++ b/frontend/src/scenes/web-analytics/webAnalyticsLogic.ts @@ -2,6 +2,7 @@ import { actions, connect, kea, listeners, path, reducers, selectors, sharedList import type { webAnalyticsLogicType } from './webAnalyticsLogicType' import { NodeKind, QuerySchema } from '~/queries/schema' +import { BaseMathType, ChartDisplayType } from '~/types' interface Layout { colSpan?: number @@ -59,6 +60,64 @@ export const webAnalyticsLogic = kea([ }, }, }, + { + layout: { + colSpan: 6, + }, + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + dateRange: { + date_from: '-7d', + date_to: '-1d', + }, + interval: 'day', + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueUsers, + name: '$pageview', + }, + ], + trendsFilter: { + compare: true, + display: ChartDisplayType.ActionsLineGraph, + }, + filterTestAccounts: true, + }, + }, + }, + { + layout: { + colSpan: 6, + }, + query: { + kind: NodeKind.InsightVizNode, + source: { + kind: NodeKind.TrendsQuery, + breakdown: { + breakdown: '$geoip_country_code', + breakdown_type: 'person', + }, + dateRange: { + date_from: '-7d', + }, + series: [ + { + event: '$pageview', + kind: NodeKind.EventsNode, + math: BaseMathType.UniqueUsers, + }, + ], + trendsFilter: { + display: ChartDisplayType.WorldMap, + }, + filterTestAccounts: true, + }, + }, + }, ], ], }), diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index f3c7963298a7e..c52cd20889b28 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -702,6 +702,10 @@ body { z-index: var(--z-ant-select-dropdown); } + .ant-popconfirm { + z-index: var(--z-bottom-notice); + } + .ant-card-bordered { border-color: var(--border); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0ca801012d6ec..60df3f8f1cf04 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3211,7 +3211,7 @@ export type BatchExportConfiguration = { id: string name: string destination: BatchExportDestination - interval: 'hour' | 'day' + interval: 'hour' | 'day' | 'every 5 minutes' created_at: string start_at: string | null end_at: string | null diff --git a/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion-kafkajs.ts b/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion-kafkajs.ts index c8d6da502f73a..6be2d9e988346 100644 --- a/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion-kafkajs.ts +++ b/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion-kafkajs.ts @@ -91,9 +91,11 @@ export async function eachBatchLegacyIngestion( const team = await queue.pluginsServer.teamManager.getTeamForEvent(currentBatch[0].pluginEvent) const distinct_id = currentBatch[0].pluginEvent.distinct_id if (team && WarningLimiter.consume(`${team.id}:${distinct_id}`, 1)) { - captureIngestionWarning(queue.pluginsServer.db, team.id, 'ingestion_capacity_overflow', { - overflowDistinctId: distinct_id, - }) + processingPromises.push( + captureIngestionWarning(queue.pluginsServer.db, team.id, 'ingestion_capacity_overflow', { + overflowDistinctId: distinct_id, + }) + ) } } diff --git a/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion.ts b/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion.ts index d3f2a77462a2a..f74da4d6890bc 100644 --- a/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion.ts +++ b/plugin-server/src/main/ingestion-queues/batch-processing/each-batch-ingestion.ts @@ -90,9 +90,11 @@ export async function eachBatchParallelIngestion( const team = await queue.pluginsServer.teamManager.getTeamForEvent(currentBatch[0].pluginEvent) const distinct_id = currentBatch[0].pluginEvent.distinct_id if (team && WarningLimiter.consume(`${team.id}:${distinct_id}`, 1)) { - captureIngestionWarning(queue.pluginsServer.db, team.id, 'ingestion_capacity_overflow', { - overflowDistinctId: distinct_id, - }) + processingPromises.push( + captureIngestionWarning(queue.pluginsServer.db, team.id, 'ingestion_capacity_overflow', { + overflowDistinctId: distinct_id, + }) + ) } } diff --git a/plugin-server/src/sentry.ts b/plugin-server/src/sentry.ts index 60c251a1d7bee..960da7de1599d 100644 --- a/plugin-server/src/sentry.ts +++ b/plugin-server/src/sentry.ts @@ -1,3 +1,5 @@ +const fs = require('fs') + import * as Sentry from '@sentry/node' import { ProfilingIntegration } from '@sentry/profiling-node' import * as Tracing from '@sentry/tracing' @@ -19,6 +21,17 @@ export function initSentry(config: PluginsServerConfig): void { if (config.SENTRY_PLUGIN_SERVER_PROFILING_SAMPLE_RATE > 0) { integrations.push(new ProfilingIntegration()) } + + let release: string | undefined = undefined + try { + // Docker containers should have a commit.txt file in the base directory with the git + // commit hash used to generate them. `plugin-server` runs from a child directory, so we + // need to look up one level. + release = fs.readFileSync('../commit.txt', 'utf8') + } catch (error) { + // The release isn't required, it's just nice to have. + } + Sentry.init({ dsn: config.SENTRY_DSN, normalizeDepth: 8, // Default: 3 @@ -28,6 +41,7 @@ export function initSentry(config: PluginsServerConfig): void { DEPLOYMENT: config.CLOUD_DEPLOYMENT, }, }, + release, integrations, tracesSampleRate: config.SENTRY_PLUGIN_SERVER_TRACING_SAMPLE_RATE, profilesSampleRate: config.SENTRY_PLUGIN_SERVER_PROFILING_SAMPLE_RATE, diff --git a/plugin-server/src/utils/db/db.ts b/plugin-server/src/utils/db/db.ts index a5873a7a8b8ec..33c384e28fb03 100644 --- a/plugin-server/src/utils/db/db.ts +++ b/plugin-server/src/utils/db/db.ts @@ -66,7 +66,6 @@ import { UUIDT, } from '../utils' import { OrganizationPluginsAccessLevel } from './../../types' -import { PromiseManager } from './../../worker/vm/promise-manager' import { KafkaProducerWrapper } from './kafka-producer-wrapper' import { PostgresRouter, PostgresUse, TransactionClient } from './postgres' import { @@ -165,16 +164,12 @@ export class DB { /** How many seconds to keep person info in Redis cache */ PERSONS_AND_GROUPS_CACHE_TTL: number - /** PromiseManager instance to keep track of voided promises */ - promiseManager: PromiseManager - constructor( postgres: PostgresRouter, redisPool: GenericPool, kafkaProducer: KafkaProducerWrapper, clickhouse: ClickHouse, statsd: StatsD | undefined, - promiseManager: PromiseManager, personAndGroupsCacheTtl = 1 ) { this.postgres = postgres @@ -183,7 +178,6 @@ export class DB { this.clickhouse = clickhouse this.statsd = statsd this.PERSONS_AND_GROUPS_CACHE_TTL = personAndGroupsCacheTtl - this.promiseManager = promiseManager } // ClickHouse diff --git a/plugin-server/src/utils/db/hub.ts b/plugin-server/src/utils/db/hub.ts index 6673cda368c11..dfcda8b8328bc 100644 --- a/plugin-server/src/utils/db/hub.ts +++ b/plugin-server/src/utils/db/hub.ts @@ -129,15 +129,7 @@ export async function createHub( const promiseManager = new PromiseManager(serverConfig, statsd) - const db = new DB( - postgres, - redisPool, - kafkaProducer, - clickhouse, - statsd, - promiseManager, - serverConfig.PERSON_INFO_CACHE_TTL - ) + const db = new DB(postgres, redisPool, kafkaProducer, clickhouse, statsd, serverConfig.PERSON_INFO_CACHE_TTL) const teamManager = new TeamManager(postgres, serverConfig, statsd) const organizationManager = new OrganizationManager(postgres, teamManager) const pluginsApiKeyManager = new PluginsApiKeyManager(db) diff --git a/plugin-server/src/worker/ingestion/event-pipeline/prepareEventStep.ts b/plugin-server/src/worker/ingestion/event-pipeline/prepareEventStep.ts index d44bb440b3c4a..e51fa5df4a477 100644 --- a/plugin-server/src/worker/ingestion/event-pipeline/prepareEventStep.ts +++ b/plugin-server/src/worker/ingestion/event-pipeline/prepareEventStep.ts @@ -7,12 +7,14 @@ import { EventPipelineRunner } from './runner' export async function prepareEventStep(runner: EventPipelineRunner, event: PluginEvent): Promise { const { team_id, uuid } = event + const tsParsingIngestionWarnings: Promise[] = [] const invalidTimestampCallback = function (type: string, details: Record) { // TODO: make that metric name more generic when transitionning to prometheus runner.hub.statsd?.increment('process_event_invalid_timestamp', { teamId: String(team_id), type: type }) - captureIngestionWarning(runner.hub.db, team_id, type, details) + tsParsingIngestionWarnings.push(captureIngestionWarning(runner.hub.db, team_id, type, details)) } + const preIngestionEvent = await runner.hub.eventsProcessor.processEvent( String(event.distinct_id), event, @@ -20,6 +22,7 @@ export async function prepareEventStep(runner: EventPipelineRunner, event: Plugi parseEventTimestamp(event, invalidTimestampCallback), uuid! // it will throw if it's undefined, ) + await Promise.all(tsParsingIngestionWarnings) return preIngestionEvent } diff --git a/plugin-server/src/worker/ingestion/person-state.ts b/plugin-server/src/worker/ingestion/person-state.ts index 994b0efa98269..0fd815a6175a7 100644 --- a/plugin-server/src/worker/ingestion/person-state.ts +++ b/plugin-server/src/worker/ingestion/person-state.ts @@ -326,7 +326,7 @@ export class PersonState { } if (isDistinctIdIllegal(mergeIntoDistinctId)) { this.statsd?.increment('illegal_distinct_ids.total', { distinctId: mergeIntoDistinctId }) - captureIngestionWarning(this.db, teamId, 'cannot_merge_with_illegal_distinct_id', { + await captureIngestionWarning(this.db, teamId, 'cannot_merge_with_illegal_distinct_id', { illegalDistinctId: mergeIntoDistinctId, otherDistinctId: otherPersonDistinctId, eventUuid: this.event.uuid, @@ -335,7 +335,7 @@ export class PersonState { } if (isDistinctIdIllegal(otherPersonDistinctId)) { this.statsd?.increment('illegal_distinct_ids.total', { distinctId: otherPersonDistinctId }) - captureIngestionWarning(this.db, teamId, 'cannot_merge_with_illegal_distinct_id', { + await captureIngestionWarning(this.db, teamId, 'cannot_merge_with_illegal_distinct_id', { illegalDistinctId: otherPersonDistinctId, otherDistinctId: mergeIntoDistinctId, eventUuid: this.event.uuid, @@ -421,7 +421,7 @@ export class PersonState { // If merge isn't allowed, we will ignore it, log an ingestion warning and exit if (!mergeAllowed) { // TODO: add event UUID to the ingestion warning - captureIngestionWarning(this.db, this.teamId, 'cannot_merge_already_identified', { + await captureIngestionWarning(this.db, this.teamId, 'cannot_merge_already_identified', { sourcePersonDistinctId: otherPersonDistinctId, targetPersonDistinctId: mergeIntoDistinctId, eventUuid: this.event.uuid, diff --git a/plugin-server/src/worker/ingestion/process-event.ts b/plugin-server/src/worker/ingestion/process-event.ts index 4b0397c66d792..f378c0e6c1770 100644 --- a/plugin-server/src/worker/ingestion/process-event.ts +++ b/plugin-server/src/worker/ingestion/process-event.ts @@ -67,7 +67,7 @@ export class EventsProcessor { eventUuid: string ): Promise { if (!UUID.validateString(eventUuid, false)) { - captureIngestionWarning(this.db, teamId, 'skipping_event_invalid_uuid', { + await captureIngestionWarning(this.db, teamId, 'skipping_event_invalid_uuid', { eventUuid: JSON.stringify(eventUuid), }) throw new Error(`Not a valid UUID: "${eventUuid}"`) diff --git a/plugin-server/src/worker/ingestion/utils.ts b/plugin-server/src/worker/ingestion/utils.ts index 9a80d578eb1e8..affc100370afa 100644 --- a/plugin-server/src/worker/ingestion/utils.ts +++ b/plugin-server/src/worker/ingestion/utils.ts @@ -61,22 +61,19 @@ export function generateEventDeadLetterQueueMessage( // These get displayed under Data Management > Ingestion Warnings // These warnings get displayed to end users. Make sure these errors are actionable and useful for them and // also update IngestionWarningsView.tsx to display useful context. -export function captureIngestionWarning(db: DB, teamId: TeamId, type: string, details: Record) { - db.promiseManager.trackPromise( - db.kafkaProducer.queueMessage({ - topic: KAFKA_INGESTION_WARNINGS, - messages: [ - { - value: JSON.stringify({ - team_id: teamId, - type: type, - source: 'plugin-server', - details: JSON.stringify(details), - timestamp: castTimestampOrNow(null, TimestampFormat.ClickHouse), - }), - }, - ], - }), - 'ingestion_warning' - ) +export async function captureIngestionWarning(db: DB, teamId: TeamId, type: string, details: Record) { + await db.kafkaProducer.queueMessage({ + topic: KAFKA_INGESTION_WARNINGS, + messages: [ + { + value: JSON.stringify({ + team_id: teamId, + type: type, + source: 'plugin-server', + details: JSON.stringify(details), + timestamp: castTimestampOrNow(null, TimestampFormat.ClickHouse), + }), + }, + ], + }) } diff --git a/plugin-server/tests/worker/ingestion/utils.test.ts b/plugin-server/tests/worker/ingestion/utils.test.ts index e289449c46e42..3e65b0964595d 100644 --- a/plugin-server/tests/worker/ingestion/utils.test.ts +++ b/plugin-server/tests/worker/ingestion/utils.test.ts @@ -24,7 +24,7 @@ describe('captureIngestionWarning()', () => { } it('can read own writes', async () => { - captureIngestionWarning(hub.db, 2, 'some_type', { foo: 'bar' }) + await captureIngestionWarning(hub.db, 2, 'some_type', { foo: 'bar' }) await hub.promiseManager.awaitPromisesIfNeeded() const warnings = await delayUntilEventIngested(fetchWarnings) diff --git a/posthog/api/test/batch_exports/test_create.py b/posthog/api/test/batch_exports/test_create.py index 3b63d3f3ceb7b..ca58d13a17347 100644 --- a/posthog/api/test/batch_exports/test_create.py +++ b/posthog/api/test/batch_exports/test_create.py @@ -65,7 +65,15 @@ def test_create_batch_export_with_interval_schedule(client: HttpClient, interval ) if interval == "every 5 minutes": - feature_enabled.assert_called_once_with("high-frequency-batch-exports", str(team.uuid)) + feature_enabled.assert_called_once_with( + "high-frequency-batch-exports", + str(team.uuid), + groups={"organization": str(team.organization.id)}, + group_properties={ + "organization": {"id": str(team.organization.id), "created_at": team.organization.created_at} + }, + send_feature_flag_events=False, + ) assert response.status_code == status.HTTP_201_CREATED, response.json() @@ -179,4 +187,12 @@ def test_cannot_create_a_batch_export_with_higher_frequencies_if_not_enabled(cli batch_export_data, ) assert response.status_code == status.HTTP_403_FORBIDDEN, response.json() - feature_enabled.assert_called_once_with("high-frequency-batch-exports", str(team.uuid)) + feature_enabled.assert_called_once_with( + "high-frequency-batch-exports", + str(team.uuid), + groups={"organization": str(team.organization.id)}, + group_properties={ + "organization": {"id": str(team.organization.id), "created_at": team.organization.created_at} + }, + send_feature_flag_events=False, + ) diff --git a/posthog/batch_exports/http.py b/posthog/batch_exports/http.py index f7084d65db89c..cd8e24aca5cd6 100644 --- a/posthog/batch_exports/http.py +++ b/posthog/batch_exports/http.py @@ -170,11 +170,19 @@ def create(self, validated_data: dict) -> BatchExport: destination_data = validated_data.pop("destination") team_id = self.context["team_id"] - if validated_data["interval"] not in ("hour", "day", "week") and not posthoganalytics.feature_enabled( - "high-frequency-batch-exports", - str(Team.objects.get(id=team_id).uuid), - ): - raise PermissionDenied("Higher frequency exports are not enabled for this team.") + if validated_data["interval"] not in ("hour", "day", "week"): + team = Team.objects.get(id=team_id) + + if not posthoganalytics.feature_enabled( + "high-frequency-batch-exports", + str(team.uuid), + groups={"organization": str(team.organization.id)}, + group_properties={ + "organization": {"id": str(team.organization.id), "created_at": team.organization.created_at} + }, + send_feature_flag_events=False, + ): + raise PermissionDenied("Higher frequency exports are not enabled for this team.") destination = BatchExportDestination(**destination_data) batch_export = BatchExport(team_id=team_id, destination=destination, **validated_data) diff --git a/posthog/hogql_queries/web_analytics/ctes.py b/posthog/hogql_queries/web_analytics/ctes.py index 8fcd85b960a4f..22a69c9193803 100644 --- a/posthog/hogql_queries/web_analytics/ctes.py +++ b/posthog/hogql_queries/web_analytics/ctes.py @@ -59,10 +59,21 @@ """ PATHNAME_CTE = """ +SELECT + events.properties.`$pathname` AS pathname, + count() as total_pageviews, + uniq(events.person_id) as unique_visitors -- might want to use person id? have seen a small number of pages where unique > total +FROM + events +WHERE + (event = '$pageview') + AND events.timestamp >= now() - INTERVAL 7 DAY +GROUP BY pathname +""" + +PATHNAME_SCROLL_CTE = """ SELECT events.properties.`$prev_pageview_pathname` AS pathname, - countIf(events.event == '$pageview') as total_pageviews, - COUNT(DISTINCT events.properties.distinct_id) as unique_visitors, -- might want to use person id? have seen a small number of pages where unique > total avg(CASE WHEN toFloat(JSONExtractRaw(events.properties, '$prev_pageview_max_content_percentage')) IS NULL THEN NULL WHEN toFloat(JSONExtractRaw(events.properties, '$prev_pageview_max_content_percentage')) > 0.8 THEN 100 diff --git a/posthog/hogql_queries/web_analytics/overview_stats.py b/posthog/hogql_queries/web_analytics/overview_stats.py index 8632eaa781216..6ad7a30182444 100644 --- a/posthog/hogql_queries/web_analytics/overview_stats.py +++ b/posthog/hogql_queries/web_analytics/overview_stats.py @@ -22,8 +22,8 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: overview_stats_query = parse_select( """ SELECT - uniq(if(timestamp >= {mid} AND timestamp < {end}, events.distinct_id, NULL)) AS current_week_unique_users, - uniq(if(timestamp >= {start} AND timestamp < {mid}, events.distinct_id, NULL)) AS previous_week_unique_users, + uniq(if(timestamp >= {mid} AND timestamp < {end}, events.person_id, NULL)) AS current_week_unique_users, + uniq(if(timestamp >= {start} AND timestamp < {mid}, events.person_id, NULL)) AS previous_week_unique_users, uniq(if(timestamp >= {mid} AND timestamp < {end}, events.properties.$session_id, NULL)) AS current_week_unique_sessions, uniq(if(timestamp >= {start} AND timestamp < {mid}, events.properties.$session_id, NULL)) AS previous_week_unique_sessions, diff --git a/posthog/hogql_queries/web_analytics/top_clicks.py b/posthog/hogql_queries/web_analytics/top_clicks.py index 8521e35f461bf..d5e8237715ac8 100644 --- a/posthog/hogql_queries/web_analytics/top_clicks.py +++ b/posthog/hogql_queries/web_analytics/top_clicks.py @@ -31,6 +31,7 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: GROUP BY el_text ORDER BY total_clicks DESC +LIMIT 10 """, timings=self.timings, ) diff --git a/posthog/hogql_queries/web_analytics/top_pages.py b/posthog/hogql_queries/web_analytics/top_pages.py index a17febefdd31a..6e13196275331 100644 --- a/posthog/hogql_queries/web_analytics/top_pages.py +++ b/posthog/hogql_queries/web_analytics/top_pages.py @@ -3,7 +3,7 @@ from posthog.hogql import ast from posthog.hogql.parser import parse_select from posthog.hogql.query import execute_hogql_query -from posthog.hogql_queries.web_analytics.ctes import SESSION_CTE, PATHNAME_CTE +from posthog.hogql_queries.web_analytics.ctes import SESSION_CTE, PATHNAME_CTE, PATHNAME_SCROLL_CTE from posthog.hogql_queries.web_analytics.web_analytics_query_runner import WebAnalyticsQueryRunner from posthog.hogql_queries.utils.query_date_range import QueryDateRange from posthog.models.filters.mixins.utils import cached_property @@ -19,6 +19,8 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: session_query = parse_select(SESSION_CTE, timings=self.timings) with self.timings.measure("pathname_query"): pathname_query = parse_select(PATHNAME_CTE, timings=self.timings) + with self.timings.measure("pathname_scroll_query"): + pathname_scroll_query = parse_select(PATHNAME_SCROLL_CTE, timings=self.timings) with self.timings.measure("top_pages_query"): top_sources_query = parse_select( """ @@ -26,9 +28,9 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: pathname.pathname as pathname, pathname.total_pageviews as total_pageviews, pathname.unique_visitors as unique_visitors, - pathname.scroll_gt80_percentage as scroll_gt80_percentage, - pathname.average_scroll_percentage as average_scroll_percentage, - bounce_rate.bounce_rate as bounce_rate + bounce_rate.bounce_rate as bounce_rate, + scroll_data.scroll_gt80_percentage as scroll_gt80_percentage, + scroll_data.average_scroll_percentage as average_scroll_percentage FROM {pathname_query} AS pathname LEFT OUTER JOIN @@ -43,11 +45,20 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: ) AS bounce_rate ON pathname.pathname = bounce_rate.earliest_pathname +LEFT OUTER JOIN + {pathname_scroll_query} AS scroll_data +ON + pathname.pathname = scroll_data.pathname ORDER BY total_pageviews DESC +LIMIT 10 """, timings=self.timings, - placeholders={"pathname_query": pathname_query, "session_query": session_query}, + placeholders={ + "pathname_query": pathname_query, + "session_query": session_query, + "pathname_scroll_query": pathname_scroll_query, + }, ) return top_sources_query diff --git a/posthog/hogql_queries/web_analytics/top_sources.py b/posthog/hogql_queries/web_analytics/top_sources.py index ba61c6ab82698..8de3b79b19574 100644 --- a/posthog/hogql_queries/web_analytics/top_sources.py +++ b/posthog/hogql_queries/web_analytics/top_sources.py @@ -32,7 +32,7 @@ def to_query(self) -> ast.SelectQuery | ast.SelectUnionQuery: GROUP BY blended_source ORDER BY total_pageviews DESC -LIMIT 100 +LIMIT 10 """, timings=self.timings, placeholders={"session_query": session_query}, diff --git a/posthog/settings/sentry.py b/posthog/settings/sentry.py index 23d02d0a68a6c..208f3bfd81e2c 100644 --- a/posthog/settings/sentry.py +++ b/posthog/settings/sentry.py @@ -122,9 +122,20 @@ def sentry_init() -> None: sentry_logging = LoggingIntegration(level=sentry_logging_level, event_level=None) profiles_sample_rate = get_from_env("SENTRY_PROFILES_SAMPLE_RATE", type_cast=float, default=0.0) + release = None + try: + # Docker containers should have a commit.txt file in the base directory with the git + # commit hash used to generate them. + with open("commit.txt") as f: + release = f.read() + except: + # The release isn't required, it's just nice to have. + pass + sentry_sdk.init( send_default_pii=send_pii, dsn=os.environ["SENTRY_DSN"], + release=release, integrations=[DjangoIntegration(), CeleryIntegration(), RedisIntegration(), sentry_logging], request_bodies="always" if send_pii else "never", sample_rate=1.0,