Skip to content

Commit

Permalink
IS-310: Setup GrowthBook for BE (#926)
Browse files Browse the repository at this point in the history
* feat: add growthbook as dep

* feat: add to env example

* feat: add polyfill deps for growthbook

* feat: add utils

* feat: setup types for growthbook

* feat: add extended type for request handler

* feat: add middleware

* feat: update middleware

* feat: add growthbook to convict

* feat: use middleware across routes

* feat: extract into constant file

* fix: add GB key to .env.test

* feat: enable auto-refresh

* feat: introduce interface for GrowthBook

* feat: add comment explaining new handler type

* fix: make return of custom handler type void

* fix: imports

* feat: inject attributes from authN middleware

* feat: inject site-name at authorization

* feat: add type safety for growthbook property in req

* fix: minor fixes

* feat: inject attributes from authN middleware

* feat: add growthbook as optional property in session context

* feat: inject growthbook into session

* feat: add type safety for GB

* feat: add key for ggs whitelist

* feat: update method to read from feature flag

* chore: remove env for ggs whitelist

* fix: remove unused constant

* feat: enable dev mode only for dev node env

* feat: remove dependency on ggs env var

* feat: make growthbook optional in Express request

* feat: add type safety

* feat: fix tests

* fix: conditional logic

* fix: log statement

* fix: remove role

* feat: add better logging for debugging

* fix: use logger instead of console

* feat: update function signatures

* feat: update tests

* feat: refactor flags to use namespaced references
  • Loading branch information
harishv7 authored Aug 30, 2023
1 parent 86ec668 commit 68d6508
Show file tree
Hide file tree
Showing 20 changed files with 465 additions and 76 deletions.
5 changes: 4 additions & 1 deletion .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,7 @@ export NETLIFY_ACCESS_TOKEN=""
export SGID_CLIENT_ID=""
export SGID_CLIENT_SECRET=""
export SGID_PRIVATE_KEY=""
export SGID_REDIRECT_URI=""
export SGID_REDIRECT_URI=""

# GrowthBook
export GROWTHBOOK_CLIENT_KEY=""
5 changes: 4 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,7 @@ export NETLIFY_ACCESS_TOKEN="blahblah"
export SGID_CLIENT_ID="client"
export SGID_CLIENT_SECRET="secret"
export SGID_PRIVATE_KEY="private"
export SGID_REDIRECT_URI="http://localhost:8081/v2/auth/sgid/auth-redirect"
export SGID_REDIRECT_URI="http://localhost:8081/v2/auth/sgid/auth-redirect"

# GrowthBook
export GROWTHBOOK_CLIENT_KEY="some random key"
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@aws-sdk/client-dynamodb": "^3.370.0",
"@aws-sdk/client-secrets-manager": "^3.370.0",
"@aws-sdk/lib-dynamodb": "^3.370.0",
"@growthbook/growthbook": "^0.27.0",
"@octokit/rest": "^18.12.0",
"@opengovsg/formsg-sdk": "^0.10.0",
"@opengovsg/sgid-client": "^2.0.0",
Expand All @@ -47,9 +48,11 @@
"convict": "^6.2.4",
"cookie-parser": "~1.4.5",
"cors": "^2.8.5",
"cross-fetch": "^4.0.0",
"crypto-js": "^4.1.1",
"dd-trace": "^4.7.0",
"debug": "~2.6.9",
"eventsource": "^2.0.2",
"exponential-backoff": "^3.1.0",
"express": "~4.17.3",
"express-rate-limit": "^6.7.0",
Expand Down
10 changes: 10 additions & 0 deletions src/classes/UserWithSiteSessionData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { GrowthBook } from "@growthbook/growthbook"

import { FeatureFlags } from "@root/types/featureFlags"

import UserSessionData, { SessionDataProps } from "./UserSessionData"

export type UserWithSiteSessionDataProps = SessionDataProps & {
siteName: string
growthbook?: GrowthBook
}

/**
Expand All @@ -11,9 +16,14 @@ export type UserWithSiteSessionDataProps = SessionDataProps & {
class UserWithSiteSessionData extends UserSessionData {
readonly siteName: string

readonly growthbook?: GrowthBook<FeatureFlags>

constructor(props: UserWithSiteSessionDataProps) {
super(props)
this.siteName = props.siteName
if (props.growthbook) {
this.growthbook = props.growthbook
}
}

getGithubParamsWithSite() {
Expand Down
12 changes: 7 additions & 5 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,13 +405,15 @@ const config = convict({
default: "mock-redirect-uri",
},
},
featureFlags: {
ggsWhitelistedRepos: {
doc: "Comma-separated list of whitelisted repos for local Git service",
env: "WHITELISTED_GIT_SERVICE_REPOS",
format: String,
growthbook: {
clientKey: {
doc: "GrowthBook SDK client key",
env: "GROWTHBOOK_CLIENT_KEY",
format: "required-string",
default: "",
},
},
featureFlags: {
ggsTrackedSites: {
doc: "Comma-separated list of tracked sites for GitHub API hits",
env: "GGS_EXPERIMENTAL_TRACKING_SITES",
Expand Down
3 changes: 3 additions & 0 deletions src/constants/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const FEATURE_FLAGS = {
GGS_WHITELISTED_REPOS: "ggs_whitelisted_repos",
} as const
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./constants"
export * from "./pages"
export * from "./versions"
export * from "./featureFlags"
15 changes: 15 additions & 0 deletions src/fixtures/sessionData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { GrowthBook } from "@growthbook/growthbook"

import GithubSessionData from "@root/classes/GithubSessionData"
import UserSessionData from "@root/classes/UserSessionData"
import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData"
import { FeatureFlags } from "@root/types/featureFlags"

import {
MOCK_USER_EMAIL_ONE,
Expand All @@ -20,6 +23,7 @@ export const mockEmail = "mockEmail"
export const mockTreeSha = "mockTreeSha"
export const mockCurrentCommitSha = "mockCurrentCommitSha"
export const mockSiteName = "mockSiteName"
export const mockGrowthBook = new GrowthBook<FeatureFlags>()

export const mockGithubState = {
treeSha: mockTreeSha,
Expand All @@ -41,6 +45,17 @@ export const mockUserWithSiteSessionData = new UserWithSiteSessionData({
siteName: mockSiteName,
})

export const mockUserWithSiteSessionDataAndGrowthBook = new UserWithSiteSessionData(
{
githubId: mockGithubId,
accessToken: mockAccessToken,
isomerUserId: mockIsomerUserId,
email: mockEmail,
siteName: mockSiteName,
growthbook: mockGrowthBook,
}
)

export const mockGithubSessionData = new GithubSessionData({
treeSha: mockTreeSha,
currentCommitSha: mockCurrentCommitSha,
Expand Down
20 changes: 18 additions & 2 deletions src/middleware/authentication.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import autoBind from "auto-bind"
import { NextFunction, Request, Response } from "express"
import { NextFunction, Response } from "express"
import { Session } from "express-session"

import UserSessionData from "@root/classes/UserSessionData"
import AuthenticationMiddlewareService from "@root/services/middlewareServices/AuthenticationMiddlewareService"
import { SessionData } from "@root/types/express/session"
import { GrowthBookAttributes } from "@root/types/featureFlags"
import { RequestWithGrowthBook } from "@root/types/request"

interface RequestWithSession extends Request {
interface RequestWithSession extends RequestWithGrowthBook {
session: Session & SessionData
}

Expand All @@ -30,6 +32,7 @@ export class AuthenticationMiddleware {
next: NextFunction
) {
const { cookies, originalUrl: url, session } = req

try {
const {
isomerUserId,
Expand All @@ -46,6 +49,19 @@ export class AuthenticationMiddleware {
...rest,
})
res.locals.userSessionData = userSessionData

// populate growthbook
if (req.growthbook) {
const gbAttributes: GrowthBookAttributes = {
isomerUserId,
email,
}
if (session.userInfo.githubId) {
gbAttributes.githubId = session.userInfo.githubId
}
req.growthbook.setAttributes(gbAttributes)
}

return next()
} catch (err) {
return next(err)
Expand Down
31 changes: 31 additions & 0 deletions src/middleware/featureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import UserWithSiteSessionData from "@root/classes/UserWithSiteSessionData"
import config from "@root/config/config"
import logger from "@root/logger/logger"
import { RequestHandlerWithGrowthbook } from "@root/types"
import { getNewGrowthbookInstance } from "@root/utils/growthbook-utils"

export const featureFlagMiddleware: RequestHandlerWithGrowthbook<
never,
unknown,
unknown,
never,
{ userWithSiteSessionData: UserWithSiteSessionData }
> = async (req, res, next) => {
req.growthbook = getNewGrowthbookInstance(config.get("growthbook.clientKey"))

// Clean up at the end of the request
res.on("close", () => {
if (req.growthbook) req.growthbook.destroy()
})

// Wait for features to load (will be cached in-memory for future requests)
req.growthbook
.loadFeatures({ autoRefresh: true })
.then(() => next())
.catch((e: unknown) => {
logger.error(
`Failed to load features from GrowthBook: ${JSON.stringify(e)}`
)
next()
})
}
19 changes: 19 additions & 0 deletions src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import UserWithSiteSessionData from "@classes/UserWithSiteSessionData"

import CollaboratorsService from "@root/services/identity/CollaboratorsService"
import { RequestHandler } from "@root/types"
import { GrowthBookAttributes } from "@root/types/featureFlags"
import IdentityAuthService from "@services/identity/AuthService"
import IsomerAdminsService from "@services/identity/IsomerAdminsService"
import UsersService from "@services/identity/UsersService"
import AuthenticationMiddlewareService from "@services/middlewareServices/AuthenticationMiddlewareService"
import AuthorizationMiddlewareService from "@services/middlewareServices/AuthorizationMiddlewareService"
import FormsProcessingService from "@services/middlewareServices/FormsProcessingService"

import { featureFlagMiddleware } from "./featureFlag"

const getAuthenticationMiddleware = () => {
const authenticationMiddlewareService = new AuthenticationMiddlewareService()
const authenticationMiddleware = new AuthenticationMiddleware({
Expand Down Expand Up @@ -82,11 +85,26 @@ const attachSiteHandler: RequestHandler<
params: { siteName },
} = req
const { userSessionData } = res.locals

// populate growthbook
if (req.growthbook) {
const { isomerUserId, email, githubId } = userSessionData
const gbAttributes: GrowthBookAttributes = {
isomerUserId,
email,
siteName,
}
if (githubId) gbAttributes.githubId = githubId
req.growthbook.setAttributes(gbAttributes)
}

const userWithSiteSessionData = new UserWithSiteSessionData({
...userSessionData.getGithubParams(),
siteName,
growthbook: req.growthbook, // inject into session
})
res.locals.userWithSiteSessionData = userWithSiteSessionData

return next()
}

Expand All @@ -95,4 +113,5 @@ export {
getAuthorizationMiddleware,
attachFormSGHandler,
attachSiteHandler,
featureFlagMiddleware,
}
Loading

0 comments on commit 68d6508

Please sign in to comment.