Skip to content

Commit

Permalink
Add sample feature flagging system to next.js template (#259)
Browse files Browse the repository at this point in the history
## Changes
- Add a service class for retrieving flags from evidently
- Add example consuming the flags
- Updated docs
- tests

## Context for reviewers
- This is followup / related work alongside last week's platform infra
changes.

## Testing
- I'd love some help testing this out against the platform infra.

---------

Co-authored-by: Sawyer <[email protected]>
aligg and sawyerh authored Dec 8, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 526ef47 commit 7c7d5f5
Showing 15 changed files with 2,220 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -2,3 +2,4 @@
.DS_Store
# Developer-specific IDE settings
.vscode
.env.local
6 changes: 6 additions & 0 deletions app/.env.development
Original file line number Diff line number Diff line change
@@ -5,3 +5,9 @@

# If you deploy to a subpath, change this to the subpath so relative paths work correctly.
NEXT_PUBLIC_BASE_PATH=

# AWS Evidently Feature Flag variables
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
FEATURE_FLAGS_PROJECT=
AWS_REGION=
1 change: 1 addition & 0 deletions app/README.md
Original file line number Diff line number Diff line change
@@ -161,4 +161,5 @@ Optionally, configure your code editor to auto run these tools on file save. Mos
## Other topics

- [Internationalization](../docs/internationalization.md)
- [Feature flags](../docs/feature-flags.md)
- Refer to the [architecture decision records](../docs/decisions) for more context on technical decisions.
7 changes: 7 additions & 0 deletions app/jest.config.js
Original file line number Diff line number Diff line change
@@ -16,6 +16,13 @@ const customJestConfig = {
testEnvironment: "jsdom",
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
moduleDirectories: ["node_modules", "<rootDir>/"],
moduleNameMapper: {
// Force uuid to resolve with the CJS entry point ↴
// See https://github.com/uuidjs/uuid/issues/451
// This can be removed when @aws-sdk uses uuid v9+ ↴
// https://github.com/aws/aws-sdk-js-v3/issues/3964
uuid: require.resolve("uuid"),
},
};

module.exports = createJestConfig(customJestConfig);
2,053 changes: 2,053 additions & 0 deletions app/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@
"ts:check": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-evidently": "^3.465.0",
"@trussworks/react-uswds": "^6.0.0",
"@uswds/uswds": "3.7.0",
"lodash": "^4.17.21",
4 changes: 4 additions & 0 deletions app/src/i18n/messages/en-US/index.ts
Original file line number Diff line number Diff line change
@@ -19,6 +19,10 @@ export const messages = {
intro:
"This is a template for a React web application using the <LinkToNextJs>Next.js framework</LinkToNextJs>.",
body: "This is template includes:<ul><li>Framework for server-side rendered, static, or hybrid React applications</li><li>TypeScript and React testing tools</li><li>U.S. Web Design System for themeable styling and a set of common components</li><li>Type checking, linting, and code formatting tools</li><li>Storybook for a frontend workshop environment</li></ul>",
feature_flagging:
"The template includes AWS Evidently for feature flagging. Toggle flag to see the content below change:",
flag_off: "Flag is disabled",
flag_on: "Flag is enabled",
formatting:
"The template includes an internationalization library with basic formatters built-in. Such as numbers: { amount, number, currency }, and dates: { isoDate, date, long}.",
},
26 changes: 23 additions & 3 deletions app/src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import type { GetServerSideProps, NextPage } from "next";
import type {
GetServerSideProps,
InferGetServerSidePropsType,
NextPage,
} from "next";
import { getMessagesWithFallbacks } from "src/i18n/getMessagesWithFallbacks";
import { isFeatureEnabled } from "src/services/feature-flags";

import { useTranslations } from "next-intl";
import Head from "next/head";

const Home: NextPage = () => {
interface PageProps {
isFooEnabled: boolean;
}

const Home: NextPage<InferGetServerSidePropsType<typeof getServerSideProps>> = (
props
) => {
const t = useTranslations("home");

return (
@@ -36,16 +47,25 @@ const Home: NextPage = () => {
isoDate: new Date("2023-11-29T23:30:00.000Z"),
})}
</p>

{/* Demonstration of feature flagging */}
<p>{t("feature_flagging")}</p>
<p>{props.isFooEnabled ? t("flag_on") : t("flag_off")}</p>
</div>
</>
);
};

// Change this to getStaticProps if you're not using server-side rendering
export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
export const getServerSideProps: GetServerSideProps<PageProps> = async ({
locale,
}) => {
const isFooEnabled = await isFeatureEnabled("foo", "anonymous");

return {
props: {
messages: await getMessagesWithFallbacks(locale),
isFooEnabled,
},
};
};
52 changes: 52 additions & 0 deletions app/src/services/feature-flags/FeatureFlagManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Evidently } from "@aws-sdk/client-evidently";

/**
* Class for managing feature flagging via AWS Evidently.
* Class method are available for use in next.js server side code.
*
* https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/evidently/
*
*/
export class FeatureFlagManager {
client: Evidently;
private _project = process.env.FEATURE_FLAGS_PROJECT;

constructor() {
this.client = new Evidently();
}

async isFeatureEnabled(featureName: string, userId?: string) {
const evalRequest = {
entityId: userId,
feature: featureName,
project: this._project,
};

let featureFlagValue = false;
try {
const evaluation = await this.client.evaluateFeature(evalRequest);
if (evaluation && evaluation.value?.boolValue !== undefined) {
featureFlagValue = evaluation.value.boolValue;
console.log({
message: "Made feature flag evaluation with AWS Evidently",
data: {
reason: evaluation.reason,
userId: userId,
featureName: featureName,
featureFlagValue: featureFlagValue,
},
});
}
} catch (e) {
console.error({
message: "Error retrieving feature flag variation from AWS Evidently",
data: {
err: e,
userId: userId,
featureName: featureName,
},
});
}
return featureFlagValue;
}
}
6 changes: 6 additions & 0 deletions app/src/services/feature-flags/LocalFeatureFlagManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class LocalFeatureFlagManager {
async isFeatureEnabled(featureName: string, userId: string) {
console.log("Using mock feature flag manager", { featureName, userId });
return Promise.resolve(false);
}
}
4 changes: 4 additions & 0 deletions app/src/services/feature-flags/__mocks__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { LocalFeatureFlagManager } from "../LocalFeatureFlagManager";
import type { FlagManager } from "../setup";

export const manager: FlagManager = new LocalFeatureFlagManager();
5 changes: 5 additions & 0 deletions app/src/services/feature-flags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { manager } from "./setup";

export function isFeatureEnabled(feature: string, userId?: string) {
return manager.isFeatureEnabled(feature, userId);
}
10 changes: 10 additions & 0 deletions app/src/services/feature-flags/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { FeatureFlagManager } from "./FeatureFlagManager";
import { LocalFeatureFlagManager } from "./LocalFeatureFlagManager";

export interface FlagManager {
isFeatureEnabled(feature: string, userId?: string): Promise<boolean>;
}

export const manager: FlagManager = process.env.FEATURE_FLAGS_PROJECT
? new FeatureFlagManager()
: new LocalFeatureFlagManager();
26 changes: 23 additions & 3 deletions app/tests/pages/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { axe } from "jest-axe";
import Index from "src/pages/index";
import { GetServerSidePropsContext } from "next";
import Index, { getServerSideProps } from "src/pages/index";
import { LocalFeatureFlagManager } from "src/services/feature-flags/LocalFeatureFlagManager";
import { render, screen } from "tests/react-utils";

describe("Index", () => {
// Demonstration of rendering translated text, and asserting the presence of a dynamic value.
// You can delete this test for your own project.
it("renders link to Next.js docs", () => {
render(<Index />);
render(<Index isFooEnabled={true} />);

const link = screen.getByRole("link", { name: /next\.js/i });

@@ -15,9 +17,27 @@ describe("Index", () => {
});

it("passes accessibility scan", async () => {
const { container } = render(<Index />);
const { container } = render(<Index isFooEnabled={true} />);
const results = await axe(container);

expect(results).toHaveNoViolations();
});

it("conditionally displays content based on feature flag values", () => {
const { container } = render(<Index isFooEnabled={true} />);
expect(container).toHaveTextContent("Flag is enabled");
});

it("retrieves feature flags", async () => {
const featureName = "foo";
const userId = "anonymous";
const featureFlagSpy = jest
.spyOn(LocalFeatureFlagManager.prototype, "isFeatureEnabled")
.mockResolvedValue(true);
await getServerSideProps({
req: { cookies: {} },
res: {},
} as unknown as GetServerSidePropsContext);
expect(featureFlagSpy).toHaveBeenCalledWith(featureName, userId);
});
});
24 changes: 24 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Feature flagging

- [AWS Evidently](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Evidently.html) is used for feature flagging
- For more information about the decision-making behind using Evidently, [this infra ADR is available](https://github.com/navapbc/template-infra/blob/68b2db42d06198cb070b0603e63a930db346309f/docs/decisions/infra/0010-feature-flags-system-design.md)
- Additional documentation of the feature flagging solution is available in [infra docs](https://github.com/navapbc/template-infra/blob/main/docs/feature-flags.md)

## How it works

1. `services/feature-flags/FeatureFlagManager` provides a service layer to interact with AWS Evidently endpoints. For example, class method `isFeatureEnabled` calls out to Evidently to retrieve a feature flag value we can then return to the client
1. Pages can call `isFeatureEnabled` from Next.js server side code and return the feature flag value to components as props.

## Local development

Out-of-the-box, local calls where `FEATURE_FLAGS_PROJECT` environment variable is unset will fall back to use `LocalFeatureFlagManager` which defaults flag values to `false`.

If you want to test Evidently locally, use your AWS IAM credentials. Once you set `FEATURE_FLAGS_PROJECT` and the AWS environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION`) in `app/.env.local`, calls to Evidently will succeed.

## Creating a new feature flag

To create a new feature flag, update `/infra/[app_name]/app-config/main.tf`. More information available in infra repository [docs](https://github.com/navapbc/template-infra/blob/main/docs/feature-flags.md).

## Toggling feature flags

Toggle feature flags via the AWS Console GUI. More information [here](https://github.com/navapbc/template-infra/blob/main/docs/feature-flags.md#managing-feature-releases-and-partial-rollouts-via-aws-console).

0 comments on commit 7c7d5f5

Please sign in to comment.