Skip to content
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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,23 @@ VAPID_SUBJECT=mailto:support@example.com
# from /api/settings/branding/logo/... with CDN-style immutable cache headers
# (fine for local dev and CI).
BLOB_READ_WRITE_TOKEN=

# ---------------------------------------------------------------------------
# Error tracking & crash reporting (Sentry)
# ---------------------------------------------------------------------------
# See docs/ERROR_TRACKING.md for setup, grouping, sourcemaps, privacy, and
# alert rules for critical errors.
#
# NEXT_PUBLIC_SENTRY_DSN: client-side DSN used by the browser SDK. Leave empty
# to disable error reporting (e.g. local dev and CI). This value is public.
NEXT_PUBLIC_SENTRY_DSN=

# SENTRY_ORG / SENTRY_PROJECT: used by the Sentry CLI/Webpack plugin to upload
# sourcemaps during the build so stack traces are readable.
SENTRY_ORG=
SENTRY_PROJECT=

# SENTRY_AUTH_TOKEN: server-only token with `project:releases` scope for
# sourcemap uploads. Never expose this to the client. Leave empty to skip
# sourcemap upload (builds still succeed, traces will be minified).
SENTRY_AUTH_TOKEN=
25 changes: 25 additions & 0 deletions docs/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ The initial audit surfaced the following categories of critical/serious violatio
- **Browser coverage locally vs. CI.** A fresh dev sandbox may only have the Chromium Playwright browser installed. CI installs Chromium, Firefox, and WebKit via `npx playwright install --with-deps`, so the merge gate is multi-engine even when a local run (`npx playwright test --grep @a11y`) only exercises Chromium.
- **Moderate/minor axe findings are not gated on.** Only `critical`/`serious` impact violations fail the build, per the acceptance criteria. Any moderate/minor findings surfaced by axe during a run are not currently tracked separately.

## Breadcrumbs and contextual help

The breadcrumb navigation (`src/components/Breadcrumbs.tsx`) and contextual help system (`src/components/HelpTooltip.tsx`) are covered by the same axe gate and have an explicit accessibility contract:

### Breadcrumbs

- Rendered as a `<nav aria-label="Breadcrumb">` landmark so screen readers can jump to it.
- The trail is an ordered list (`<ol>`); each crumb is an `<li>`.
- The current page is marked with `aria-current="page"` on its link/text and is not itself a link target.
- Separators are decorative (`aria-hidden="true"`) so they are not announced.
- Every crumb is a real focusable link (or plain text for the current page) reachable in DOM order via Tab.

### Contextual help (`HelpTooltip`)

- The trigger is a `<button type="button">` with an accessible name (`aria-label`, e.g. "Help: Invoice status") — never an icon-only element without a label.
- The help text is associated with the trigger via `aria-describedby` pointing at the tooltip content element, so the description is announced when the trigger receives focus.
- The tooltip opens on focus **and** hover, and closes on `Escape` and on blur, so keyboard-only users can read and dismiss it without a pointer.
- The tooltip content is exposed in the accessibility tree (not `aria-hidden`) while open; it uses `role="tooltip"` and is referenced by the trigger's `aria-describedby`.
- Tooltip text must meet 4.5:1 contrast against its background in both light and dark themes.

### Audit scope notes

- The breadcrumb and help-tooltip components are exercised on the audited routes listed above (they render in the shared layout / page headers). Any new route that mounts them inherits coverage automatically once added to the `PAGES` array.
- When adding a new `HelpTooltip`, verify: (1) the trigger has a non-empty accessible name, (2) `aria-describedby` resolves to an element that exists in the DOM, and (3) the tooltip is dismissible with `Escape`. These are the failure modes axe and the keyboard interaction tests are most likely to catch.

## Extending the audit

To add a new page to the gate, add an entry to the `PAGES` array in `e2e/a11y.spec.ts` — the shared `runAxe()` helper and assertion are reused automatically. For flows that require user interaction (opening a modal, completing a multi-step form), follow the pattern in the two targeted tests at the bottom of that file: drive the interaction with Playwright, then call `runAxe(page)` and assert on `critical`/`serious` violations.
127 changes: 127 additions & 0 deletions docs/FEATURE_FLAGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Feature Flags

This document describes how feature flags are used in this project to control feature rollout, testing, and experimentation. It covers the flag lifecycle, naming conventions, rollout percentages, A/B testing, analytics, and cleanup.

## Overview

Feature flags (also called feature toggles) let us ship code without immediately exposing it to all users. They allow us to:

- Roll out features gradually to a percentage of users
- Test features with internal users before public release
- Run A/B tests to measure the impact of changes
- Disable a feature quickly if something goes wrong, without a redeploy

## Feature Flag Service

We use a hosted feature flag service that provides:

- A dashboard UI for creating and toggling flags
- Server-side and client-side SDKs for flag evaluation
- Targeting rules (by user ID, percentage, attributes)
- Built-in analytics and experiment support

Flag evaluation is cached locally by the SDK, so evaluating a flag has negligible performance impact. Flags are evaluated once per request/session where possible and results are memoized.

## Flag Lifecycle

Every flag moves through the following stages:

1. **Proposed** — A flag is requested in the issue tracker with a name, owner, and purpose.
2. **Created** — The flag is created in the flag service dashboard, defaulting to `off` in production.
3. **Development** — The flag is enabled for internal/test users only while the feature is built.
4. **Rollout** — The flag is enabled for a growing percentage of users (see [Rollout Percentages](#rollout-percentages)).
5. **Full rollout** — The flag is enabled for 100% of users.
6. **Cleanup** — The flag and its dead code paths are removed (see [Cleanup](#cleanup)).

Each flag must have an **owner** and a **target cleanup date** recorded in the flag service description.

## Naming Conventions

Flag names must be lowercase, hyphen-separated, and follow this pattern:

```
<area>-<feature>-<variant>
```

Examples:

- `checkout-one-click-payment`
- `search-vector-ranking`
- `profile-avatar-upload`

Rules:

- Use a stable, descriptive name — never rename a flag after it has shipped; create a new one instead.
- Prefix experimental flags with `exp-` (e.g. `exp-onboarding-tour`).
- Prefix kill-switch flags with `kill-` (e.g. `kill-recommendations`).
- Keep names short but unambiguous; avoid abbreviations that are not widely understood.

## Rollout Percentages

Gradual rollout uses percentage-based targeting. Recommended progression:

| Stage | Audience | Duration (guideline) |
|-------|----------|----------------------|
| Internal | Team members only | Until feature is stable |
| Canary | 1% of users | 1–2 days |
| Early | 5% of users | 2–3 days |
| Growing | 25% of users | 3–5 days |
| Majority | 50% of users | 3–5 days |
| Full | 100% of users | — |

Guidelines:

- Percentage targeting must be **sticky** — a given user should consistently fall into the same bucket. Use a stable bucketing key (e.g. user ID) rather than random sampling per request.
- Monitor error rates, latency, and key business metrics at each stage before increasing the percentage.
- If metrics regress, roll back to the previous percentage immediately.
- Never jump directly from 0% to 100% for user-facing features unless the change is trivial.

## A/B Testing

A/B tests use the same flag infrastructure with experiment support:

1. Define the experiment in the flag service with named variants (e.g. `control`, `treatment`).
2. Assign a stable bucketing key so users see a consistent variant.
3. Expose the variant to the application via flag evaluation.
4. Track exposure and conversion events through the analytics integration (see [Analytics](#analytics)).
5. Run the experiment for a pre-determined duration and sample size; do not peek and stop early.
6. Record the result and decision in the experiment description, then clean up.

Experiments must have a hypothesis, a primary metric, and a defined success threshold before launch.

## Analytics

Flag evaluations and experiment exposures are tracked automatically:

- **Exposure events** are emitted when a user is evaluated for an experiment flag, including the flag key and variant.
- **Conversion events** are emitted by application code at the relevant action points.
- Events are sent to our analytics pipeline and are available in the flag service dashboard.

Do not emit exposure events for simple on/off rollout flags unless you need to measure adoption. Avoid logging personally identifiable information in flag events.

## Cleanup

Flags are temporary. To keep the codebase healthy:

- Every flag must have a target cleanup date when created.
- Once a flag is at 100% (or an experiment has concluded), open a cleanup task to:
1. Remove the flag from the flag service.
2. Remove the flag evaluation code and any dead branches.
3. Remove associated tests that only exist for the flag.
4. Update this document if the flag was referenced.
- Flags older than their cleanup date are reported in the weekly engineering review.

## Performance

Flag evaluation is designed to have no measurable performance impact:

- The SDK caches flag configurations locally and refreshes them in the background.
- Evaluations are in-memory lookups.
- Results are memoized per request where appropriate.

If you suspect a flag is causing a performance issue, check the SDK's diagnostics and report it to the platform team.

## Related Documentation

- [User Guide](USER_GUIDE.md) — end-user documentation, including how to manage feature flags in the UI.
- [Contributing Guide](../CONTRIBUTING.md) — development workflow and flag-related contribution guidelines.
115 changes: 114 additions & 1 deletion instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,127 @@ import * as Sentry from "@sentry/nextjs";

const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;

/**
* Keys that may contain personally identifiable information.
* These are scrubbed from event payloads before they are sent to Sentry.
*/
const PII_KEYS = [
"email",
"phone",
"name",
"firstName",
"lastName",
"address",
"ip",
"ipAddress",
"wallet",
"walletAddress",
"publicKey",
"privateKey",
"secret",
"password",
"token",
"authorization",
"cookie",
];

const REDACTED = "[REDACTED]";

function scrubValue(value: unknown, depth = 0): unknown {
if (depth > 8 || value == null) {
return value;
}

if (Array.isArray(value)) {
return value.map((item) => scrubValue(item, depth + 1));
}

if (typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (PII_KEYS.some((piiKey) => key.toLowerCase() === piiKey.toLowerCase())) {
result[key] = REDACTED;
} else {
result[key] = scrubValue(val, depth + 1);
}
}
return result;
}

return value;
}

function scrubEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent | null {
if (event.user) {
// Only keep the hashed wallet identifier; drop everything else.
event.user = event.user.id ? { id: event.user.id } : {};
}

if (event.request) {
delete event.request.cookies;
delete event.request.headers;
delete event.request.data;
delete event.request.query_string;
}

if (event.extra) {
event.extra = scrubValue(event.extra) as Record<string, unknown>;
}

if (event.contexts) {
event.contexts = scrubValue(event.contexts) as typeof event.contexts;
}

if (event.breadcrumbs) {
event.breadcrumbs = event.breadcrumbs.map((breadcrumb) => ({
...breadcrumb,
data: breadcrumb.data
? (scrubValue(breadcrumb.data) as Record<string, unknown>)
: breadcrumb.data,
}));
}

return event;
}

if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0.1,
debug: false,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.0,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
beforeSend(event) {
return scrubEvent(event);
},
beforeSendTransaction(event) {
return scrubEvent(event);
},
});
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
/**
* Associates the current Sentry scope with a hashed wallet identifier.
* The raw wallet address must never be passed here; callers are expected
* to provide a one-way hash so no PII leaves the client.
*/
export function setUser(hashedWalletId: string | null): void {
if (!dsn) {
return;
}

if (hashedWalletId) {
Sentry.setUser({ id: hashedWalletId });
} else {
Sentry.setUser(null);
}
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Loading