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

Allow feature flags to use sessionStorage and ignore ff_ query params #2121

Merged
merged 7 commits into from
May 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions documentation/frontend/reference/feature_flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ page.
should [populate `en.json5`](#key-title) if the feature is switchable and
visible to the user.

##### `storage`

This determines whether the feature is stored in a cookie (`'cookie'`) or in
`sessionStorage` (`'session'`). By default all feature flags are stored in
cookies but certain preferences that should only be applicable for a single
session can be stored in `sessionStorage` instead.

##### `supportsQuery`

By default, all switchable flags can be set per-request using the `ff_<flag>`
query parameters. This is useful for testing and debugging. However, some flags
dealing with sensitivity can be used to make malicious URLs, so they can avoid
being set via query parameters by setting this to `false`.

### `groups`

This setting pertains to how feature flags can serve as user-level preferences.
Expand Down
25 changes: 22 additions & 3 deletions frontend/feat/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,41 @@
"production": "disabled"
},
"description": "Mark 50% of results as mature to test content safety.",
"defaultState": "off"
"defaultState": "off",
"storage": "cookie"
},
"external_sources": {
"status": {
"staging": "switchable",
"production": "disabled"
},
"description": "Toggle the external sources in the header's search type switcher.",
"defaultState": "off"
"defaultState": "off",
"storage": "cookie"
},
"analytics": {
"status": {
"staging": "switchable",
"production": "disabled"
},
"description": "Record custom events and page views."
"description": "Record custom events and page views.",
"storage": "cookie"
},
"sensitive_content": {
"status": {
"staging": "switchable",
"production": "disabled"
},
"description": "Turn on sensitive content fetching and blurring.",
"defaultState": "off",
"storage": "cookie"
},
"fetch_sensitive": {
"status": "switchable",
"defaultState": "off",
"description": "Show results marked as sensitive in the results area.",
"supportsQuery": false,
"storage": "session"
}
},
"groups": [
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/constants/feature-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ export const OFF = "off"
export const FEATURE_STATES = [ON, OFF] as const

export type FeatureState = typeof FEATURE_STATES[number]

export const SESSION = "session"
export const COOKIE = "cookie"

export const STORAGES = [SESSION, COOKIE] as const

export type Storage = typeof STORAGES[number]
6 changes: 6 additions & 0 deletions frontend/src/layouts/content-layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { useLayout } from "~/composables/use-layout"

import { useUiStore } from "~/stores/ui"
import { useSearchStore } from "~/stores/search"
import { useFeatureFlagStore } from "~/stores/feature-flag"

import { IsHeaderScrolledKey, IsSidebarVisibleKey } from "~/types/provides"

Expand Down Expand Up @@ -87,6 +88,11 @@ export default defineComponent({
const uiStore = useUiStore()
const searchStore = useSearchStore()

const featureStore = useFeatureFlagStore()
onMounted(() => {
featureStore.initFromSession()
})

const { updateBreakpoint } = useLayout()

/**
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { PortalTarget as VTeleportTarget } from "portal-vue"
import { useLayout } from "~/composables/use-layout"

import { useUiStore } from "~/stores/ui"
import { useFeatureFlagStore } from "~/stores/feature-flag"

import VBanners from "~/components/VBanner/VBanners.vue"
import VFooter from "~/components/VFooter/VFooter.vue"
Expand All @@ -45,6 +46,11 @@ export default defineComponent({
setup() {
const uiStore = useUiStore()

const featureStore = useFeatureFlagStore()
onMounted(() => {
featureStore.initFromSession()
})

const { updateBreakpoint } = useLayout()

/**
Expand Down
75 changes: 61 additions & 14 deletions frontend/src/stores/feature-flag.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Vue from "vue"
import { defineStore } from "pinia"

import { useStorage } from "@vueuse/core"

import featureData from "~~/feat/feature-flags.json"
Expand All @@ -15,9 +15,13 @@ import {
ON,
OFF,
DISABLED,
COOKIE,
SESSION,
} from "~/constants/feature-flag"
import { LOCAL, DEPLOY_ENVS, DeployEnv } from "~/constants/deploy-env"

import type { Context } from "@nuxt/types"

import type { Dictionary } from "vue-router/types/router"

type FlagName = keyof typeof featureData["features"]
Expand Down Expand Up @@ -93,14 +97,16 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
/**
* Get the mapping of switchable features to their preferred states.
*/
flagStateMap: (state: FeatureFlagState): Record<string, FeatureState> => {
const featureMap: Record<string, FeatureState> = {}
Object.entries(state.flags).forEach(([name, flag]) => {
if (getFlagStatus(flag) === SWITCHABLE)
featureMap[name] = getFeatureState(flag)
})
return featureMap
},
flagStateMap:
(state: FeatureFlagState) =>
(dest: string): Record<string, FeatureState> => {
const featureMap: Record<string, FeatureState> = {}
Object.entries(state.flags).forEach(([name, flag]) => {
if (getFlagStatus(flag) === SWITCHABLE && flag.storage === dest)
featureMap[name] = getFeatureState(flag)
})
return featureMap
},
},
actions: {
/**
Expand All @@ -114,16 +120,53 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
*/
initFromCookies(cookies: Record<string, FeatureState>) {
Object.entries(this.flags).forEach(([name, flag]) => {
if (getFlagStatus(flag) === SWITCHABLE)
flag.preferredState = cookies[name]
if (getFlagStatus(flag) === SWITCHABLE && flag.storage === COOKIE)
Vue.set(flag, "preferredState", cookies[name])
})
},
/**
* Write the current state of the switchable flags to the cookie.
*
* @param cookies - the Nuxt cookies module
*/
writeToCookies(cookies: Context["$cookies"]) {
cookies.set("features", this.flagStateMap(COOKIE))
},
/**
* Initialize the state of the switchable flags from the session storage.
* `Vue.set` is used to ensure reactivity is maintained.
*/
initFromSession() {
if (typeof window === "undefined") return
const features = useStorage<Record<string, FeatureState>>(
"features",
{},
sessionStorage
)
Object.entries(this.flags).forEach(([name, flag]) => {
if (getFlagStatus(flag) === SWITCHABLE && flag.storage === SESSION) {
Vue.set(flag, "preferredState", features.value[name])
}
})
},
/**
* Write the current state of the feature flags to the cookie. These cookies
* are read in the corresponding `initFromCookies` method.
*/
writeToCookie() {
this.$nuxt.$cookies.set("features", this.flagStateMap)
this.$nuxt.$cookies.set("features", this.flagStateMap(COOKIE))
},
/**
* Write the current state of the switchable flags to the session storage.
*/
writeToSession() {
if (typeof window === "undefined") return
const features = useStorage<Record<string, FeatureState>>(
"features",
{},
sessionStorage
)
features.value = this.flagStateMap(SESSION)
},
/**
* Set the value of flag entries from the query parameters. Only those
Expand Down Expand Up @@ -152,8 +195,11 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
// TODO: type `FlagName` should be inferred by TS
const flagName = name.substring(3) as FlagName
const flag = this.flags[flagName]
if (getFlagStatus(flag) === SWITCHABLE) {
flag.preferredState = state
if (
getFlagStatus(flag) === SWITCHABLE &&
flag.supportsQuery !== false
) {
Vue.set(flag, "preferredState", state)
}
})
},
Expand All @@ -168,6 +214,7 @@ export const useFeatureFlagStore = defineStore(FEATURE_FLAG, {
if (getFlagStatus(flag) === SWITCHABLE) {
flag.preferredState = targetState
this.writeToCookie()
this.writeToSession()
if (name === "analytics") this.syncAnalyticsWithLocalStorage()
} else warn(`Cannot set preferred state for non-switchable flag: ${name}`)
},
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/types/feature-flag.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { FeatureState, FlagStatus } from "~/constants/feature-flag"
import type {
FeatureState,
FlagStatus,
Storage,
} from "~/constants/feature-flag"
import type { DeployEnv } from "~/constants/deploy-env"

export interface FeatureFlag {
Expand All @@ -8,4 +12,8 @@ export interface FeatureFlag {

defaultState?: FeatureState
preferredState?: FeatureState // only set for switchable flag with known preference

supportsQuery?: boolean // default: true

storage: Storage
}
Loading