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

Add sample feature flagging system to next.js template #259

Merged
merged 42 commits into from
Dec 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f9767d6
add FF manager
aligg Dec 2, 2023
353630f
format
aligg Dec 2, 2023
6e20779
tweaks
aligg Dec 2, 2023
ed8be4d
update logging
aligg Dec 2, 2023
4bbe4fc
format
aligg Dec 2, 2023
3f03b05
tweak config
aligg Dec 2, 2023
682ba3f
refactor a few things
aligg Dec 2, 2023
4c00a33
tweaking
aligg Dec 2, 2023
5608096
foo
aligg Dec 2, 2023
4bbaf3d
typing errs
aligg Dec 4, 2023
8e4ea30
add mocks
aligg Dec 4, 2023
121a839
format
aligg Dec 4, 2023
072e5c9
variable names, console err, addl pr feedback
aligg Dec 4, 2023
96a1067
remove region
aligg Dec 4, 2023
22af91b
format
aligg Dec 4, 2023
ee9ebc7
rename class
aligg Dec 5, 2023
5960077
a few more pr review comments
aligg Dec 5, 2023
b85be42
add docs
aligg Dec 5, 2023
211a22c
add back evidently mock for now
aligg Dec 5, 2023
19bfc25
add back mock w/ format
aligg Dec 5, 2023
14e7f66
rebase
aligg Dec 6, 2023
f3eb517
format
aligg Dec 6, 2023
ebd85a6
add additional structure for feature flagging
aligg Dec 6, 2023
5e7bbd3
linting
aligg Dec 6, 2023
d5bc09e
spurious patch file
aligg Dec 6, 2023
cc4f457
update docs
aligg Dec 6, 2023
ea37495
spurious patch file again
aligg Dec 6, 2023
4ef4d5f
tests
aligg Dec 7, 2023
baa0f9c
Merge branch 'main' of github.com:navapbc/template-application-nextjs…
sawyerh Dec 7, 2023
04958d5
rename
aligg Dec 7, 2023
e03f4bc
Update docs/feature-flagging.md
aligg Dec 7, 2023
e8033ee
Update docs/feature-flagging.md
aligg Dec 7, 2023
f8abfea
Update app/src/pages/index.tsx
aligg Dec 7, 2023
68da9d5
Update app/src/pages/index.tsx
aligg Dec 7, 2023
354b7ec
Update app/src/services/feature-flags/LocalFeatureFlagManager.ts
aligg Dec 7, 2023
a3768f2
rename feature-flags docs and camel case keys
aligg Dec 7, 2023
dcdf950
make userId optional and remove extra lockfile
aligg Dec 7, 2023
6b0d455
update docs, jest config, and typo on project env var
aligg Dec 8, 2023
6e0fe93
format
aligg Dec 8, 2023
90e4a95
Update docs/feature-flags.md
aligg Dec 8, 2023
e038ff4
update env.development file, remove line from test and update readme
aligg Dec 8, 2023
62b1f5b
add a test for get server side props
aligg Dec 8, 2023
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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
aligg marked this conversation as resolved.
Show resolved Hide resolved
sawyerh marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Up @@ -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
Expand Up @@ -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
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/messages/en-US/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.",
},
Expand Down
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 (
Expand Down Expand Up @@ -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,
},
};
};
Expand Down
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
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we may still want to add the NODE_ENV !== "test" check here, since we are getting rid of the Jest mock. It's not causing issues at the moment since there's no test for getServerSideProps so the feature flag check is never executed in a test, but the NODE_ENV check would be useful for when a project adds test coverage for code that calls isFeatureEnabled.

Copy link
Contributor

Choose a reason for hiding this comment

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

I prefer not to do environment checks in our source code since that breaks 12 factor app principles. A couple of reasons: (1) if the logic in the code has branches based on environment variables, it means that we have less confidence that the code branch that's covered in one environment (e.g. automated test suite) would work the same as the code branch that's covered when deployed to production and. Relatedly, in terms of test coverage tools, one entire branch of code will not be covered. (2) it makes it harder to implement automated integration tests if the test suite is forced to not use the AWS flag manager.

If we want to be more explicit about when we're using one flag manager type over the other without having to comment out or remove the FEATURE_FLAGS_PROJECT env var, we could always add an extra env var like FEATURE_FLAG_MANAGER_TYPE that is either "aws" or "local" e.g.

manager = process.env.FEATURE_FLAG_MANAGER_TYPE === "aws" ? new AWSFeatureFlagManager(process.env.FEATURE_FLAGS_PROJECT) : new LocalFeatureFlagManager();

would that address the issue you mentioned without a NODE_ENV check?

Copy link
Contributor

@sawyerh sawyerh Dec 8, 2023

Choose a reason for hiding this comment

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

Cross-posting from Slack:

Sorry, I caused confusion with an inaccurate statement — Next.js only loads env vars into the test environment from the .env file, but it doesn’t load the vars in files like .env.development or .env.local so I think ultimately Ali your existing condition that just checks the FF project var would be enough.

? new FeatureFlagManager()
: new LocalFeatureFlagManager();
26 changes: 23 additions & 3 deletions app/tests/pages/index.test.tsx
aligg marked this conversation as resolved.
Show resolved Hide resolved
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 });

Expand All @@ -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
aligg marked this conversation as resolved.
Show resolved Hide resolved
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).