Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add cohorts for A/B testing in Specs List Banners #23735

Merged
merged 34 commits into from
Sep 16, 2022

Conversation

warrensplayer
Copy link
Contributor

@warrensplayer warrensplayer commented Sep 8, 2022

User facing changelog

n/a

Additional details

In order to be able to A/B test different concepts in the app, an ability to assign a user to a particular cohort is needed. A cohort can be assigned per feature or functionality that needs to be A/B tested.

In this first use case, A/B testing is limited to the ACI Smart Prompt banners being shown on the Specs list page of the app. See the details in #23118 that describes the specific changes.

The cohort functionality is encapsulated in a composable called useCohorts in the frontend-shared package.

Steps to test

Prerequisites

The cohort functionality is used within the Smart Prompt banners used on the Specs list page of the app. In order to fully test the different banners, the project used for testing will need to be in a certain state.

  • The project should have been opened more than 4 days ago. This can be confirmed by looking at the state.json file under the "App Data" directory and then navigating to "projects" and then the directory with the project name and hash. The firstOpened value should be a timestamp older than 4 days ago
  • Remove the banners entry from the same file. This will reset the apps tracking of when banners were viewed or dismissed.
  • Make sure the project is not connected to a dashboard project id by removed the "lastProjectId" from the same file and the cypress.config.js file for the project as appropriate

Testing cohorts

  • After first starting the app with the launchpad loaded, select the "Developer tools > View App Data" menu item

  • Within the directory that opens, open the cache file in a text editor

  • Confirm that there is not a "COHORTS" entry in the json data

  • Make sure you are logged out from the dashboard

  • Login Banner

    • Open the project in either e2e or component testing mode
    • Confirm that the Login Banner shows
    • Reopen the cache file from above and confirm there is now a COHORTS entry with a key of aci_082022_login that matches the bannerId in LoginBanner.vue. The key should have an object with the name matching the bannerId and a cohort value of either 'A' or 'B'. Confirm the body copy of the Login Banner matches the appropriate copy for either A or B shown in the document here
    • Navigate away from the Specs list page by going to the Runs page and then back to the Specs list. The same copy should be showing in the Login Banner from before and the "COHORTS" value in the cache file should not have changed
    • Close the browser for the app either manually or from the launch pad.
    • Edit the cache file to remove the key for aci_082022_login
    • Open the app again in either mode and confirm that the cache file has a new entry for aci_082022_login and that the appropriate copy is showing
    • If the algorithm selected the same cohort as before, you can test the other option in two ways:
      • Follow the steps above for a couple of more times to edit the cache file and remove the same entry until the algorithm chooses the other cohort
      • Edit the cache file to manually edit the cohort to the other value (A or B)
  • Remaining banners

  • Test the other Smart Prompt banners as described here: feat: ACI Notification Banners #23256

  • For each banner, confirm the same "cohort" behavior and compare the results with the expected A/B testing values here

  • The keys in the "COHORTS" section should match the bannerIds for each banner

    • aci_082022_createOrganization
    • aci_082022_connectProject
    • aci_082022_record
  • UTM Parameters

    • The calculated cohort values should also be passed with the resulting call to actions from each banner. Run through the tests for this PR to confirm the cohort values are passed in the external links for the Login and Create Organization and in the mutation for creating a project. The Record Runs banner does not track the cohort when running the command

How has the user experience changed?

Once fully implemented, the only user experience change will be that different copy may be used in the banners depending on which cohort the user is assigned to.

PR Tasks

  • Have tests been added/updated?
  • Has the original issue (or this PR, if no issue exists) been tagged with a release in ZenHub? (user-facing changes only)
  • [n/a] Has a PR for user-facing changes been opened in cypress-documentation?
  • [n/a] Have API changes been updated in the type definitions?

@cypress-bot
Copy link
Contributor

cypress-bot bot commented Sep 8, 2022

Thanks for taking the time to open a PR!

@warrensplayer warrensplayer changed the title Creating useCohorts composable with weightedChoice feat: ability to create and cache cohorts for use in A/B testing Sep 8, 2022
@cypress
Copy link

cypress bot commented Sep 8, 2022



Test summary

40097 0 3354 0Flakiness 0


Run details

Project cypress
Status Passed
Commit 0d0db29
Started Sep 16, 2022 1:37 AM
Ended Sep 16, 2022 1:52 AM
Duration 15:15 💡
OS Linux Debian - 11.3
Browser Multiple

View run in Cypress Dashboard ➡️


This comment has been generated by cypress-bot as a result of this project's GitHub integration settings. You can manage this integration in this project's settings in the Cypress Dashboard

@warrensplayer warrensplayer marked this pull request as ready for review September 9, 2022 20:42
@warrensplayer warrensplayer requested review from a team and tgriesser September 9, 2022 20:42
packages/app/src/specs/banners/ConnectProjectBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/ConnectProjectBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/frontend-shared/src/utils/weightedChoice.ts Outdated Show resolved Hide resolved
@mike-plummer mike-plummer requested a review from a team September 9, 2022 21:17
Copy link
Contributor

@lmiller1990 lmiller1990 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code and coverage looks thorough! I left some questions, it's definitely tricky to manage everything with all the async fetching + UI layer and finding the best way to manage it all.

packages/app/src/specs/banners/RecordBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
@lmiller1990
Copy link
Contributor

Another thought: we have a lot of complicated manual testing for most ACI PRs. I don't see any reason we cannot invest a bit of time into some better testing infra, just in the runner - we can add some endpoints to update the cache or app data if needed, and some to force the app to refetch the data. I don't see much other than "re-open the app" here we can't automate. Just an idea, it's possible there is technical blockers I'm not aware of.

@lmiller1990 lmiller1990 requested a review from a team September 12, 2022 06:57
@warrensplayer
Copy link
Contributor Author

Reactor updates to fix race condition
I have completed the refactor to address the race condition between selecting and storing a cohort for a given banner and sending the "viewed" event for the first view. This refactor pulled the decision point for selecting the cohort out of TrackedBanner and brought it up to SpecsListBanners. The individual banners are now passed the selected cohort as a prop along with the value to use that is being A/B tested. The banner component is responsible for putting the value in the appropriate location and passing the cohort value to the TrackedBanner component that sends the event. The cohort is also used as either a utm parameter for an external URL or in the mutation to create a project.

The refactor also pushed the logic to choose the cohort to the data-context layer. This is encapsulated in the CohortsActions.

Copy link
Contributor

@lmiller1990 lmiller1990 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got one question, there's still something a little off about how we manage the different banners/cohorts - I left a reply, we might want to sync on this one?

return useCohorts(cohortConfig)
}

const cohorts = reactive({} as Record<'login' | 'connectProject' | 'organization', any>)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions about this (sorry I only just noticed as part of the re-review...)

This type and usage felt a little off. I found out why - we have a kind of "double reactivity" here. We are using reactive to hold properties which all point to Ref (which is also part of the reactivity system).

It's similar to doing something like

const [foo1, setFoo1] = useState()
const [foo1, setFoo1] = useState()

const [foos, setFoos] = useState()
setFoos({ setFoo1, setFoo2 })

I'm not sure if this really makes sense. I tried a few things, and landed on https://github.com/cypress-io/cypress/compare/stokes/23083_aci_cohorts...lmiller/stokes/23083_aci_cohorts-v1?expand=1.

type BannerType = 'login' | 'connectProject' | 'organization'
const cohorts: Partial<Record<BannerType, ReturnType<typeof getCohortForBanner>>> = {}

Alternatively, just have 3 separate variables:

const cohortLogin = ref<ReturnType<typeof getCohortForBanner> | undefined>()
// ...

This might be more simple - avoid thinking about the reactivity problem all together.

I don't know if cohorts actually needs to be reactive. Either way, this change revealed a bug - getCohortForBanner which returns useCohorts which returns ref<CohortOption | undefined>(). I got a TS error, which I fixed by doing an additional null check (see above link).

The properties (cohorts.login etc) are nullable - but we are passing them to <ConnectProjectBanner /> etc, which has a non-nullable prop type. I think we need this null check.

We missed this because of the as Record<any> cast. Can a user be in no cohort? If they are, I don't know if we'd want to display the modals.

Not sure if this actual manifested in a functional bug, but definitely worth clarifying + improving the types (ideally avoiding any).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not believe there is a double reactivity problem because Vue unwraps refs in side reactive. See: https://vuejs.org/guide/essentials/reactivity-fundamentals.html#ref-unwrapping-in-reactive-objects

Also, I do still need the cohorts to be reactive because of the mutation it uses is async. I was following this pattern for my composable: https://www.vuemastery.com/blog/coding-better-composables-5-of-5/

The problem with using reactive and trying to strongly type the value part of the Record is the unwrapping the reactive does. You can assign either a ref or the unwrapped value any place inside a reactive object, but would always access it as unwrapped.

I liked using reactive because of the unwrapping so you would not have to do all the .value calls in the template. I thought about the separate variables as well, but they would have to be double nested refs or just be defined with let and set after the getCohortForBanner returns.

I am checking in an update that looks pretty ugly, but does seem to work that defines the cohorts variable like:

type BannerType = 'login' | 'connectProject' | 'organization'

const cohorts: Partial<Record<BannerType, CohortOption | Ref<CohortOption | undefined>>> = reactive({})

Let me know what you think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind.... Went back to the any for the moment since that did introduce another type check issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right I see why cohorts needs to be reactive now - the refs are initially undefined and (possibly) assigned later on.

I think the original problem still stands doesn't it? By using any are we missing out on some value typescript static checking?

I won't hold this one up too much more - I could be missing something, I thought this would be straight forward to type, but maybe that's not the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up going with your answer but just left off the last part of the null check since the CohortOption, if present, will always have a cohort

packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/data-context/src/DataActions.ts Show resolved Hide resolved
packages/frontend-shared/src/composables/useCohorts.ts Outdated Show resolved Hide resolved
packages/app/src/specs/banners/RecordBanner.vue Outdated Show resolved Hide resolved
packages/frontend-shared/src/composables/useCohorts.ts Outdated Show resolved Hide resolved
packages/data-context/src/actions/CohortsActions.ts Outdated Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
packages/app/src/specs/SpecsListBanners.vue Show resolved Hide resolved
packages/app/src/specs/banners/TrackedBanner.vue Outdated Show resolved Hide resolved
Comment on lines 20 to 43
const cumulativeWeights = _.reduce(weights, (result: number[], value: number) => {
if (result.length === 0) {
result.push(value)
} else {
// @ts-ignores
result.push(value + result[result.length - 1])
}

return result
}, [])

// @ts-ignores
const randomNumber = Math.random() * cumulativeWeights[cumulativeWeights.length - 1]

const choice = _.transform(cumulativeWeights, (result, value, index) => {
if (value >= randomNumber) {
result.chosenIndex = index
}

return result.chosenIndex === -1
}, { chosenIndex: -1 })

return values[choice.chosenIndex]
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any ideas for not needing the two @ts-ignores I added for referencing the last value in an array?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm maybe this:

Only line I am unsure about

const randomNumber = Math.random() * (cumulativeWeights[cumulativeWeights.length - 1] ?? 1)

Should we error if cumulativeWeights[cumulativeWeights.length - 1] is null? Should be impossible?

import _ from 'lodash'

export type WeightedAlgorithm = {
  pick: (values: string[]) => string
}

/**
 * Randomly choose an index from an array based on weights
 *
 * Based on algorithm found here: https://dev.to/trekhleb/weighted-random-algorithm-in-javascript-1pdc
 *
 * @param weights array of numbered weights that correspond to the indexed values
 * @param values array of values to choose from
 */
const weightedChoice = (weights: number[], values: any[]) => {
  if (weights.length === 0 || values.length === 0 || weights.length !== values.length) {
    throw new Error('The length of the weights and values must be the same and greater than zero')
  }

  const cumulativeWeights = weights.reduce<number[]>((acc, curr) => {
    if (acc.length === 0) {
      return [curr]
    } 

    const next = acc[acc.length - 1]

    if (!next) {
      return acc
    }

    return [...acc, next]
  }, [])

  const randomNumber = Math.random() * (cumulativeWeights[cumulativeWeights.length - 1] ?? 1)

  const choice = _.transform(cumulativeWeights, (result, value, index) => {
    if (value >= randomNumber) {
      result.chosenIndex = index
    }

    return result.chosenIndex === -1
  }, { chosenIndex: -1 })

  return values[choice.chosenIndex]
}

export const WEIGHTED = (weights: number[]): WeightedAlgorithm => {
  return {
    pick: (values: any[]): string => {
      return weightedChoice(weights, values)
    },
  }
}

export const WEIGHTED_EVEN = (values: any[]): WeightedAlgorithm => {
  return WEIGHTED(_.fill(Array(values.length), 1))
}

Copy link
Contributor

@mike-plummer mike-plummer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting real close here, just a few TS and organization things

packages/data-context/src/actions/CohortsActions.ts Outdated Show resolved Hide resolved
packages/data-context/src/actions/CohortsActions.ts Outdated Show resolved Hide resolved
packages/data-context/src/util/weightedChoice.ts Outdated Show resolved Hide resolved
packages/data-context/src/util/weightedChoice.ts Outdated Show resolved Hide resolved
packages/app/src/specs/SpecsListBanners.vue Show resolved Hide resolved
Copy link
Contributor

@ZachJW34 ZachJW34 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gave it one last test and it works great. I've got one comment about the useCohort composable, but not going to block on it. Once CI and some TS issues are fixed, LGTM!

*
* @returns object with getCohort function for returning the cohort
*/
export const useCohorts = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the refactoring, I feel like the better pattern here would have been to use pinia, where the cohortOptionSelected would have been stored as state and the getCohort would be an action. It seems strange that a composable is returning a function and not reactive state.

Copy link
Contributor Author

@warrensplayer warrensplayer Sep 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a store make a GraphQL mutation? That is what I was trying to encapsulate in useCohorts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use useMutation inside an action, see https://pinia.vuejs.org/core-concepts/actions.html#accessing-other-stores-actions where they utilize composables inside pinia actions.

@warrensplayer warrensplayer changed the title feat: ability to create and cache cohorts for use in A/B testing feat: add cohorts for A/B testing in Specs List Banners Sep 16, 2022
@warrensplayer warrensplayer merged commit 314a0b2 into develop Sep 16, 2022
@warrensplayer warrensplayer deleted the stokes/23083_aci_cohorts branch September 16, 2022 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
6 participants