diff --git a/cypress/e2e/surveys.cy.ts b/cypress/e2e/surveys.cy.ts
index 28082edb3de..1cccfb545fc 100644
--- a/cypress/e2e/surveys.cy.ts
+++ b/cypress/e2e/surveys.cy.ts
@@ -269,6 +269,7 @@ describe('Surveys', () => {
// Set responses limit
cy.get('.LemonCollapsePanel').contains('Completion conditions').click()
+ cy.get('[data-attr=survey-collection-until-limit]').first().click()
cy.get('[data-attr=survey-responses-limit-input]').focus().type('228').click()
// Save the survey
@@ -276,7 +277,7 @@ describe('Surveys', () => {
cy.get('button[data-attr="launch-survey"]').should('have.text', 'Launch')
cy.reload()
- cy.contains('The survey will be stopped once 228 responses are received.').should('be.visible')
+ cy.contains('The survey will be stopped once 100228 responses are received.').should('be.visible')
})
it('creates a new survey with branching logic', () => {
diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index 881e9691b4f..b3edb1d6971 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -169,6 +169,7 @@ export const FEATURE_FLAGS = {
SURVEYS_EVENTS: 'surveys-events', // owner: #team-feature-success
SURVEYS_ACTIONS: 'surveys-actions', // owner: #team-feature-success
SURVEYS_RECURRING: 'surveys-recurring', // owner: #team-feature-success
+ SURVEYS_ADAPTIVE_COLLECTION: 'surveys-recurring', // owner: #team-feature-success
YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay
SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay
DISCUSSIONS: 'discussions', // owner: #team-replay
diff --git a/frontend/src/scenes/surveys/SurveyEdit.tsx b/frontend/src/scenes/surveys/SurveyEdit.tsx
index 89f6eef2c22..28a2b8d9205 100644
--- a/frontend/src/scenes/surveys/SurveyEdit.tsx
+++ b/frontend/src/scenes/surveys/SurveyEdit.tsx
@@ -6,6 +6,7 @@ import { IconInfo } from '@posthog/icons'
import { IconLock, IconPlus, IconTrash } from '@posthog/icons'
import {
LemonButton,
+ LemonCalendarSelect,
LemonCheckbox,
LemonCollapse,
LemonDialog,
@@ -15,17 +16,21 @@ import {
LemonTag,
LemonTextArea,
Link,
+ Popover,
} from '@posthog/lemon-ui'
import { BindLogic, useActions, useValues } from 'kea'
import { EventSelect } from 'lib/components/EventSelect/EventSelect'
import { FlagSelector } from 'lib/components/FlagSelector'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { FEATURE_FLAGS } from 'lib/constants'
+import { dayjs } from 'lib/dayjs'
import { IconCancel } from 'lib/lemon-ui/icons'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonRadio } from 'lib/lemon-ui/LemonRadio'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { featureFlagLogic as enabledFeaturesLogic } from 'lib/logic/featureFlagLogic'
+import { formatDate } from 'lib/utils'
+import { useMemo, useState } from 'react'
import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic'
import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagReleaseConditions'
@@ -62,15 +67,21 @@ export default function SurveyEdit(): JSX.Element {
schedule,
hasBranchingLogic,
surveyRepeatedActivationAvailable,
+ dataCollectionType,
+ surveyUsesLimit,
+ surveyUsesAdaptiveLimit,
} = useValues(surveyLogic)
const {
setSurveyValue,
resetTargeting,
+ resetSurveyResponseLimits,
+ resetSurveyAdaptiveSampling,
setSelectedPageIndex,
setSelectedSection,
setFlagPropertyErrors,
setSchedule,
deleteBranchingLogic,
+ setDataCollectionType,
} = useActions(surveyLogic)
const {
surveysMultipleQuestionsAvailable,
@@ -79,11 +90,25 @@ export default function SurveyEdit(): JSX.Element {
surveysActionsAvailable,
} = useValues(surveysLogic)
const { featureFlags } = useValues(enabledFeaturesLogic)
+ const [visible, setVisible] = useState(false)
const sortedItemIds = survey.questions.map((_, idx) => idx.toString())
const { thankYouMessageDescriptionContentType = null } = survey.appearance ?? {}
const surveysRecurringScheduleDisabledReason = surveysRecurringScheduleAvailable
? undefined
: 'Upgrade your plan to use repeating surveys'
+ const surveysAdaptiveLimitsDisabledReason = surveysRecurringScheduleAvailable
+ ? undefined
+ : 'Upgrade your plan to use an adaptive limit on survey responses'
+
+ useMemo(() => {
+ if (surveyUsesLimit) {
+ setDataCollectionType('until_limit')
+ } else if (surveyUsesAdaptiveLimit) {
+ setDataCollectionType('until_adaptive_limit')
+ } else {
+ setDataCollectionType('until_stopped')
+ }
+ }, [surveyUsesLimit, surveyUsesAdaptiveLimit, setDataCollectionType])
if (survey.iteration_count && survey.iteration_count > 0) {
setSchedule('recurring')
@@ -852,44 +877,157 @@ export default function SurveyEdit(): JSX.Element {
header: 'Completion conditions',
content: (
<>
-
- {({ onChange, value }) => {
- return (
-
- {
- const newResponsesLimit = checked ? 100 : null
- onChange(newResponsesLimit)
- }}
- />
- Stop the survey once
- {
- if (newValue && newValue > 0) {
- onChange(newValue)
- } else {
- onChange(null)
- }
- }}
- className="w-16"
- />{' '}
- responses are received.
-
-
-
-
- )
- }}
-
+
+
How long would you like to collect survey responses?
+
+ {
+ if (newValue === 'until_limit') {
+ resetSurveyAdaptiveSampling()
+ setSurveyValue('responses_limit', survey.responses_limit || 100)
+ } else if (newValue === 'until_adaptive_limit') {
+ resetSurveyResponseLimits()
+ setSurveyValue(
+ 'response_sampling_interval',
+ survey.response_sampling_interval || 1
+ )
+ setSurveyValue(
+ 'response_sampling_interval_type',
+ survey.response_sampling_interval_type || 'month'
+ )
+ setSurveyValue(
+ 'response_sampling_limit',
+ survey.response_sampling_limit || 100
+ )
+ setSurveyValue(
+ 'response_sampling_start_date',
+ survey.response_sampling_start_date || dayjs()
+ )
+ } else {
+ resetSurveyResponseLimits()
+ resetSurveyAdaptiveSampling()
+ }
+ setDataCollectionType(newValue)
+ }}
+ options={[
+ {
+ value: 'until_stopped',
+ label: 'Keep collecting responses until the survey is stopped',
+ 'data-attr': 'survey-collection-until-stopped',
+ },
+ {
+ value: 'until_limit',
+ label: 'Stop displaying the survey after reaching a certain number of completed surveys',
+ 'data-attr': 'survey-collection-until-limit',
+ },
+ {
+ value: 'until_adaptive_limit',
+ label: 'Collect a certain number of surveys per day, week or month',
+ 'data-attr': 'survey-collection-until-adaptive-limit',
+ disabledReason: surveysAdaptiveLimitsDisabledReason,
+ },
+ ]}
+ />
+
+
+ {dataCollectionType == 'until_adaptive_limit' && (
+
+
+ Starting on{' '}
+
{
+ setSurveyValue('response_sampling_start_date', value)
+ setVisible(false)
+ }}
+ showTimeToggle={false}
+ onClose={() => setVisible(false)}
+ />
+ }
+ visible={visible}
+ onClickOutside={() => setVisible(false)}
+ >
+ setVisible(!visible)}>
+ {formatDate(dayjs(survey.response_sampling_start_date || ''))}
+
+
+ , capture up to
+
{
+ setSurveyValue('response_sampling_limit', newValue)
+ }}
+ value={survey.response_sampling_limit || 0}
+ />
+ responses, every
+ {
+ setSurveyValue('response_sampling_interval', newValue)
+ }}
+ value={survey.response_sampling_interval || 0}
+ />
+ {
+ setSurveyValue('response_sampling_interval_type', newValue)
+ }}
+ options={[
+ { value: 'day', label: 'Day(s)' },
+ { value: 'week', label: 'Week(s)' },
+ { value: 'month', label: 'Month(s)' },
+ ]}
+ />
+
+
+
+
+
+ )}
+ {dataCollectionType == 'until_limit' && (
+
+ {({ onChange, value }) => {
+ return (
+
+ Stop the survey once
+ {
+ if (newValue && newValue > 0) {
+ onChange(newValue)
+ } else {
+ onChange(null)
+ }
+ }}
+ className="w-16"
+ />{' '}
+ responses are received.
+
+
+
+
+ )
+ }}
+
+ )}
{featureFlags[FEATURE_FLAGS.SURVEYS_RECURRING] && (
-
-
How often should we show this survey?
+
+
How often should we show this survey?
) : null}
- {survey.responses_limit && (
+ {surveyUsesLimit && (
<>
Completion conditions
@@ -351,6 +352,17 @@ export function SurveyView({ id }: { id: string }): JSX.Element {
>
)}
+ {surveyUsesAdaptiveLimit && (
+ <>
+
Completion conditions
+
+ Survey response collection is limited to receive{' '}
+ {survey.response_sampling_limit} responses every{' '}
+ {survey.response_sampling_interval}{' '}
+ {survey.response_sampling_interval_type}(s).
+
+ >
+ )}
{
id: 'new'
linked_flag_id: number | null
diff --git a/frontend/src/scenes/surveys/surveyLogic.tsx b/frontend/src/scenes/surveys/surveyLogic.tsx
index 528aac6db6e..65345656590 100644
--- a/frontend/src/scenes/surveys/surveyLogic.tsx
+++ b/frontend/src/scenes/surveys/surveyLogic.tsx
@@ -105,6 +105,7 @@ export interface QuestionResultsReady {
[key: string]: boolean
}
+export type DataCollectionType = 'until_stopped' | 'until_limit' | 'until_adaptive_limit'
export type ScheduleType = 'once' | 'recurring'
const getResponseField = (i: number): string => (i === 0 ? '$survey_response' : `$survey_response_${i}`)
@@ -168,6 +169,9 @@ export const surveyLogic = kea([
nextStep,
specificQuestionIndex,
}),
+ setDataCollectionType: (dataCollectionType: DataCollectionType) => ({
+ dataCollectionType,
+ }),
resetBranchingForQuestion: (questionIndex) => ({ questionIndex }),
deleteBranchingLogic: true,
archiveSurvey: true,
@@ -178,6 +182,8 @@ export const surveyLogic = kea([
setSchedule: (schedule: ScheduleType) => ({ schedule }),
resetTargeting: true,
+ resetSurveyAdaptiveSampling: true,
+ resetSurveyResponseLimits: true,
setFlagPropertyErrors: (errors: any) => ({ errors }),
}),
loaders(({ props, actions, values }) => ({
@@ -608,6 +614,19 @@ export const surveyLogic = kea([
loadSurveySuccess: () => {
actions.loadSurveyUserStats()
},
+ resetSurveyResponseLimits: () => {
+ actions.setSurveyValue('responses_limit', null)
+ },
+
+ resetSurveyAdaptiveSampling: () => {
+ actions.setSurveyValues({
+ response_sampling_interval: null,
+ response_sampling_interval_type: null,
+ response_sampling_limit: null,
+ response_sampling_start_date: null,
+ response_sampling_daily_limits: null,
+ })
+ },
resetTargeting: () => {
actions.setSurveyValue('linked_flag_id', NEW_SURVEY.linked_flag_id)
actions.setSurveyValue('targeting_flag_filters', NEW_SURVEY.targeting_flag_filters)
@@ -647,6 +666,12 @@ export const surveyLogic = kea([
setSurveyMissing: () => true,
},
],
+ dataCollectionType: [
+ 'until_stopped' as DataCollectionType,
+ {
+ setDataCollectionType: (_, { dataCollectionType }) => dataCollectionType,
+ },
+ ],
survey: [
{ ...NEW_SURVEY } as NewSurvey | Survey,
@@ -877,6 +902,24 @@ export const surveyLogic = kea([
return !!(survey.start_date && !survey.end_date)
},
],
+ surveyUsesLimit: [
+ (s) => [s.survey],
+ (survey: Survey): boolean => {
+ return !!(survey.responses_limit && survey.responses_limit > 0)
+ },
+ ],
+ surveyUsesAdaptiveLimit: [
+ (s) => [s.survey],
+ (survey: Survey): boolean => {
+ return !!(
+ survey.response_sampling_interval &&
+ survey.response_sampling_interval > 0 &&
+ survey.response_sampling_interval_type !== '' &&
+ survey.response_sampling_limit &&
+ survey.response_sampling_limit > 0
+ )
+ },
+ ],
surveyShufflingQuestionsAvailable: [
(s) => [s.survey],
(survey: Survey): boolean => {
@@ -1022,6 +1065,7 @@ export const surveyLogic = kea([
}
},
],
+
getBranchingDropdownValue: [
(s) => [s.survey],
(survey) => (questionIndex: number, question: RatingSurveyQuestion | MultipleSurveyQuestion) => {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 94f5aae7176..24c25f4df48 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2767,6 +2767,11 @@ export interface Survey {
iteration_start_dates?: string[]
current_iteration?: number | null
current_iteration_start_date?: string
+ response_sampling_start_date?: string | null
+ response_sampling_interval_type?: string | null
+ response_sampling_interval?: number | null
+ response_sampling_limit?: number | null
+ response_sampling_daily_limits?: string[] | null
}
export enum SurveyUrlMatchType {
diff --git a/posthog/tasks/test/test_update_survey_adaptive_sampling.py b/posthog/tasks/test/test_update_survey_adaptive_sampling.py
index cd7964ffe15..91696cf1ce8 100644
--- a/posthog/tasks/test/test_update_survey_adaptive_sampling.py
+++ b/posthog/tasks/test/test_update_survey_adaptive_sampling.py
@@ -1,3 +1,4 @@
+import json
from unittest.mock import patch, MagicMock
from datetime import datetime
from django.utils import timezone
@@ -39,6 +40,18 @@ class TestUpdateSurveyAdaptiveSampling(BaseTest):
self.assertEqual(internal_response_sampling_flag.rollout_percentage, 20)
mock_get_count.assert_called_once_with(self.survey.id)
+ @freeze_time("2024-12-21T12:00:00Z")
+ @patch("posthog.tasks.update_survey_adaptive_sampling._get_survey_responses_count")
+ def test_updates_rollout_after_interval_is_over(self, mock_get_count: MagicMock) -> None:
+ mock_get_count.return_value = 50
+ update_survey_adaptive_sampling()
+ internal_response_sampling_flag = FeatureFlag.objects.get(id=self.internal_response_sampling_flag.id)
+ self.assertEqual(internal_response_sampling_flag.rollout_percentage, 100)
+ mock_get_count.assert_called_once_with(self.survey.id)
+ survey = Survey.objects.get(id=self.survey.id)
+ response_sampling_daily_limits = json.loads(survey.response_sampling_daily_limits)
+ self.assertEqual(response_sampling_daily_limits[0].get("date"), "2024-12-22")
+
@freeze_time("2024-12-13T12:00:00Z")
@patch("posthog.tasks.update_survey_adaptive_sampling._get_survey_responses_count")
def test_no_update_when_limit_reached(self, mock_get_count: MagicMock) -> None:
diff --git a/posthog/tasks/update_survey_adaptive_sampling.py b/posthog/tasks/update_survey_adaptive_sampling.py
index bdd7d4ed048..bedf79f7f3c 100644
--- a/posthog/tasks/update_survey_adaptive_sampling.py
+++ b/posthog/tasks/update_survey_adaptive_sampling.py
@@ -1,5 +1,5 @@
import json
-from datetime import datetime
+from datetime import datetime, timedelta
from django.utils.timezone import now
from posthog.clickhouse.client import sync_execute
@@ -29,6 +29,12 @@ def _update_survey_adaptive_sampling(survey: Survey) -> None:
internal_response_sampling_flag.rollout_percentage = today_entry["rollout_percentage"]
internal_response_sampling_flag.save()
+ # this also doubles as a way to check that we're processing the final entry in the current sequence.
+ if today_entry["rollout_percentage"] == 100:
+ tomorrow = today_date + timedelta(days=1)
+ survey.response_sampling_start_date = tomorrow
+ survey.save(update_fields=["response_sampling_start_date", "response_sampling_daily_limits"])
+
def _get_survey_responses_count(survey_id: int) -> int:
data = sync_execute(