diff --git a/ee/api/test/test_capture.py b/ee/api/test/test_capture.py index 891a9759a80c5..188ee973037aa 100644 --- a/ee/api/test/test_capture.py +++ b/ee/api/test/test_capture.py @@ -1,12 +1,13 @@ import hashlib import json -from typing import Any +from typing import Any, Tuple from unittest.mock import patch +from django.http import HttpResponse from django.test.client import Client +from django.utils import timezone from kafka.errors import NoBrokersAvailable from rest_framework import status - from posthog.settings.data_stores import KAFKA_EVENTS_PLUGIN_INGESTION from posthog.test.base import APIBaseTest @@ -20,6 +21,54 @@ def setUp(self): super().setUp() self.client = Client() + def _send_event(self) -> HttpResponse: + event_response = self.client.post( + "/e/", + data={ + "data": json.dumps( + [ + {"event": "beep", "properties": {"distinct_id": "eeee", "token": self.team.api_token}}, + {"event": "boop", "properties": {"distinct_id": "aaaa", "token": self.team.api_token}}, + ] + ), + "api_key": self.team.api_token, + }, + ) + + return event_response + + def _send_session_recording_event( + self, + number_of_events=1, + event_data={}, + snapshot_source=3, + snapshot_type=1, + session_id="abc123", + window_id="def456", + distinct_id="ghi789", + timestamp=1658516991883, + ) -> Tuple[dict, HttpResponse]: + event = { + "event": "$snapshot", + "properties": { + "$snapshot_data": { + "type": snapshot_type, + "data": {"source": snapshot_source, "data": event_data}, + "timestamp": timestamp, + }, + "$session_id": session_id, + "$window_id": window_id, + "distinct_id": distinct_id, + }, + "offset": 1993, + } + + capture_recording_response = self.client.post( + "/s/", data={"data": json.dumps([event for _ in range(number_of_events)]), "api_key": self.team.api_token} + ) + + return event, capture_recording_response + @patch("posthog.kafka_client.client._KafkaProducer.produce") def test_produce_to_kafka(self, kafka_produce): response = self.client.post( @@ -154,7 +203,7 @@ def test_kafka_connection_error(self, kafka_produce): def test_partition_key_override(self, kafka_produce): default_partition_key = f"{self.team.api_token}:id1" - response = self.client.post( + self.client.post( "/capture/", { "data": json.dumps( @@ -172,7 +221,7 @@ def test_partition_key_override(self, kafka_produce): }, ) - # By default we use (the hash of) as the partition key + # By default, we use (the hash of) as the partition key kafka_produce_call = kafka_produce.call_args_list[0].kwargs self.assertEqual( kafka_produce_call["key"], @@ -204,3 +253,71 @@ def test_partition_key_override(self, kafka_produce): kafka_produce_call = kafka_produce.call_args_list[1].kwargs self.assertEqual(kafka_produce_call["key"], None) + + @patch("posthog.kafka_client.client._KafkaProducer.produce") + def test_quota_limits_ignored_if_disabled(self, kafka_produce) -> None: + from ee.billing.quota_limiting import QuotaResource, replace_limited_team_tokens + + replace_limited_team_tokens(QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() + 10000}) + replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() + 10000}) + self._send_session_recording_event() + self.assertEqual(kafka_produce.call_count, 2) + + @patch("posthog.kafka_client.client._KafkaProducer.produce") + def test_quota_limits(self, kafka_produce) -> None: + from ee.billing.quota_limiting import QuotaResource, replace_limited_team_tokens + + def _produce_events(): + kafka_produce.reset_mock() + self._send_session_recording_event() + self._send_event() + + with self.settings(QUOTA_LIMITING_ENABLED=True): + replace_limited_team_tokens(QuotaResource.EVENTS, {}) + replace_limited_team_tokens(QuotaResource.RECORDINGS, {}) + + _produce_events() + self.assertEqual(kafka_produce.call_count, 4) + + replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() + 10000}) + _produce_events() + self.assertEqual(kafka_produce.call_count, 2) # Only the recording event + + replace_limited_team_tokens( + QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() + 10000} + ) + _produce_events() + self.assertEqual(kafka_produce.call_count, 0) # No events + + replace_limited_team_tokens( + QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() - 10000} + ) + replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() - 10000}) + _produce_events() + self.assertEqual(kafka_produce.call_count, 4) # All events as limit-until timestamp is in the past + + @patch("posthog.kafka_client.client._KafkaProducer.produce") + def test_quota_limited_recordings_return_retry_after_header(self, _kafka_produce) -> None: + with self.settings(QUOTA_LIMITING_ENABLED=True): + from ee.billing.quota_limiting import QuotaResource, replace_limited_team_tokens + + replace_limited_team_tokens( + QuotaResource.RECORDINGS, {self.team.api_token: timezone.now().timestamp() + 10000} + ) + _, response = self._send_session_recording_event() + + assert response.json() == { + "status": 1, + "quota_limited": ["recordings"], + } + + @patch("posthog.kafka_client.client._KafkaProducer.produce") + def test_quota_limiting_does_not_affect_events_body(self, _kafka_produce) -> None: + with self.settings(QUOTA_LIMITING_ENABLED=True): + from ee.billing.quota_limiting import QuotaResource, replace_limited_team_tokens + + replace_limited_team_tokens(QuotaResource.EVENTS, {self.team.api_token: timezone.now().timestamp() + 10000}) + + response = self._send_event() + + assert response.json() == {"status": 1} diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index ce4c16033902c..acd75839d5455 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ''' - /* user_id:127 celery:posthog.tasks.tasks.sync_insight_caching_state */ + /* user_id:128 celery:posthog.tasks.tasks.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/frontend/src/layout/navigation/TopBar/notificationsLogic.tsx b/frontend/src/layout/navigation/TopBar/notificationsLogic.tsx new file mode 100644 index 0000000000000..35499345c118b --- /dev/null +++ b/frontend/src/layout/navigation/TopBar/notificationsLogic.tsx @@ -0,0 +1,202 @@ +import { actions, events, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { teamLogic } from 'scenes/teamLogic' +import { ActivityLogItem, humanize, HumanizedActivityLogItem } from 'lib/components/ActivityLog/humanizeActivity' + +import type { notificationsLogicType } from './notificationsLogicType' +import { describerFor } from 'lib/components/ActivityLog/activityLogLogic' +import { dayjs } from 'lib/dayjs' +import ReactMarkdown from 'react-markdown' +import posthog from 'posthog-js' + +const POLL_TIMEOUT = 5 * 60 * 1000 +const MARK_READ_TIMEOUT = 2500 + +export interface ChangelogFlagPayload { + notificationDate: dayjs.Dayjs + markdown: string +} + +export interface ChangesResponse { + results: ActivityLogItem[] + last_read: string +} + +export const notificationsLogic = kea([ + path(['layout', 'navigation', 'TopBar', 'notificationsLogic']), + actions({ + toggleNotificationsPopover: true, + togglePolling: (pageIsVisible: boolean) => ({ pageIsVisible }), + setPollTimeout: (pollTimeout: number) => ({ pollTimeout }), + setMarkReadTimeout: (markReadTimeout: number) => ({ markReadTimeout }), + incrementErrorCount: true, + clearErrorCount: true, + markAllAsRead: (bookmarkDate: string) => ({ bookmarkDate }), + }), + loaders(({ actions, values }) => ({ + importantChanges: [ + null as ChangesResponse | null, + { + markAllAsRead: ({ bookmarkDate }) => { + const current = values.importantChanges + if (!current) { + return null + } + + return { + last_read: bookmarkDate, + results: current.results.map((ic) => ({ ...ic, unread: false })), + } + }, + loadImportantChanges: async (_, breakpoint) => { + await breakpoint(1) + + clearTimeout(values.pollTimeout) + + try { + const response = (await api.get( + `api/projects/${teamLogic.values.currentTeamId}/activity_log/important_changes` + )) as ChangesResponse + // we can't rely on automatic success action here because we swallow errors so always succeed + actions.clearErrorCount() + return response + } catch (e) { + // swallow errors as this isn't user initiated + // increment a counter to backoff calling the API while errors persist + actions.incrementErrorCount() + return null + } finally { + const pollTimeoutMilliseconds = values.errorCounter + ? POLL_TIMEOUT * values.errorCounter + : POLL_TIMEOUT + const timeout = window.setTimeout(actions.loadImportantChanges, pollTimeoutMilliseconds) + actions.setPollTimeout(timeout) + } + }, + }, + ], + })), + reducers({ + errorCounter: [ + 0, + { + incrementErrorCount: (state) => (state >= 5 ? 5 : state + 1), + clearErrorCount: () => 0, + }, + ], + isNotificationPopoverOpen: [ + false, + { + toggleNotificationsPopover: (state) => !state, + }, + ], + isPolling: [true, { togglePolling: (_, { pageIsVisible }) => pageIsVisible }], + pollTimeout: [ + 0, + { + setPollTimeout: (_, payload) => payload.pollTimeout, + }, + ], + markReadTimeout: [ + 0, + { + setMarkReadTimeout: (_, payload) => payload.markReadTimeout, + }, + ], + }), + listeners(({ values, actions }) => ({ + toggleNotificationsPopover: () => { + if (!values.isNotificationPopoverOpen) { + clearTimeout(values.markReadTimeout) + } else { + if (values.notifications?.[0]) { + const bookmarkDate = values.notifications.reduce((a, b) => + a.created_at.isAfter(b.created_at) ? a : b + ).created_at + actions.setMarkReadTimeout( + window.setTimeout(async () => { + await api.create( + `api/projects/${teamLogic.values.currentTeamId}/activity_log/bookmark_activity_notification`, + { + bookmark: bookmarkDate.toISOString(), + } + ) + actions.markAllAsRead(bookmarkDate.toISOString()) + }, MARK_READ_TIMEOUT) + ) + } + } + }, + })), + selectors({ + notifications: [ + (s) => [s.importantChanges], + (importantChanges): HumanizedActivityLogItem[] => { + try { + const importantChangesHumanized = humanize(importantChanges?.results || [], describerFor, true) + + let changelogNotification: ChangelogFlagPayload | null = null + const flagPayload = posthog.getFeatureFlagPayload('changelog-notification') + if (!!flagPayload) { + changelogNotification = { + markdown: flagPayload['markdown'], + notificationDate: dayjs(flagPayload['notificationDate']), + } as ChangelogFlagPayload + } + + if (changelogNotification) { + const lastRead = importantChanges?.last_read ? dayjs(importantChanges.last_read) : null + const changeLogIsUnread = + !!lastRead && + (lastRead.isBefore(changelogNotification.notificationDate) || + lastRead == changelogNotification.notificationDate) + + const changelogNotificationHumanized: HumanizedActivityLogItem = { + email: 'joe@posthog.com', + name: 'Joe', + isSystem: true, + description: ( + <> + {changelogNotification.markdown} + + ), + created_at: changelogNotification.notificationDate, + unread: changeLogIsUnread, + } + const notifications = [changelogNotificationHumanized, ...importantChangesHumanized] + notifications.sort((a, b) => { + if (a.created_at.isBefore(b.created_at)) { + return 1 + } else if (a.created_at.isAfter(b.created_at)) { + return -1 + } else { + return 0 + } + }) + return notifications + } + + return humanize(importantChanges?.results || [], describerFor, true) + } catch (e) { + // swallow errors as this isn't user initiated + return [] + } + }, + ], + hasNotifications: [(s) => [s.notifications], (notifications) => !!notifications.length], + unread: [ + (s) => [s.notifications], + (notifications: HumanizedActivityLogItem[]) => notifications.filter((ic) => ic.unread), + ], + unreadCount: [(s) => [s.unread], (unread) => (unread || []).length], + hasUnread: [(s) => [s.unreadCount], (unreadCount) => unreadCount > 0], + }), + events(({ actions, values }) => ({ + afterMount: () => actions.loadImportantChanges(null), + beforeUnmount: () => { + clearTimeout(values.pollTimeout) + clearTimeout(values.markReadTimeout) + }, + })), +]) diff --git a/frontend/src/lib/components/ChartFilter/chartFilterLogic.ts b/frontend/src/lib/components/ChartFilter/chartFilterLogic.ts new file mode 100644 index 0000000000000..a94e57dcc76cd --- /dev/null +++ b/frontend/src/lib/components/ChartFilter/chartFilterLogic.ts @@ -0,0 +1,46 @@ +import { kea } from 'kea' +import type { chartFilterLogicType } from './chartFilterLogicType' +import { ChartDisplayType, InsightLogicProps } from '~/types' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' + +export const chartFilterLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps('new'), + path: (key) => ['lib', 'components', 'ChartFilter', 'chartFilterLogic', key], + connect: (props: InsightLogicProps) => ({ + actions: [insightVizDataLogic(props), ['updateInsightFilter', 'updateBreakdown']], + values: [insightVizDataLogic(props), ['isTrends', 'isStickiness', 'display', 'series']], + }), + + actions: () => ({ + setChartFilter: (chartFilter: ChartDisplayType) => ({ chartFilter }), + }), + + selectors: { + chartFilter: [(s) => [s.display], (display): ChartDisplayType | null | undefined => display], + }, + + listeners: ({ actions, values }) => ({ + setChartFilter: ({ chartFilter }) => { + const { isTrends, isStickiness, display, series } = values + const newDisplay = chartFilter as ChartDisplayType + + if ((isTrends || isStickiness) && display !== newDisplay) { + actions.updateInsightFilter({ display: newDisplay }) + + // For the map, make sure we are breaking down by country + if (isTrends && newDisplay === ChartDisplayType.WorldMap) { + const math = series?.[0].math + + actions.updateBreakdown({ + breakdown: '$geoip_country_code', + breakdown_type: ['dau', 'weekly_active', 'monthly_active'].includes(math || '') + ? 'person' + : 'event', + }) + } + } + }, + }), +}) diff --git a/frontend/src/lib/components/CompareFilter/compareFilterLogic.ts b/frontend/src/lib/components/CompareFilter/compareFilterLogic.ts new file mode 100644 index 0000000000000..c3e2d1b65dcbe --- /dev/null +++ b/frontend/src/lib/components/CompareFilter/compareFilterLogic.ts @@ -0,0 +1,46 @@ +import { kea } from 'kea' +import { ChartDisplayType, InsightLogicProps } from '~/types' +import type { compareFilterLogicType } from './compareFilterLogicType' +import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils' +import { insightLogic } from 'scenes/insights/insightLogic' +import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' + +export const compareFilterLogic = kea({ + props: {} as InsightLogicProps, + key: keyForInsightLogicProps('new'), + path: (key) => ['lib', 'components', 'CompareFilter', 'compareFilterLogic', key], + connect: (props: InsightLogicProps) => ({ + values: [ + insightLogic(props), + ['canEditInsight'], + insightVizDataLogic(props), + ['compare', 'display', 'insightFilter', 'isLifecycle', 'dateRange'], + ], + actions: [insightVizDataLogic(props), ['updateInsightFilter']], + }), + + actions: () => ({ + setCompare: (compare: boolean) => ({ compare }), + toggleCompare: true, + }), + + selectors: { + disabled: [ + (s) => [s.canEditInsight, s.isLifecycle, s.display, s.dateRange], + (canEditInsight, isLifecycle, display, dateRange) => + !canEditInsight || + isLifecycle || + display === ChartDisplayType.WorldMap || + dateRange?.date_from === 'all', + ], + }, + + listeners: ({ values, actions }) => ({ + setCompare: ({ compare }) => { + actions.updateInsightFilter({ compare }) + }, + toggleCompare: () => { + actions.setCompare(!values.compare) + }, + }), +}) diff --git a/frontend/src/lib/components/HedgehogBuddy/hedgehogBuddyLogic.ts b/frontend/src/lib/components/HedgehogBuddy/hedgehogBuddyLogic.ts index 6aae0da02fe23..a914f71643bc2 100644 --- a/frontend/src/lib/components/HedgehogBuddy/hedgehogBuddyLogic.ts +++ b/frontend/src/lib/components/HedgehogBuddy/hedgehogBuddyLogic.ts @@ -1,20 +1,27 @@ import { actions, kea, listeners, path, reducers, selectors } from 'kea' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import posthog from 'posthog-js' -import type { hedgehogBuddyLogicType } from './hedgehogBuddyLogicType' +import type { hedgehogbuddyLogicType } from './hedgehogbuddyLogicType' +import posthog from 'posthog-js' import { AccessoryInfo, standardAccessories } from './sprites/sprites' -export const hedgehogBuddyLogic = kea([ - path(['hedgehog', 'hedgehogBuddyLogic']), +import type { hedgehogbuddyLogicType } from './hedgehogBuddyLogicType' + +export const hedgehogbuddyLogic = kea([ + path(['hedgehog', 'hedgehogbuddyLogic']), actions({ setHedgehogModeEnabled: (enabled: boolean) => ({ enabled }), addAccessory: (accessory: AccessoryInfo) => ({ accessory }), removeAccessory: (accessory: AccessoryInfo) => ({ accessory }), }), - reducers(() => ({ + reducers(({}) => ({ + hedgehogModeEnabled: [ + false as boolean, + { persist: true }, + { + setHedgehogModeEnabled: (_, { enabled }) => enabled, + }, + ], accessories: [ [] as AccessoryInfo[], { persist: true }, @@ -40,16 +47,15 @@ export const hedgehogBuddyLogic = kea([ return Object.keys(standardAccessories) }, ], - hedgehogModeEnabled: [ - () => [featureFlagLogic.selectors.featureFlags], - (featureFlags): boolean => !!featureFlags[FEATURE_FLAGS.HEDGEHOG_MODE], - ], }), - listeners(() => ({ + listeners(({}) => ({ setHedgehogModeEnabled: ({ enabled }) => { - posthog.updateEarlyAccessFeatureEnrollment(FEATURE_FLAGS.HEDGEHOG_MODE, enabled) - posthog.capture(enabled ? 'hedgehog mode enabled' : 'hedgehog mode disabled') + if (enabled) { + posthog.capture('hedgehog mode enabled') + } else { + posthog.capture('hedgehog mode disabled') + } }, })), ]) diff --git a/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts b/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts new file mode 100644 index 0000000000000..cad3d7eaa824f --- /dev/null +++ b/frontend/src/lib/components/PersonalAPIKeys/personalAPIKeysLogic.ts @@ -0,0 +1,43 @@ +import { kea } from 'kea' +import api from 'lib/api' +import { PersonalAPIKeyType } from '~/types' +import type { personalAPIKeysLogicType } from './personalAPIKeysLogicType' +import { copyToClipboard } from 'lib/utils' +import { lemonToast } from 'lib/lemon-ui/lemonToast' + +export const personalAPIKeysLogic = kea({ + path: ['lib', 'components', 'PersonalAPIKeys', 'personalAPIKeysLogic'], + loaders: ({ values }) => ({ + keys: [ + [] as PersonalAPIKeyType[], + { + loadKeys: async () => { + const response: PersonalAPIKeyType[] = await api.get('api/personal_api_keys/') + return response + }, + createKey: async (label: string) => { + const newKey: PersonalAPIKeyType = await api.create('api/personal_api_keys/', { + label, + }) + return [newKey, ...values.keys] + }, + deleteKey: async (key: PersonalAPIKeyType) => { + await api.delete(`api/personal_api_keys/${key.id}/`) + return (values.keys as PersonalAPIKeyType[]).filter((filteredKey) => filteredKey.id != key.id) + }, + }, + ], + }), + listeners: () => ({ + createKeySuccess: async ({ keys }: { keys: PersonalAPIKeyType[] }) => { + keys[0]?.value && (await copyToClipboard(keys[0].value, 'personal API key value')) + }, + deleteKeySuccess: ({}: { keys: PersonalAPIKeyType[] }) => { + lemonToast.success(`Personal API key deleted`) + }, + }), + + events: ({ actions }) => ({ + afterMount: [actions.loadKeys], + }), +}) diff --git a/frontend/src/lib/components/PropertyGroupFilters/propertyGroupFilterLogic.ts b/frontend/src/lib/components/PropertyGroupFilters/propertyGroupFilterLogic.ts new file mode 100644 index 0000000000000..88d228ea88be8 --- /dev/null +++ b/frontend/src/lib/components/PropertyGroupFilters/propertyGroupFilterLogic.ts @@ -0,0 +1,108 @@ +import { actions, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' + +import { PropertyGroupFilter, FilterLogicalOperator, EmptyPropertyFilter } from '~/types' +import { PropertyGroupFilterLogicProps } from 'lib/components/PropertyFilters/types' + +import type { propertyGroupFilterLogicType } from './propertyGroupFilterLogicType' +import { convertPropertiesToPropertyGroup, objectsEqual } from 'lib/utils' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' + +export const propertyGroupFilterLogic = kea([ + path(['lib', 'components', 'PropertyGroupFilters', 'propertyGroupFilterLogic']), + props({} as PropertyGroupFilterLogicProps), + key((props) => props.pageKey), + + propsChanged(({ actions, props }, oldProps) => { + if (props.value && !objectsEqual(props.value, oldProps.value)) { + actions.setFilters(props.value) + } + }), + + actions({ + update: (propertyGroupIndex?: number) => ({ propertyGroupIndex }), + setFilters: (filters: PropertyGroupFilter) => ({ filters }), + removeFilterGroup: (filterGroup: number) => ({ filterGroup }), + setOuterPropertyGroupsType: (type: FilterLogicalOperator) => ({ type }), + setPropertyFilters: (properties, index: number) => ({ properties, index }), + setInnerPropertyGroupType: (type: FilterLogicalOperator, index: number) => ({ type, index }), + duplicateFilterGroup: (propertyGroupIndex: number) => ({ propertyGroupIndex }), + addFilterGroup: true, + }), + + reducers(({ props }) => ({ + filters: [ + convertPropertiesToPropertyGroup(props.value), + { + setFilters: (_, { filters }) => filters, + addFilterGroup: (state) => { + if (!state.values) { + return { + type: FilterLogicalOperator.And, + values: [ + { + type: FilterLogicalOperator.And, + values: [{} as EmptyPropertyFilter], + }, + ], + } + } + const filterGroups = [ + ...state.values, + { type: FilterLogicalOperator.And, values: [{} as EmptyPropertyFilter] }, + ] + + return { ...state, values: filterGroups } + }, + removeFilterGroup: (state, { filterGroup }) => { + const filteredGroups = [...state.values] + filteredGroups.splice(filterGroup, 1) + return { ...state, values: filteredGroups } + }, + setOuterPropertyGroupsType: (state, { type }) => { + return { ...state, type } + }, + setPropertyFilters: (state, { properties, index }) => { + const values = [...state.values] + values[index] = { ...values[index], values: properties } + + return { ...state, values } + }, + setInnerPropertyGroupType: (state, { type, index }) => { + const values = [...state.values] + values[index] = { ...values[index], type } + return { ...state, values } + }, + duplicateFilterGroup: (state, { propertyGroupIndex }) => { + const values = state.values.concat([state.values[propertyGroupIndex]]) + return { ...state, values } + }, + }, + ], + })), + listeners(({ actions, props, values }) => ({ + setFilters: () => actions.update(), + setPropertyFilters: () => actions.update(), + setInnerPropertyGroupType: ({ type, index }) => { + eventUsageLogic.actions.reportChangeInnerPropertyGroupFiltersType( + type, + values.filters.values[index].values.length + ) + actions.update() + }, + setOuterPropertyGroupsType: ({ type }) => { + eventUsageLogic.actions.reportChangeOuterPropertyGroupFiltersType(type, values.filters.values.length) + actions.update() + }, + removeFilterGroup: () => actions.update(), + addFilterGroup: () => { + eventUsageLogic.actions.reportPropertyGroupFilterAdded() + }, + update: () => { + props.onChange(values.filters) + }, + })), + + selectors({ + propertyGroupFilter: [(s) => [s.filters], (propertyGroupFilter) => propertyGroupFilter], + }), +]) diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts b/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts new file mode 100644 index 0000000000000..4eaf9dbabb158 --- /dev/null +++ b/frontend/src/lib/logic/inAppPrompt/inAppPromptEventCaptureLogic.ts @@ -0,0 +1,76 @@ +import { kea, path, actions, listeners } from 'kea' +import type { inAppPromptEventCaptureLogicType } from './inAppPromptEventCaptureLogicType' +import posthog from 'posthog-js' +import { PromptType } from './inAppPromptLogic' + +const inAppPromptEventCaptureLogic = kea([ + path(['lib', 'logic', 'inAppPrompt', 'eventCapture']), + actions({ + reportPromptShown: (type: PromptType, sequence: string, step: number, totalSteps: number) => ({ + type, + sequence, + step, + totalSteps, + }), + reportPromptForward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }), + reportPromptBackward: (sequence: string, step: number, totalSteps: number) => ({ sequence, step, totalSteps }), + reportPromptSequenceDismissed: (sequence: string, step: number, totalSteps: number) => ({ + sequence, + step, + totalSteps, + }), + reportPromptSequenceCompleted: (sequence: string, step: number, totalSteps: number) => ({ + sequence, + step, + totalSteps, + }), + reportProductTourStarted: true, + reportProductTourSkipped: true, + }), + listeners({ + reportPromptShown: ({ type, sequence, step, totalSteps }) => { + posthog.capture('prompt shown', { + type, + sequence, + step, + totalSteps, + }) + }, + reportPromptForward: ({ sequence, step, totalSteps }) => { + posthog.capture('prompt forward', { + sequence, + step, + totalSteps, + }) + }, + reportPromptBackward: ({ sequence, step, totalSteps }) => { + posthog.capture('prompt backward', { + sequence, + step, + totalSteps, + }) + }, + reportPromptSequenceDismissed: ({ sequence, step, totalSteps }) => { + posthog.capture('prompt sequence dismissed', { + sequence, + step, + totalSteps, + }) + }, + reportPromptSequenceCompleted: ({ sequence, step, totalSteps }) => { + posthog.capture('prompt sequence completed', { + sequence, + step, + totalSteps, + }) + }, + reportProductTourStarted: () => { + posthog.capture('product tour started') + }, + reportProductTourSkipped: () => { + posthog.capture('product tour skipped') + }, + }), +]) + +export { inAppPromptEventCaptureLogic } diff --git a/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx new file mode 100644 index 0000000000000..d079aaee32d2b --- /dev/null +++ b/frontend/src/lib/logic/inAppPrompt/inAppPromptLogic.tsx @@ -0,0 +1,513 @@ +import ReactDOM from 'react-dom' +import { Placement } from '@floating-ui/react' +import { kea, path, actions, reducers, listeners, selectors, connect, afterMount, beforeUnmount } from 'kea' +import type { inAppPromptLogicType } from './inAppPromptLogicType' +import { router, urlToAction } from 'kea-router' +import { + LemonActionableTooltip, + LemonActionableTooltipProps, +} from 'lib/lemon-ui/LemonActionableTooltip/LemonActionableTooltip' +import { inAppPromptEventCaptureLogic } from './inAppPromptEventCaptureLogic' +import api from 'lib/api' +import { now } from 'lib/dayjs' +import wcmatch from 'wildcard-match' +import { + IconUnverifiedEvent, + IconApps, + IconBarChart, + IconCohort, + IconComment, + IconExperiment, + IconFlag, + IconGauge, + IconLive, + IconMessages, + IconPerson, + IconRecording, + IconTools, + IconCoffee, + IconTrendUp, +} from 'lib/lemon-ui/icons' +import { Lettermark } from 'lib/lemon-ui/Lettermark' + +/** To be extended with other types of notifications e.g. modals, bars */ +export type PromptType = 'tooltip' + +export type PromptButton = { + url?: string + action?: string + label: string +} + +export type Prompt = { + step: number + type: PromptType + text: string + placement: Placement + reference: string | null + title?: string + buttons?: PromptButton[] + icon?: string +} + +export type Tooltip = Prompt & { type: 'tooltip' } + +export type PromptSequence = { + key: string + prompts: Prompt[] + path_match: string[] + path_exclude: string[] + must_be_completed?: string[] + requires_opt_in?: boolean + type: string +} + +export type PromptConfig = { + sequences: PromptSequence[] +} + +export type PromptState = { + key: string + last_updated_at: string + step: number | null + completed?: boolean + dismissed?: boolean +} + +export type ValidSequenceWithState = { + sequence: PromptSequence + state: { step: number; completed?: boolean } +} + +export type PromptUserState = { + [key: string]: PromptState +} + +export enum DefaultAction { + NEXT = 'next', + PREVIOUS = 'previous', + START_PRODUCT_TOUR = 'start-product-tour', + SKIP = 'skip', +} + +// we show a new sequence with 1 second delay, because users immediately dismiss prompts that are invasive +const NEW_SEQUENCE_DELAY = 1000 +// make sure to change this prefix in case the schema of cached values is changed +// otherwise the code will try to run with cached deprecated values +const CACHE_PREFIX = 'v5' + +const iconMap = { + home: , + 'live-events': , + dashboard: , + insight: , + messages: , + recordings: , + 'feature-flags': , + experiments: , + 'web-performance': , + 'data-management': , + persons: , + cohorts: , + annotations: , + apps: , + toolbar: , + 'trend-up': , +} + +/** Display a with the ability to remove it from the DOM */ +function cancellableTooltipWithRetries( + tooltip: Tooltip, + onAction: (action: string) => void, + options: { maxSteps: number; onClose: () => void; next: () => void; previous: () => void } +): { close: () => void; show: Promise } { + let trigger = (): void => {} + const close = (): number => window.setTimeout(trigger, 1) + const show = new Promise((resolve, reject) => { + const div = document.createElement('div') + function destroy(): void { + const unmountResult = ReactDOM.unmountComponentAtNode(div) + if (unmountResult && div.parentNode) { + div.parentNode.removeChild(div) + } + } + + document.body.appendChild(div) + trigger = destroy + + const tryRender = function (retries: number): void { + try { + let props: LemonActionableTooltipProps = { + title: tooltip.title, + text: tooltip.text, + placement: tooltip.placement, + step: tooltip.step, + maxSteps: options.maxSteps, + next: () => { + destroy() + options.next() + }, + previous: () => { + destroy() + options.previous() + }, + close: () => { + destroy() + options.onClose() + }, + visible: true, + buttons: tooltip.buttons + ? tooltip.buttons.map((button) => { + if (button.action) { + return { + ...button, + action: () => onAction(button.action as string), + } + } + return { + url: button.url, + label: button.label, + } + }) + : [], + icon: tooltip.icon ? iconMap[tooltip.icon] : null, + } + if (tooltip.reference) { + const element = tooltip.reference + ? (document.querySelector(`[data-attr="${tooltip.reference}"]`) as HTMLElement) + : null + if (!element) { + throw 'Prompt reference element not found' + } + props = { ...props, element } + } + + ReactDOM.render(, div) + + resolve(true) + } catch (e) { + if (retries == 0) { + reject(e) + } else { + setTimeout(function () { + tryRender(retries - 1) + }, 1000) + } + } + } + tryRender(3) + }) + + return { + close, + show, + } +} + +export const inAppPromptLogic = kea([ + path(['lib', 'logic', 'inAppPrompt']), + connect(inAppPromptEventCaptureLogic), + actions({ + findValidSequences: true, + setValidSequences: (validSequences: ValidSequenceWithState[]) => ({ validSequences }), + runFirstValidSequence: (options: { runDismissedOrCompleted?: boolean }) => ({ options }), + runSequence: (sequence: PromptSequence, step: number) => ({ sequence, step }), + promptShownSuccessfully: true, + closePrompts: true, + dismissSequence: true, + clearSequence: true, + nextPrompt: true, + previousPrompt: true, + updatePromptState: (update: Partial) => ({ update }), + setUserState: (state: PromptUserState, sync = true) => ({ state, sync }), + syncState: (options: { forceRun?: boolean }) => ({ options }), + setSequences: (sequences: PromptSequence[]) => ({ sequences }), + promptAction: (action: string) => ({ action }), + optInProductTour: true, + optOutProductTour: true, + }), + reducers(() => ({ + sequences: [ + [] as PromptSequence[], + { persist: true, prefix: CACHE_PREFIX }, + { + setSequences: (_, { sequences }) => sequences, + }, + ], + currentSequence: [ + null as PromptSequence | null, + { + runSequence: (_, { sequence }) => sequence, + clearSequence: () => null, + }, + ], + currentStep: [ + 0, + { + runSequence: (_, { step }) => step, + clearSequence: () => 0, + }, + ], + userState: [ + {} as PromptUserState, + { persist: true, prefix: CACHE_PREFIX }, + { + setUserState: (_, { state }) => state, + }, + ], + canShowProductTour: [ + false, + { persist: true, prefix: CACHE_PREFIX }, + { + optInProductTour: () => true, + optOutProductTour: () => false, + }, + ], + validSequences: [ + [] as ValidSequenceWithState[], + { + setValidSequences: (_, { validSequences }) => validSequences, + }, + ], + validProductTourSequences: [ + [] as ValidSequenceWithState[], + { + setValidSequences: (_, { validSequences }) => + validSequences?.filter((v) => v.sequence.type === 'product-tour') || [], + }, + ], + isPromptVisible: [ + false, + { + promptShownSuccessfully: () => true, + closePrompts: () => false, + dismissSequence: () => false, + }, + ], + })), + selectors(() => ({ + prompts: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.prompts ?? []], + sequenceKey: [(s) => [s.currentSequence], (sequence: PromptSequence | null) => sequence?.key], + })), + listeners(({ actions, values, cache }) => ({ + syncState: async ({ options }, breakpoint) => { + await breakpoint(100) + try { + const updatedState = await api.update(`api/prompts/my_prompts`, values.userState) + if (updatedState) { + if (JSON.stringify(values.userState) !== JSON.stringify(updatedState['state'])) { + actions.setUserState(updatedState['state'], false) + } + if ( + JSON.stringify(values.sequences) !== JSON.stringify(updatedState['sequences']) || + options.forceRun + ) { + actions.setSequences(updatedState['sequences']) + } + } + } catch (error: any) { + console.error(error) + } + }, + closePrompts: () => cache.runOnClose?.(), + setSequences: actions.findValidSequences, + runSequence: async ({ sequence, step = 0 }) => { + const prompt = sequence.prompts.find((prompt) => prompt.step === step) + if (prompt) { + switch (prompt.type) { + case 'tooltip': + const { close, show } = cancellableTooltipWithRetries(prompt, actions.promptAction, { + maxSteps: values.prompts.length, + onClose: actions.dismissSequence, + previous: () => actions.promptAction(DefaultAction.PREVIOUS), + next: () => actions.promptAction(DefaultAction.NEXT), + }) + cache.runOnClose = close + + try { + await show + const updatedState: Partial = { + step: values.currentStep, + } + if (step === sequence.prompts.length - 1) { + updatedState.completed = true + inAppPromptEventCaptureLogic.actions.reportPromptSequenceCompleted( + sequence.key, + step, + values.prompts.length + ) + } + actions.updatePromptState(updatedState) + inAppPromptEventCaptureLogic.actions.reportPromptShown( + prompt.type, + sequence.key, + step, + values.prompts.length + ) + actions.promptShownSuccessfully() + } catch (e) { + console.error(e) + } + break + default: + break + } + } + }, + updatePromptState: ({ update }) => { + if (values.sequenceKey) { + const key = values.sequenceKey + const currentState = values.userState[key] || { key, step: 0 } + actions.setUserState({ + ...values.userState, + [key]: { + ...currentState, + ...update, + last_updated_at: now().toISOString(), + }, + }) + } + }, + previousPrompt: () => { + if (values.currentSequence) { + actions.runSequence(values.currentSequence, values.currentStep - 1) + inAppPromptEventCaptureLogic.actions.reportPromptBackward( + values.currentSequence.key, + values.currentStep, + values.currentSequence.prompts.length + ) + } + }, + nextPrompt: () => { + if (values.currentSequence) { + actions.runSequence(values.currentSequence, values.currentStep + 1) + inAppPromptEventCaptureLogic.actions.reportPromptForward( + values.currentSequence.key, + values.currentStep, + values.currentSequence.prompts.length + ) + } + }, + findValidSequences: () => { + const pathname = router.values.currentLocation.pathname + const valid = [] + for (const sequence of values.sequences) { + // for now the only valid rule is related to the pathname, can be extended + const must_match = [...sequence.path_match] + if (must_match.includes('/*')) { + must_match.push('/**') + } + const isMatchingPath = must_match.some((value) => wcmatch(value)(pathname)) + if (!isMatchingPath) { + continue + } + const isMatchingExclusion = sequence.path_exclude.some((value) => wcmatch(value)(pathname)) + if (isMatchingExclusion) { + continue + } + const hasOptedInToSequence = sequence.requires_opt_in ? values.canShowProductTour : true + if (!values.userState[sequence.key]) { + continue + } + const sequenceState = values.userState[sequence.key] + const completed = !!sequenceState.completed || sequenceState.step === sequence.prompts.length + const canRun = !sequenceState.dismissed && hasOptedInToSequence + if (!canRun) { + continue + } + if (sequence.type !== 'product-tour' && (completed || sequenceState.step === sequence.prompts.length)) { + continue + } + valid.push({ + sequence, + state: { + step: sequenceState.step ? sequenceState.step + 1 : 0, + completed, + }, + }) + } + actions.setValidSequences(valid) + }, + setValidSequences: () => { + if (!values.isPromptVisible) { + actions.runFirstValidSequence({}) + } + }, + runFirstValidSequence: ({ options }) => { + if (values.validSequences) { + actions.closePrompts() + let firstValid = null + if (options.runDismissedOrCompleted) { + firstValid = values.validSequences[0] + } else { + // to make it less greedy, we don't allow half-run sequences to be started automatically + firstValid = values.validSequences.filter( + (sequence) => !sequence.state.completed && sequence.state.step === 0 + )?.[0] + } + if (firstValid) { + const { sequence, state } = firstValid + setTimeout(() => actions.runSequence(sequence, state.step), NEW_SEQUENCE_DELAY) + } + } + }, + dismissSequence: () => { + if (values.sequenceKey) { + const key = values.sequenceKey + const currentState = values.userState[key] + if (currentState && !currentState.completed) { + actions.updatePromptState({ + dismissed: true, + }) + if (values.currentStep < values.prompts.length) { + inAppPromptEventCaptureLogic.actions.reportPromptSequenceDismissed( + values.sequenceKey, + values.currentStep, + values.prompts.length + ) + } + } + actions.clearSequence() + } + }, + setUserState: ({ sync }) => sync && actions.syncState({}), + promptAction: ({ action }) => { + actions.closePrompts() + switch (action) { + case DefaultAction.NEXT: + actions.nextPrompt() + break + case DefaultAction.PREVIOUS: + actions.previousPrompt() + break + case DefaultAction.START_PRODUCT_TOUR: + actions.optInProductTour() + inAppPromptEventCaptureLogic.actions.reportProductTourStarted() + actions.runFirstValidSequence({ runDismissedOrCompleted: true }) + break + case DefaultAction.SKIP: + actions.optOutProductTour() + inAppPromptEventCaptureLogic.actions.reportProductTourSkipped() + break + default: + const potentialSequence = values.sequences.find((s) => s.key === action) + if (potentialSequence) { + actions.runSequence(potentialSequence, 0) + } + break + } + }, + })), + urlToAction(({ actions }) => ({ + '*': () => { + actions.closePrompts() + if (!['login', 'signup', 'ingestion'].find((path) => router.values.location.pathname.includes(path))) { + actions.findValidSequences() + } + }, + })), + afterMount(({ actions }) => { + actions.syncState({ forceRun: true }) + }), + beforeUnmount(({ cache }) => cache.runOnClose?.()), +]) diff --git a/frontend/src/scenes/notebooks/Notebook/notebookPopoverLogic.ts b/frontend/src/scenes/notebooks/Notebook/notebookPopoverLogic.ts new file mode 100644 index 0000000000000..d9699ee1c44fe --- /dev/null +++ b/frontend/src/scenes/notebooks/Notebook/notebookPopoverLogic.ts @@ -0,0 +1,130 @@ +import { actions, kea, reducers, path, listeners, selectors } from 'kea' + +import { urlToAction } from 'kea-router' +import { RefObject } from 'react' +import posthog from 'posthog-js' +import { subscriptions } from 'kea-subscriptions' +import { EditorFocusPosition } from './utils' + +import type { notebookPopoverLogicType } from './notebookPopoverLogicType' +import { NotebookPopoverVisibility } from '~/types' + +export const MIN_NOTEBOOK_SIDEBAR_WIDTH = 600 + +export const notebookPopoverLogic = kea([ + path(['scenes', 'notebooks', 'Notebook', 'notebookPopoverLogic']), + actions({ + setFullScreen: (full: boolean) => ({ full }), + selectNotebook: (id: string) => ({ id }), + setInitialAutofocus: (position: EditorFocusPosition) => ({ position }), + setElementRef: (element: RefObject) => ({ element }), + setVisibility: (visibility: NotebookPopoverVisibility) => ({ visibility }), + startDropMode: true, + endDropMode: true, + }), + + reducers(() => ({ + selectedNotebook: [ + 'scratchpad', + { persist: true }, + { + selectNotebook: (_, { id }) => id, + }, + ], + visibility: [ + 'hidden' as NotebookPopoverVisibility, + { + setVisibility: (_, { visibility }) => visibility, + }, + ], + fullScreen: [ + false, + { + setFullScreen: (_, { full }) => full, + setVisibility: (state, { visibility }) => (visibility === 'hidden' ? false : state), + }, + ], + initialAutofocus: [ + null as EditorFocusPosition, + { + selectNotebook: () => null, + setInitialAutofocus: (_, { position }) => position, + }, + ], + elementRef: [ + null as RefObject | null, + { + setElementRef: (_, { element }) => element, + }, + ], + shownAtLeastOnce: [ + false, + { + setVisibility: (state, { visibility }) => visibility !== 'hidden' || state, + }, + ], + dropMode: [ + false, + { + startDropMode: () => true, + endDropMode: () => false, + }, + ], + })), + + selectors(({ cache, actions }) => ({ + dropListeners: [ + (s) => [s.dropMode], + (dropMode): { onDragEnter?: () => void; onDragLeave?: () => void } => { + return dropMode + ? { + onDragEnter: () => { + cache.dragEntercount = (cache.dragEntercount || 0) + 1 + if (cache.dragEntercount === 1) { + actions.setVisibility('visible') + } + }, + + onDragLeave: () => { + cache.dragEntercount = (cache.dragEntercount || 0) - 1 + + if (cache.dragEntercount <= 0) { + cache.dragEntercount = 0 + actions.setVisibility('peek') + } + }, + } + : {} + }, + ], + })), + + subscriptions({ + visibility: (value, oldvalue) => { + if (oldvalue !== undefined && value !== oldvalue) { + posthog.capture(`notebook sidebar ${value}`) + } + }, + }), + + listeners(({ cache, actions, values }) => ({ + startDropMode: () => { + cache.dragEntercount = 0 + actions.setVisibility('peek') + }, + endDropMode: () => { + if (values.visibility === 'peek') { + actions.setVisibility('hidden') + } + }, + })), + + urlToAction(({ actions, values }) => ({ + '/*': () => { + // Any navigation should trigger exiting full screen + if (values.visibility === 'visible') { + actions.setVisibility('hidden') + } + }, + })), +]) diff --git a/frontend/src/scenes/organization/Settings/inviteLogic.ts b/frontend/src/scenes/organization/Settings/inviteLogic.ts new file mode 100644 index 0000000000000..cfb7811a457e6 --- /dev/null +++ b/frontend/src/scenes/organization/Settings/inviteLogic.ts @@ -0,0 +1,142 @@ +import { kea } from 'kea' +import { OrganizationInviteType } from '~/types' +import api from 'lib/api' +import { organizationLogic } from 'scenes/organizationLogic' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import type { inviteLogicType } from './inviteLogicType' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' +import { router } from 'kea-router' +import { lemonToast } from 'lib/lemon-ui/lemonToast' + +/** State of a single invite row (with input data) in bulk invite creation. */ +export interface InviteRowState { + target_email: string + first_name: string + isValid: boolean + message?: string +} + +const EMPTY_INVITE: InviteRowState = { target_email: '', first_name: '', isValid: true } + +export const inviteLogic = kea({ + path: ['scenes', 'organization', 'Settings', 'inviteLogic'], + actions: { + showInviteModal: true, + hideInviteModal: true, + updateInviteAtIndex: (payload, index: number) => ({ payload, index }), + deleteInviteAtIndex: (index: number) => ({ index }), + updateMessage: (message: string) => ({ message }), + appendInviteRow: true, + resetInviteRows: true, + }, + connect: { + values: [preflightLogic, ['preflight']], + actions: [router, ['locationChanged']], + }, + reducers: () => ({ + isInviteModalShown: [ + false, + { + showInviteModal: () => true, + hideInviteModal: () => false, + locationChanged: () => false, + }, + ], + invitesToSend: [ + [EMPTY_INVITE] as InviteRowState[], + { + updateInviteAtIndex: (state, { payload, index }) => { + const newState = [...state] + newState[index] = { ...state[index], ...payload } + return newState + }, + deleteInviteAtIndex: (state, { index }) => { + const newState = [...state] + newState.splice(index, 1) + return newState + }, + appendInviteRow: (state) => [...state, EMPTY_INVITE], + resetInviteRows: () => [EMPTY_INVITE], + inviteTeamMembersSuccess: () => [EMPTY_INVITE], + }, + ], + message: [ + '', + { + updateMessage: (_, { message }) => message, + }, + ], + }), + selectors: { + canSubmit: [ + (selectors) => [selectors.invitesToSend], + (invites: InviteRowState[]) => + invites.filter(({ target_email }) => !!target_email).length > 0 && + invites.filter(({ isValid }) => !isValid).length == 0, + ], + }, + loaders: ({ values }) => ({ + invitedTeamMembersInternal: [ + [] as OrganizationInviteType[], + { + inviteTeamMembers: async () => { + if (!values.canSubmit) { + return { invites: [] } + } + + const payload: Pick[] = + values.invitesToSend.filter((invite) => invite.target_email) + eventUsageLogic.actions.reportBulkInviteAttempted( + payload.length, + payload.filter((invite) => !!invite.first_name).length + ) + if (values.message) { + payload.forEach((payload) => (payload.message = values.message)) + } + return await api.create('api/organizations/@current/invites/bulk/', payload) + }, + }, + ], + invites: [ + [] as OrganizationInviteType[], + { + loadInvites: async () => { + return (await api.get('api/organizations/@current/invites/')).results + }, + deleteInvite: async (invite: OrganizationInviteType) => { + await api.delete(`api/organizations/@current/invites/${invite.id}/`) + preflightLogic.actions.loadPreflight() // Make sure licensed_users_available is updated + lemonToast.success(`Invite for ${invite.target_email} has been canceled`) + return values.invites.filter((thisInvite) => thisInvite.id !== invite.id) + }, + }, + ], + }), + listeners: ({ values, actions }) => ({ + inviteTeamMembersSuccess: (): void => { + const inviteCount = values.invitedTeamMembersInternal.length + if (values.preflight?.email_service_available) { + lemonToast.success(`Invited ${inviteCount} new team member${inviteCount === 1 ? '' : 's'}`) + } else { + lemonToast.success('Team invite links generated') + } + + organizationLogic.actions.loadCurrentOrganization() + actions.loadInvites() + + if (values.preflight?.email_service_available) { + actions.hideInviteModal() + } + }, + }), + events: ({ actions }) => ({ + afterMount: [actions.loadInvites], + }), + urlToAction: ({ actions }) => ({ + '*': (_, searchParams) => { + if (searchParams.invite_modal) { + actions.showInviteModal() + } + }, + }), +}) diff --git a/posthog/api/capture.py b/posthog/api/capture.py index b00c363e6de16..cc69ea14a5a01 100644 --- a/posthog/api/capture.py +++ b/posthog/api/capture.py @@ -1,3 +1,4 @@ +import dataclasses import hashlib import json import re @@ -261,9 +262,16 @@ def drop_performance_events(events: List[Any]) -> List[Any]: return cleaned_list -def drop_events_over_quota(token: str, events: List[Any]) -> List[Any]: +@dataclasses.dataclass(frozen=True) +class EventsOverQuotaResult: + events: List[Any] + events_were_limited: bool + recordings_were_limited: bool + + +def drop_events_over_quota(token: str, events: List[Any]) -> EventsOverQuotaResult: if not settings.EE_AVAILABLE: - return events + return EventsOverQuotaResult(events, False, False) from ee.billing.quota_limiting import QuotaResource, list_limited_team_attributes @@ -271,12 +279,15 @@ def drop_events_over_quota(token: str, events: List[Any]) -> List[Any]: limited_tokens_events = list_limited_team_attributes(QuotaResource.EVENTS) limited_tokens_recordings = list_limited_team_attributes(QuotaResource.RECORDINGS) + recordings_were_limited = False + events_were_limited = False for event in events: if event.get("event") in SESSION_RECORDING_EVENT_NAMES: EVENTS_RECEIVED_COUNTER.labels(resource_type="recordings").inc() if token in limited_tokens_recordings: EVENTS_DROPPED_OVER_QUOTA_COUNTER.labels(resource_type="recordings", token=token).inc() if settings.QUOTA_LIMITING_ENABLED: + recordings_were_limited = True continue else: @@ -284,11 +295,14 @@ def drop_events_over_quota(token: str, events: List[Any]) -> List[Any]: if token in limited_tokens_events: EVENTS_DROPPED_OVER_QUOTA_COUNTER.labels(resource_type="events", token=token).inc() if settings.QUOTA_LIMITING_ENABLED: + events_were_limited = True continue results.append(event) - return results + return EventsOverQuotaResult( + results, events_were_limited=events_were_limited, recordings_were_limited=recordings_were_limited + ) @csrf_exempt @@ -375,10 +389,15 @@ def get_event(request): except Exception as e: capture_exception(e) + # TODO we're not going to return 429 on events before we audit our SDKs + # events_were_quota_limited = False + recordings_were_quota_limited = False try: - events = drop_events_over_quota(token, events) + events_over_quota_result = drop_events_over_quota(token, events) + events = events_over_quota_result.events + # events_were_quota_limited = events_over_quota_result.events_were_limited + recordings_were_quota_limited = events_over_quota_result.recordings_were_limited except Exception as e: - # NOTE: Whilst we are testing this code we want to track exceptions but allow the events through if anything goes wrong capture_exception(e) try: @@ -504,7 +523,13 @@ def get_event(request): pass statsd.incr("posthog_cloud_raw_endpoint_success", tags={"endpoint": "capture"}) - return cors_response(request, JsonResponse({"status": 1})) + + response_payload: Dict[str, Any] = {"status": 1} + + if recordings_were_quota_limited: + response_payload["quota_limited"] = ["recordings"] + + return cors_response(request, JsonResponse(response_payload)) def preprocess_events(events: List[Dict[str, Any]]) -> Iterator[Tuple[Dict[str, Any], UUIDT, str]]: diff --git a/posthog/api/test/test_capture.py b/posthog/api/test/test_capture.py index 73654abf7a269..4a9d86aeb9a81 100644 --- a/posthog/api/test/test_capture.py +++ b/posthog/api/test/test_capture.py @@ -8,7 +8,7 @@ from collections import Counter from datetime import datetime, timedelta from datetime import timezone as tz -from typing import Any, Dict, List, Union, cast +from typing import Any, Dict, List, Union, cast, Tuple from unittest import mock from unittest.mock import ANY, MagicMock, call, patch from urllib.parse import quote @@ -190,10 +190,10 @@ def _send_original_version_session_recording_event( window_id="def456", distinct_id="ghi789", timestamp=1658516991883, - ) -> dict: + expected_status_code: int = status.HTTP_200_OK, + ) -> Tuple[dict, HttpResponse]: if event_data is None: event_data = {} - event = { "event": "$snapshot", "properties": { @@ -209,15 +209,16 @@ def _send_original_version_session_recording_event( "offset": 1993, } - self.client.post( + capture_recording_response = self.client.post( "/s/", data={ "data": json.dumps([event for _ in range(number_of_events)]), "api_key": self.team.api_token, }, ) + assert capture_recording_response.status_code == expected_status_code - return event + return event, capture_recording_response def _send_august_2023_version_session_recording_event( self, diff --git a/posthog/exceptions.py b/posthog/exceptions.py index 0dd73cb403400..5b52fb98d1e02 100644 --- a/posthog/exceptions.py +++ b/posthog/exceptions.py @@ -65,10 +65,13 @@ def generate_exception_response( type: str = "validation_error", attr: Optional[str] = None, status_code: int = status.HTTP_400_BAD_REQUEST, + headers: Optional[dict] = None, ) -> JsonResponse: """ Generates a friendly JSON error response in line with drf-exceptions-hog for endpoints not under DRF. """ + if headers is None: + headers = {} # Importing here because this module is loaded before Django settings are configured, # and statshog relies on those being ready @@ -78,4 +81,6 @@ def generate_exception_response( f"posthog_cloud_raw_endpoint_exception", tags={"endpoint": endpoint, "code": code, "type": type, "attr": attr}, ) - return JsonResponse({"type": type, "code": code, "detail": detail, "attr": attr}, status=status_code) + return JsonResponse( + {"type": type, "code": code, "detail": detail, "attr": attr}, status=status_code, headers=headers + ) diff --git a/posthog/hogql/transforms/test/__snapshots__/test_in_cohort.ambr b/posthog/hogql/transforms/test/__snapshots__/test_in_cohort.ambr index 98abb1ceb6030..d45052c06889a 100644 --- a/posthog/hogql/transforms/test/__snapshots__/test_in_cohort.ambr +++ b/posthog/hogql/transforms/test/__snapshots__/test_in_cohort.ambr @@ -31,7 +31,7 @@ FROM events LEFT JOIN ( SELECT person_static_cohort.person_id AS cohort_person_id, 1 AS matched, person_static_cohort.cohort_id AS cohort_id FROM person_static_cohort - WHERE and(equals(person_static_cohort.team_id, 420), in(person_static_cohort.cohort_id, [2]))) AS __in_cohort ON equals(__in_cohort.cohort_person_id, events.person_id) + WHERE and(equals(person_static_cohort.team_id, 420), in(person_static_cohort.cohort_id, [16]))) AS __in_cohort ON equals(__in_cohort.cohort_person_id, events.person_id) WHERE and(equals(events.team_id, 420), 1, ifNull(equals(__in_cohort.matched, 1), 0)) LIMIT 100 SETTINGS readonly=2, max_execution_time=60, allow_experimental_object_type=1 @@ -42,7 +42,7 @@ FROM events LEFT JOIN ( SELECT person_id AS cohort_person_id, 1 AS matched, cohort_id FROM static_cohort_people - WHERE in(cohort_id, [2])) AS __in_cohort ON equals(__in_cohort.cohort_person_id, person_id) + WHERE in(cohort_id, [16])) AS __in_cohort ON equals(__in_cohort.cohort_person_id, person_id) WHERE and(1, equals(__in_cohort.matched, 1)) LIMIT 100 ''' @@ -55,7 +55,7 @@ FROM events LEFT JOIN ( SELECT person_static_cohort.person_id AS cohort_person_id, 1 AS matched, person_static_cohort.cohort_id AS cohort_id FROM person_static_cohort - WHERE and(equals(person_static_cohort.team_id, 420), in(person_static_cohort.cohort_id, [3]))) AS __in_cohort ON equals(__in_cohort.cohort_person_id, events.person_id) + WHERE and(equals(person_static_cohort.team_id, 420), in(person_static_cohort.cohort_id, [17]))) AS __in_cohort ON equals(__in_cohort.cohort_person_id, events.person_id) WHERE and(equals(events.team_id, 420), 1, ifNull(equals(__in_cohort.matched, 1), 0)) LIMIT 100 SETTINGS readonly=2, max_execution_time=60, allow_experimental_object_type=1 @@ -66,7 +66,7 @@ FROM events LEFT JOIN ( SELECT person_id AS cohort_person_id, 1 AS matched, cohort_id FROM static_cohort_people - WHERE in(cohort_id, [3])) AS __in_cohort ON equals(__in_cohort.cohort_person_id, person_id) + WHERE in(cohort_id, [17])) AS __in_cohort ON equals(__in_cohort.cohort_person_id, person_id) WHERE and(1, equals(__in_cohort.matched, 1)) LIMIT 100 ''' diff --git a/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel.ambr b/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel.ambr index 4a8a029323773..14d50251dbeca 100644 --- a/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel.ambr +++ b/posthog/hogql_queries/insights/funnels/test/__snapshots__/test_funnel.ambr @@ -350,7 +350,7 @@ if(and(equals(e.event, 'user signed up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 8)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 2)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), 1, 0) AS step_0, if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, @@ -871,7 +871,7 @@ if(and(equals(e.event, 'user signed up'), ifNull(in(e__pdi.person_id, (SELECT person_static_cohort.person_id AS person_id FROM person_static_cohort - WHERE and(equals(person_static_cohort.team_id, 2), equals(person_static_cohort.cohort_id, 9)))), 0)), 1, 0) AS step_0, + WHERE and(equals(person_static_cohort.team_id, 2), equals(person_static_cohort.cohort_id, 3)))), 0)), 1, 0) AS step_0, if(ifNull(equals(step_0, 1), 0), timestamp, NULL) AS latest_0, if(equals(e.event, 'paid'), 1, 0) AS step_1, if(ifNull(equals(step_1, 1), 0), timestamp, NULL) AS latest_1 diff --git a/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr b/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr index b55778fa0d1fa..ef3b23794866d 100644 --- a/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr +++ b/posthog/hogql_queries/insights/test/__snapshots__/test_lifecycle_query_runner.ambr @@ -79,7 +79,7 @@ WHERE and(equals(events.team_id, 2), greaterOrEquals(toTimeZone(events.timestamp, 'UTC'), minus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 00:00:00', 6, 'UTC'))), toIntervalDay(1))), less(toTimeZone(events.timestamp, 'UTC'), plus(toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-19 23:59:59', 6, 'UTC'))), toIntervalDay(1))), ifNull(in(person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 10)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 4)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0), equals(events.event, '$pageview')) GROUP BY person_id) diff --git a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr index 64d6dcbc02e7b..d9e0cd6ed6abf 100644 --- a/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr +++ b/posthog/hogql_queries/insights/trends/test/__snapshots__/test_trends.ambr @@ -85,7 +85,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC'))), ifNull(equals(e__pdi__person.`properties___$bool_prop`, 'x'), 0), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 11)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 5)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY day_start) @@ -172,7 +172,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-07 23:59:59', 6, 'UTC'))), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.person_properties, '$bool_prop'), ''), 'null'), '^"|"$', ''), 'x'), 0), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 12)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 6)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY day_start) @@ -688,7 +688,7 @@ WHERE and(equals(e.team_id, 2), and(equals(e.event, '$pageview'), and(or(ifNull(equals(e__pdi__person.properties___name, 'p1'), 0), ifNull(equals(e__pdi__person.properties___name, 'p2'), 0), ifNull(equals(e__pdi__person.properties___name, 'p3'), 0)), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 31)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 25)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)))) GROUP BY value @@ -757,7 +757,7 @@ WHERE and(equals(e.team_id, 2), and(and(equals(e.event, '$pageview'), and(or(ifNull(equals(e__pdi__person.properties___name, 'p1'), 0), ifNull(equals(e__pdi__person.properties___name, 'p2'), 0), ifNull(equals(e__pdi__person.properties___name, 'p3'), 0)), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 31)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 25)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'val'], ['$$_posthog_breakdown_other_$$', 'val'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, 'key'), ''), 'null'), '^"|"$', ''), 'val'), 0))), ifNull(greaterOrEquals(timestamp, minus(assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-01 00:00:00', 6, 'UTC')), toIntervalDay(7))), 0), ifNull(lessOrEquals(timestamp, assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-12 23:59:59', 6, 'UTC'))), 0)) GROUP BY timestamp, actor_id, @@ -1592,7 +1592,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 44)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 38)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY value @@ -1640,7 +1640,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(e__pdi.person_id, (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 44)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 38)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start, @@ -1691,7 +1691,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 45)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 39)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0))) GROUP BY value @@ -1738,7 +1738,7 @@ WHERE and(equals(e.team_id, 2), greaterOrEquals(toTimeZone(e.timestamp, 'UTC'), toStartOfDay(assumeNotNull(parseDateTime64BestEffortOrNull('2019-12-28 00:00:00', 6, 'UTC')))), lessOrEquals(toTimeZone(e.timestamp, 'UTC'), assumeNotNull(parseDateTime64BestEffortOrNull('2020-01-04 23:59:59', 6, 'UTC'))), and(equals(e.event, 'sign up'), ifNull(in(ifNull(nullIf(e__override.override_person_id, '00000000-0000-0000-0000-000000000000'), e.person_id), (SELECT cohortpeople.person_id AS person_id FROM cohortpeople - WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 45)) + WHERE and(equals(cohortpeople.team_id, 2), equals(cohortpeople.cohort_id, 39)) GROUP BY cohortpeople.person_id, cohortpeople.cohort_id, cohortpeople.version HAVING ifNull(greater(sum(cohortpeople.sign), 0), 0))), 0)), or(ifNull(equals(transform(ifNull(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), '$$_posthog_breakdown_null_$$'), ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], ['$$_posthog_breakdown_other_$$', 'value', 'other_value'], '$$_posthog_breakdown_other_$$'), '$$_posthog_breakdown_other_$$'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'value'), 0), ifNull(equals(replaceRegexpAll(nullIf(nullIf(JSONExtractRaw(e.properties, '$some_property'), ''), 'null'), '^"|"$', ''), 'other_value'), 0))) GROUP BY day_start,