1298 lines
31 KiB
Markdown
1298 lines
31 KiB
Markdown
# Feature Flags
|
|
|
|
Last updated: 2026-05-19
|
|
|
|
## Purpose
|
|
|
|
This document describes the in-house feature flag system that replaces the old Split/Harness runtime dependency for the
|
|
bodyshop application.
|
|
|
|
The goal of this system is to be a practical drop-in replacement for the existing Split usage in the front end while
|
|
letting us manage feature flag definitions and bodyshop assignments from our own admin panel. It uses:
|
|
|
|
- Hasura/Postgres for durable flag definitions and per-bodyshop assignments.
|
|
- The bodyshop backend for authenticated runtime flag evaluation.
|
|
- Redis for short-lived runtime response caching.
|
|
- Hasura event triggers plus admin-side invalidation for cache freshness.
|
|
- The admin panel for creating, editing, deleting, and assigning flags.
|
|
- A local compatibility module in the bodyshop client so existing Split-shaped hooks/components keep their API shape.
|
|
|
|
The important design choice is that the bodyshop application does not talk to Harness/Split at runtime anymore. It asks
|
|
our backend for feature flags for the active bodyshop, and the backend reads our database.
|
|
|
|
## Current Status
|
|
|
|
The system currently supports:
|
|
|
|
- Global feature flag definitions.
|
|
- Per-bodyshop flag assignments.
|
|
- Optional `activeDate` and `deactiveDate` scheduling on per-bodyshop assignments.
|
|
- Custom treatment values, not just `on`, `off`, and `control`.
|
|
- Optional JSON config per assignment.
|
|
- Runtime Redis caching.
|
|
- Targeted cache invalidation when one bodyshop's flag assignments change.
|
|
- Global cache invalidation when feature flag definitions change.
|
|
- Live browser refresh through the existing Socket.IO connection when flags change.
|
|
- Admin CRUD for flag definitions.
|
|
- Admin assignment management from both the bodyshop edit screen and the flag CRUD screen.
|
|
- Importing exported Harness/Split assignments by matching Harness target keys to `bodyshops.imexshopid`.
|
|
- A temporary footer indicator that displays `Test Feature Flag Enabled` when `TEST_FLAG` is `on`.
|
|
|
|
## Glossary
|
|
|
|
### Feature Flag Definition
|
|
|
|
A global registry row in `public.feature_flags`.
|
|
|
|
This answers:
|
|
|
|
- What is the flag called?
|
|
- Is this flag globally active?
|
|
- What is the default treatment when a bodyshop has no explicit assignment?
|
|
- What description should admins see?
|
|
|
|
Example:
|
|
|
|
```text
|
|
name: Enhanced_Payroll
|
|
default_treatment: off
|
|
active: true
|
|
description: Enable enhanced payroll and labor allocation features.
|
|
```
|
|
|
|
### Bodyshop Assignment
|
|
|
|
A per-shop override row in `public.bodyshop_feature_flags`.
|
|
|
|
This answers:
|
|
|
|
- Which bodyshop has this flag assigned?
|
|
- What treatment does that bodyshop receive?
|
|
- Is there config attached to this flag?
|
|
- Is the assignment only active during a date window?
|
|
|
|
Example:
|
|
|
|
```text
|
|
bodyshopid: 2f9...
|
|
name: Enhanced_Payroll
|
|
treatment: on
|
|
config: null
|
|
activeDate: null
|
|
deactiveDate: null
|
|
```
|
|
|
|
### Treatment
|
|
|
|
The value returned for a flag. Historically this was commonly `on`, `off`, or `control`, but Split supports arbitrary
|
|
treatments, so our database now allows any non-empty text.
|
|
|
|
Examples:
|
|
|
|
```text
|
|
on
|
|
off
|
|
control
|
|
variant-a
|
|
new-workflow
|
|
demo
|
|
```
|
|
|
|
The client compatibility layer treats `off` and `control` as disabled in boolean-style code paths. Code that asks for
|
|
the treatment string can receive any treatment value.
|
|
|
|
### Config
|
|
|
|
Optional JSON attached to a bodyshop assignment.
|
|
|
|
Example:
|
|
|
|
```json
|
|
{
|
|
"buttonText": "Try new workflow",
|
|
"limit": 25
|
|
}
|
|
```
|
|
|
|
This is returned through `useTreatmentWithConfig` and `useTreatmentsWithConfig`.
|
|
|
|
### Active Date and Deactive Date
|
|
|
|
Optional schedule fields on an assignment:
|
|
|
|
- `activeDate`: the assignment should not turn on before this timestamp.
|
|
- `deactiveDate`: the assignment should stop being active at or after this timestamp.
|
|
|
|
Both fields are optional. If a flag is assigned with no dates, the assignment is simply active.
|
|
|
|
The canonical field names are:
|
|
|
|
```text
|
|
activeDate
|
|
deactiveDate
|
|
```
|
|
|
|
Avoid introducing aliases like `active_at`, `activeAt`, `active_date`, `inactiveDate`, etc. Keeping one naming convention
|
|
reduces mapping bugs and makes Hasura/admin/client behavior much easier to reason about.
|
|
|
|
## High-Level Architecture
|
|
|
|
```text
|
|
Admin Panel
|
|
|
|
|
| /adm/feature-flags
|
|
| /adm/bodyshops/:bodyshopId/feature-flags
|
|
| /adm/feature-flags/:name/bodyshops
|
|
v
|
|
Bodyshop Backend
|
|
|
|
|
| GraphQL admin client
|
|
v
|
|
Hasura/Postgres
|
|
|
|
|
| event triggers
|
|
v
|
|
Bodyshop Backend cache invalidation route
|
|
|
|
|
| updates Redis cache version/key
|
|
| emits feature-flags-changed
|
|
v
|
|
Redis + Socket.IO
|
|
|
|
|
| global event or bodyshop room event
|
|
v
|
|
Bodyshop Client SocketProvider
|
|
|
|
|
| window feature-flags-changed event
|
|
v
|
|
Feature flag compatibility layer refetches
|
|
|
|
Bodyshop Client
|
|
|
|
|
| imports local feature flag compatibility module
|
|
v
|
|
client/src/feature-flags/splitio-react-replacement.jsx
|
|
|
|
|
| GET /feature-flags/bodyshops/:bodyshopId
|
|
v
|
|
Bodyshop Backend
|
|
|
|
|
| Redis read-through cache
|
|
v
|
|
Hasura/Postgres when cache misses
|
|
```
|
|
|
|
## Runtime Request Flow
|
|
|
|
When the bodyshop client needs flags:
|
|
|
|
1. App code imports hooks/components from the local compatibility layer:
|
|
- `client/src/feature-flags/splitio-react-replacement.jsx`
|
|
2. The compatibility layer reads the active bodyshop from Redux.
|
|
3. It requests flags from:
|
|
- `GET /feature-flags/bodyshops/:bodyshopId`
|
|
4. The backend verifies the Firebase user can access that bodyshop through Hasura permissions.
|
|
5. The backend checks Redis for cached flags.
|
|
6. If Redis has a current-version cache entry, the backend returns it.
|
|
7. If Redis misses, the backend queries Hasura for:
|
|
- active feature flag definitions
|
|
- assignments for the requested bodyshop
|
|
8. The backend combines definitions and assignments into a Split-like payload.
|
|
9. The backend writes the payload to Redis with a short TTL.
|
|
10. The compatibility layer stores the successful payload in browser `localStorage` as a 24-hour last-known fallback.
|
|
11. The compatibility layer evaluates dates and exposes treatments/configs through Split-compatible hooks.
|
|
12. If a `feature-flags-changed` socket event arrives later, the compatibility layer refetches the runtime route.
|
|
|
|
## Database Tables
|
|
|
|
### `public.feature_flags`
|
|
|
|
This table stores global flag definitions.
|
|
|
|
Important columns:
|
|
|
|
| Column | Type | Meaning |
|
|
| --- | --- | --- |
|
|
| `name` | `text` | Primary key. This is the flag key used by the client. |
|
|
| `description` | `text` | Optional admin-facing explanation. |
|
|
| `default_treatment` | `text` | Treatment returned when a bodyshop does not have an assignment. Must be non-empty. |
|
|
| `active` | `boolean` | Globally enables/disables the flag definition. |
|
|
| `created_at` | `timestamptz` | Creation timestamp. |
|
|
| `updated_at` | `timestamptz` | Updated by trigger. |
|
|
|
|
Important behavior:
|
|
|
|
- `name` is the stable identifier. Renaming a flag is not currently supported from the admin UI.
|
|
- `active = false` removes the definition from normal runtime evaluation.
|
|
- `default_treatment` can be any non-empty text.
|
|
- Deleting a definition cascades per-bodyshop assignments because of the FK on `bodyshop_feature_flags.name`.
|
|
|
|
### `public.bodyshop_feature_flags`
|
|
|
|
This table stores per-bodyshop assignments.
|
|
|
|
Important columns:
|
|
|
|
| Column | Type | Meaning |
|
|
| --- | --- | --- |
|
|
| `id` | `uuid` | Row primary key. |
|
|
| `bodyshopid` | `uuid` | Bodyshop receiving this assignment. |
|
|
| `name` | `text` | Feature flag name. FK to `feature_flags.name`. |
|
|
| `treatment` | `text` | Treatment returned for this bodyshop. Must be non-empty. |
|
|
| `config` | `jsonb` | Optional treatment config. |
|
|
| `activeDate` | `timestamptz` | Optional start timestamp. |
|
|
| `deactiveDate` | `timestamptz` | Optional end timestamp. |
|
|
| `created_at` | `timestamptz` | Creation timestamp. |
|
|
| `updated_at` | `timestamptz` | Updated by trigger. |
|
|
|
|
Important constraints:
|
|
|
|
- Unique assignment per bodyshop and flag:
|
|
- `(bodyshopid, name)`
|
|
- `treatment` must be non-empty text.
|
|
- `deactiveDate` must be after `activeDate` if both are present.
|
|
- Assignments cascade delete when the bodyshop or feature flag is deleted.
|
|
|
|
## Hasura Metadata
|
|
|
|
The metadata tracks both new tables.
|
|
|
|
### `feature_flags`
|
|
|
|
Relationships:
|
|
|
|
- Array relationship to `bodyshop_feature_flags`.
|
|
|
|
Permissions:
|
|
|
|
- `user` role can select active definitions.
|
|
|
|
Event triggers:
|
|
|
|
- `cache_feature_flags`
|
|
- Fires on insert, update, and delete.
|
|
- Calls:
|
|
- `/feature-flags/cache/invalidate`
|
|
- This causes a global feature flag cache version bump in Redis.
|
|
|
|
### `bodyshop_feature_flags`
|
|
|
|
Relationships:
|
|
|
|
- Object relationship to `bodyshop`.
|
|
- Object relationship to `feature_flag`.
|
|
|
|
Permissions:
|
|
|
|
- `user` role can select rows only through active bodyshop associations and active feature flag definitions.
|
|
|
|
Event triggers:
|
|
|
|
- `cache_bodyshop_feature_flags`
|
|
- Fires on insert, update, and delete.
|
|
- Calls:
|
|
- `/feature-flags/cache/invalidate`
|
|
- This clears the affected bodyshop's current-version feature flag cache key.
|
|
|
|
## Backend Runtime Route
|
|
|
|
### `GET /feature-flags/bodyshops/:bodyshopId`
|
|
|
|
Location:
|
|
|
|
```text
|
|
server/routes/featureFlagRoutes.js
|
|
server/feature-flags/feature-flags.js
|
|
```
|
|
|
|
Middleware:
|
|
|
|
- Firebase auth validation.
|
|
- User GraphQL client.
|
|
|
|
Behavior:
|
|
|
|
1. Reads `bodyshopId` from the route.
|
|
2. Verifies the authenticated user can access the bodyshop using `CHECK_BODYSHOP_ACCESS`.
|
|
3. Reads the current feature flag cache version from Redis.
|
|
4. Attempts to read:
|
|
- `bodyshop-feature-flags:v<version>:<bodyshopId>`
|
|
5. If found, returns cached payload with:
|
|
- `source: "redis"`
|
|
6. If not found, queries Hasura using `GET_BODYSHOP_FEATURE_FLAGS`.
|
|
7. Builds a payload.
|
|
8. Stores it in Redis.
|
|
9. Returns payload with:
|
|
- `source: "database"`
|
|
|
|
Example response:
|
|
|
|
```json
|
|
{
|
|
"bodyshopId": "00000000-0000-0000-0000-000000000000",
|
|
"flags": {
|
|
"Enhanced_Payroll": {
|
|
"treatment": "on",
|
|
"config": null,
|
|
"activeDate": null,
|
|
"deactiveDate": null
|
|
},
|
|
"New_Workflow_Demo": {
|
|
"treatment": "demo",
|
|
"config": {
|
|
"buttonText": "Try it"
|
|
},
|
|
"activeDate": "2026-06-01T13:00:00+00:00",
|
|
"deactiveDate": "2026-06-05T21:00:00+00:00"
|
|
}
|
|
},
|
|
"cachedAt": "2026-05-19T15:00:00.000Z",
|
|
"source": "database"
|
|
}
|
|
```
|
|
|
|
### Dev Debug Logging
|
|
|
|
When `NODE_ENV === "development"`, the runtime route logs each hit with level `DEBUG`:
|
|
|
|
```text
|
|
feature-flags-route-hit
|
|
```
|
|
|
|
The log metadata includes:
|
|
|
|
- `bodyshopId`
|
|
- `source`
|
|
- `redis`
|
|
- `database`
|
|
- `flagCount`
|
|
|
|
This is helpful for confirming:
|
|
|
|
- The client is actually hitting the backend.
|
|
- Redis is being used after the first request.
|
|
- Cache invalidation causes the next request to read from the database again.
|
|
|
|
## Backend Cache Invalidation Route
|
|
|
|
### `POST /feature-flags/cache/invalidate`
|
|
|
|
Location:
|
|
|
|
```text
|
|
server/routes/featureFlagRoutes.js
|
|
server/feature-flags/feature-flags.js
|
|
```
|
|
|
|
Middleware:
|
|
|
|
- Event secret authorization.
|
|
|
|
This route is called by Hasura event triggers.
|
|
|
|
Behavior:
|
|
|
|
- If the event body includes a `bodyshopid`, it invalidates only that bodyshop's current-version cache key.
|
|
- If no `bodyshopid` is present, it treats the event as a global definition change and increments the global cache
|
|
version.
|
|
|
|
### Assignment Change
|
|
|
|
When `bodyshop_feature_flags` changes, the event includes either `event.data.new.bodyshopid` or
|
|
`event.data.old.bodyshopid`.
|
|
|
|
The backend deletes:
|
|
|
|
```text
|
|
bodyshop-feature-flags:v<currentVersion>:<bodyshopId>
|
|
```
|
|
|
|
The next request for that bodyshop reads from Hasura and writes a fresh current-version cache entry.
|
|
|
|
### Definition Change
|
|
|
|
When `feature_flags` changes, the event has no `bodyshopid`.
|
|
|
|
The backend increments:
|
|
|
|
```text
|
|
bodyshop-feature-flags:version
|
|
```
|
|
|
|
Example:
|
|
|
|
```text
|
|
bodyshop-feature-flags:version = 12
|
|
```
|
|
|
|
After increment:
|
|
|
|
```text
|
|
bodyshop-feature-flags:version = 13
|
|
```
|
|
|
|
All old keys such as:
|
|
|
|
```text
|
|
bodyshop-feature-flags:v12:<bodyshopId>
|
|
```
|
|
|
|
become invisible to the runtime route, because it now reads:
|
|
|
|
```text
|
|
bodyshop-feature-flags:v13:<bodyshopId>
|
|
```
|
|
|
|
The old keys are not eagerly deleted. They expire naturally after the configured TTL.
|
|
|
|
## Redis Cache Details
|
|
|
|
Location:
|
|
|
|
```text
|
|
server/utils/redisHelpers.js
|
|
```
|
|
|
|
Important constants:
|
|
|
|
```text
|
|
FEATURE_FLAGS_CACHE_TTL = 3600
|
|
FEATURE_FLAGS_CACHE_VERSION_KEY = bodyshop-feature-flags:version
|
|
```
|
|
|
|
Important helper functions:
|
|
|
|
- `getBodyshopFeatureFlagsCacheVersion`
|
|
- `getBodyshopFeatureFlagsFromRedis`
|
|
- `setBodyshopFeatureFlagsInRedis`
|
|
- `invalidateBodyshopFeatureFlagsInRedis`
|
|
- `invalidateAllBodyshopFeatureFlagsInRedis`
|
|
|
|
Cache key format:
|
|
|
|
```text
|
|
bodyshop-feature-flags:v<version>:<bodyshopId>
|
|
```
|
|
|
|
Global version key:
|
|
|
|
```text
|
|
bodyshop-feature-flags:version
|
|
```
|
|
|
|
Why versioning is used:
|
|
|
|
- Redis `KEYS bodyshop-feature-flags:*` can block Redis on a large keyspace.
|
|
- Feature flag definition changes affect all bodyshops.
|
|
- Incrementing a single version key is constant-time and cheap.
|
|
- Old entries naturally expire due to the 1 hour TTL.
|
|
|
|
Important operational note:
|
|
|
|
The version key does not need a TTL. It should remain in Redis and monotonically increase. If Redis is flushed, the
|
|
helper recreates the version key at `1`, and the cache warms again on demand.
|
|
|
|
## Admin Backend Routes
|
|
|
|
Location:
|
|
|
|
```text
|
|
server/routes/adminRoutes.js
|
|
server/admin/adminops.js
|
|
```
|
|
|
|
### `GET /adm/feature-flags`
|
|
|
|
Returns feature flag definitions.
|
|
|
|
Query params:
|
|
|
|
- `includeInactive=true`
|
|
- returns inactive definitions too.
|
|
|
|
Used by:
|
|
|
|
- Bodyshop edit screen.
|
|
- Feature flag CRUD screen.
|
|
|
|
### `POST /adm/feature-flags`
|
|
|
|
Creates a new feature flag definition.
|
|
|
|
Typical body:
|
|
|
|
```json
|
|
{
|
|
"name": "New_Workflow_Demo",
|
|
"description": "Enable the new demo workflow.",
|
|
"default_treatment": "off",
|
|
"active": true
|
|
}
|
|
```
|
|
|
|
After creation, all feature flag caches are invalidated by incrementing the Redis cache version.
|
|
The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch.
|
|
|
|
### `PUT /adm/feature-flags/:name`
|
|
|
|
Updates a feature flag definition.
|
|
|
|
The flag `name` is not edited. Other fields can change:
|
|
|
|
- `description`
|
|
- `default_treatment`
|
|
- `active`
|
|
|
|
After update, all feature flag caches are invalidated by incrementing the Redis cache version.
|
|
The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch.
|
|
|
|
### `DELETE /adm/feature-flags/:name`
|
|
|
|
Deletes a feature flag definition.
|
|
|
|
Because assignments FK to `feature_flags.name` with cascade delete, this also removes bodyshop assignments for that
|
|
flag.
|
|
|
|
After delete, all feature flag caches are invalidated by incrementing the Redis cache version.
|
|
The backend also emits a global `feature-flags-changed` socket event so open browser tabs refetch.
|
|
|
|
### `GET /adm/bodyshops/:bodyshopId/feature-flags`
|
|
|
|
Returns assignments for one bodyshop.
|
|
|
|
Used by the bodyshop edit screen.
|
|
|
|
### `GET /adm/feature-flags/:name/bodyshops`
|
|
|
|
Returns all bodyshops assigned to one flag.
|
|
|
|
Used by the flag CRUD screen's shop assignment drawer.
|
|
|
|
### `PUT /adm/feature-flags/:name/bodyshops`
|
|
|
|
Replaces the shop assignment list for one flag.
|
|
|
|
Typical body:
|
|
|
|
```json
|
|
{
|
|
"assignments": [
|
|
{
|
|
"bodyshopid": "00000000-0000-0000-0000-000000000000",
|
|
"treatment": "on",
|
|
"config": null,
|
|
"activeDate": null,
|
|
"deactiveDate": null
|
|
},
|
|
{
|
|
"bodyshopid": "11111111-1111-1111-1111-111111111111",
|
|
"treatment": "demo",
|
|
"config": {
|
|
"mode": "guided"
|
|
},
|
|
"activeDate": "2026-06-01T13:00:00.000Z",
|
|
"deactiveDate": "2026-06-05T21:00:00.000Z"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Important behavior:
|
|
|
|
- Assignments present in the request are upserted.
|
|
- Existing assignments for the flag that are not present in the request are deleted.
|
|
- Redis caches for changed bodyshops are invalidated.
|
|
- The backend emits `feature-flags-changed` socket events for changed bodyshops.
|
|
|
|
### `POST /adm/updateshop`
|
|
|
|
The existing bodyshop update route also saves feature flag assignments.
|
|
|
|
It accepts `bodyshop.featureFlags` and stores those assignments in `bodyshop_feature_flags`, not in the legacy
|
|
`bodyshops.features.featureFlags` JSON location.
|
|
|
|
This keeps the bodyshop edit screen working while moving feature flags into first-class tables.
|
|
After assignment changes, Redis caches for the changed bodyshop are invalidated and a bodyshop-scoped
|
|
`feature-flags-changed` socket event is emitted.
|
|
|
|
## Admin Panel UI
|
|
|
|
Admin repo:
|
|
|
|
```text
|
|
C:\Users\DaveRicher\WebstormProjects\bodyshop-admin
|
|
```
|
|
|
|
### Bodyshop Edit Screen
|
|
|
|
File:
|
|
|
|
```text
|
|
src/components/bodyshop-manage/bodyshop-manage.component.jsx
|
|
```
|
|
|
|
Capabilities:
|
|
|
|
- View assignments for a bodyshop.
|
|
- Add a flag assignment.
|
|
- Remove a flag assignment.
|
|
- Set the assignment treatment.
|
|
- Set optional `activeDate`.
|
|
- Set optional `deactiveDate`.
|
|
|
|
This screen is useful when the workflow starts with a bodyshop:
|
|
|
|
> "Turn on `Enhanced_Payroll` for APPLE."
|
|
|
|
### Feature Flag CRUD Screen
|
|
|
|
File:
|
|
|
|
```text
|
|
src/components/feature-flags/feature-flags.component.jsx
|
|
```
|
|
|
|
Route:
|
|
|
|
```text
|
|
/feature-flags
|
|
```
|
|
|
|
Capabilities:
|
|
|
|
- View all feature flag definitions.
|
|
- Search definitions by name/description.
|
|
- Create a new flag.
|
|
- Edit an existing flag.
|
|
- Delete a flag.
|
|
- See assignment counts.
|
|
- Open the shops drawer for a flag.
|
|
- Add/remove bodyshops from a flag.
|
|
- Set treatment/config/dates for each assigned shop.
|
|
|
|
This screen is useful when the workflow starts with a feature:
|
|
|
|
> "We built `New_Workflow_Demo`; assign it to APPLE, DEMO, and QBO."
|
|
|
|
### Custom Treatments in Admin
|
|
|
|
Treatment fields use preset suggestions for:
|
|
|
|
- `on`
|
|
- `off`
|
|
- `control`
|
|
|
|
Admins can type custom treatment values as well.
|
|
|
|
Examples:
|
|
|
|
```text
|
|
variant-a
|
|
variant-b
|
|
demo
|
|
new-ui
|
|
legacy-ui
|
|
```
|
|
|
|
## Bodyshop Frontend Compatibility Layer
|
|
|
|
File:
|
|
|
|
```text
|
|
client/src/feature-flags/splitio-react-replacement.jsx
|
|
```
|
|
|
|
The client imports the Split-shaped local API:
|
|
|
|
```js
|
|
import { useTreatment, useTreatmentsWithConfig } from "../feature-flags/splitio-react-replacement";
|
|
```
|
|
|
|
The relative path varies by file location. The important part is that all feature flag imports should point to
|
|
`client/src/feature-flags/splitio-react-replacement.jsx`, not to the old Split package. We no longer rely on a Vite
|
|
alias to replace that package; code should import our local module directly.
|
|
|
|
Currently supported exports include:
|
|
|
|
- `SplitFactoryProvider`
|
|
- `useSplitClient`
|
|
- `useTreatmentsWithConfig`
|
|
- `useTreatment`
|
|
- `useTreatmentWithConfig`
|
|
- `SplitContext`
|
|
- `useSplitContext`
|
|
|
|
Unknown flags default to `off`.
|
|
|
|
### Date Evaluation
|
|
|
|
Dates are evaluated on the client compatibility layer.
|
|
|
|
Expected behavior:
|
|
|
|
- No dates:
|
|
- assignment is active immediately.
|
|
- `activeDate` only:
|
|
- assignment is inactive before `activeDate`.
|
|
- assignment is active at/after `activeDate`.
|
|
- `deactiveDate` only:
|
|
- assignment is active before `deactiveDate`.
|
|
- assignment is inactive at/after `deactiveDate`.
|
|
- Both dates:
|
|
- assignment is active in the half-open interval:
|
|
- `activeDate <= now < deactiveDate`
|
|
|
|
This makes demo windows possible without needing a background job to flip rows. The compatibility layer also schedules a
|
|
browser timer for the next `activeDate` or `deactiveDate` boundary so an already-open tab reevaluates the flag when the
|
|
window opens or closes.
|
|
|
|
### Browser Fallback Cache
|
|
|
|
For outage resilience, the compatibility layer stores the last successful backend flag payload in browser
|
|
`localStorage`. The browser cache is valid for up to 24 hours.
|
|
|
|
The key is scoped by bodyshop:
|
|
|
|
```text
|
|
bodyshop-feature-flags:<bodyshopId>
|
|
```
|
|
|
|
Fallback order:
|
|
|
|
```text
|
|
1. Backend response from GET /feature-flags/bodyshops/:bodyshopId
|
|
2. Last-known browser cache for that bodyshop
|
|
3. Unknown flags resolve to off
|
|
```
|
|
|
|
The client does not use old `bodyshops.features.featureFlags` JSON as a feature flag fallback. Feature flags now come
|
|
from the backend/db-backed system or from the last-known browser cache created from that backend response.
|
|
|
|
### Live Browser Refresh
|
|
|
|
Feature flag changes are pushed to open browser tabs through the existing Socket.IO infrastructure.
|
|
|
|
Backend emit helper:
|
|
|
|
```text
|
|
server/feature-flags/socket-events.js
|
|
```
|
|
|
|
Frontend listener/bridge:
|
|
|
|
```text
|
|
client/src/contexts/SocketIO/socketProvider.jsx
|
|
client/src/feature-flags/splitio-react-replacement.jsx
|
|
```
|
|
|
|
Socket event name:
|
|
|
|
```text
|
|
feature-flags-changed
|
|
```
|
|
|
|
Payload shape:
|
|
|
|
```json
|
|
{
|
|
"bodyshopId": "00000000-0000-0000-0000-000000000000",
|
|
"changedAt": "2026-05-19T15:00:00.000Z",
|
|
"name": "Enhanced_Payroll",
|
|
"scope": "bodyshop",
|
|
"source": "admin",
|
|
"table": "bodyshop_feature_flags"
|
|
}
|
|
```
|
|
|
|
Important fields:
|
|
|
|
- `scope`
|
|
- `global`: every open tab should refetch.
|
|
- `bodyshop`: only tabs for the matching `bodyshopId` should refetch.
|
|
- `source`
|
|
- `admin`: emitted after admin backend writes.
|
|
- `hasura`: emitted after Hasura event-trigger invalidation.
|
|
- `table`
|
|
- `feature_flags`: global definition change.
|
|
- `bodyshop_feature_flags`: per-bodyshop assignment change.
|
|
|
|
Backend behavior:
|
|
|
|
- Definition changes emit globally with `scope: "global"`.
|
|
- Assignment changes emit to the bodyshop room when a bodyshop id is known.
|
|
- If a bodyshop room helper is unavailable, the fallback room name is:
|
|
|
|
```text
|
|
bodyshop-broadcast-room:<bodyshopId>
|
|
```
|
|
|
|
Frontend behavior:
|
|
|
|
- `SocketProvider` listens for the socket event.
|
|
- It bridges the socket event into a browser `CustomEvent` with the same event name.
|
|
- `SplitFactoryProvider` listens for that browser event.
|
|
- Global events always trigger a refetch.
|
|
- Bodyshop events trigger a refetch only when `payload.bodyshopId` matches the active Redux bodyshop.
|
|
- Refetches are debounced for 150 ms to collapse duplicate admin/Hasura events.
|
|
|
|
If the refetch fails because the backend is unreachable, the normal browser fallback cache behavior still applies.
|
|
|
|
## Harness/Split Export and Import
|
|
|
|
Exporter script:
|
|
|
|
```text
|
|
scripts/export-harness-feature-flags.js
|
|
```
|
|
|
|
NPM script:
|
|
|
|
```text
|
|
npm run feature-flags:export-harness
|
|
```
|
|
|
|
Output folder:
|
|
|
|
```text
|
|
harness-feature-flags-export
|
|
```
|
|
|
|
Important output files:
|
|
|
|
- `feature_flags.json`
|
|
- `bodyshop_feature_flags.json`
|
|
- `global_defaults.json`
|
|
- `unmapped_rules.json`
|
|
- `raw/sdk_split_changes.json`
|
|
- `bodyshop_feature_flags_import.sql`
|
|
|
|
The import SQL maps Harness/Split target keys to bodyshops by matching:
|
|
|
|
```sql
|
|
lower("bodyshops"."imexshopid") = lower("exported_flags"."imexshopid")
|
|
```
|
|
|
|
This matters because Harness targeting was based on `imexshopid`, not the bodyshop UUID.
|
|
|
|
The generated SQL also reports:
|
|
|
|
- exported Harness target keys that did not match any `bodyshops.imexshopid`
|
|
- exported feature flag names that did not already exist in `public.feature_flags`
|
|
|
|
The importer preserves custom treatment names and casts missing configs as `NULL::jsonb`, which avoids Postgres treating
|
|
the `config` column as text during import.
|
|
|
|
### Export Completeness Caveat
|
|
|
|
The exporter can only export the project/environment visible to the supplied key.
|
|
|
|
If the key is an SDK key, it exports what that SDK key can read. It does not prove that every Harness project,
|
|
environment, or SDK key has been exported.
|
|
|
|
For complete migration confidence, collect either:
|
|
|
|
- a Harness Admin API key with access to the relevant projects/environments, or
|
|
- all SDK keys used by the environments we care about.
|
|
|
|
## Applying DB Changes
|
|
|
|
The feature flag migrations are in:
|
|
|
|
```text
|
|
hasura/migrations
|
|
```
|
|
|
|
Important migrations:
|
|
|
|
```text
|
|
1778870000000_create_table_public_feature_flags
|
|
1778950000000_create_table_public_bodyshop_feature_flags
|
|
1779040000000_allow_custom_feature_flag_treatments
|
|
```
|
|
|
|
After migrations, apply Hasura metadata so relationships, permissions, and event triggers are active.
|
|
|
|
Important metadata file:
|
|
|
|
```text
|
|
hasura/metadata/tables.yaml
|
|
```
|
|
|
|
After applying migrations/metadata:
|
|
|
|
1. Restart the backend.
|
|
2. Restart the admin panel if needed.
|
|
3. Create or edit a flag in admin.
|
|
4. Assign it to a bodyshop.
|
|
5. Request runtime flags for that bodyshop.
|
|
|
|
## Recommended Smoke Test
|
|
|
|
Use this flow after applying DB changes or changing cache behavior.
|
|
|
|
### 1. Create a Test Flag
|
|
|
|
In admin:
|
|
|
|
- Go to `/feature-flags`.
|
|
- Create:
|
|
|
|
```text
|
|
name: Demo_Test_Flag
|
|
default_treatment: off
|
|
active: true
|
|
```
|
|
|
|
### 2. Assign a Bodyshop
|
|
|
|
From the same flag screen:
|
|
|
|
- Open `Shops`.
|
|
- Add one test bodyshop.
|
|
- Set treatment:
|
|
|
|
```text
|
|
on
|
|
```
|
|
|
|
Optional:
|
|
|
|
- Add an `activeDate`.
|
|
- Add a `deactiveDate`.
|
|
- Add config JSON.
|
|
|
|
### 3. Hit Runtime Route
|
|
|
|
Call:
|
|
|
|
```text
|
|
GET /feature-flags/bodyshops/:bodyshopId
|
|
```
|
|
|
|
Expected first response:
|
|
|
|
```text
|
|
source: database
|
|
```
|
|
|
|
Expected payload:
|
|
|
|
```json
|
|
{
|
|
"flags": {
|
|
"Demo_Test_Flag": {
|
|
"treatment": "on"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Hit Runtime Route Again
|
|
|
|
Expected second response:
|
|
|
|
```text
|
|
source: redis
|
|
```
|
|
|
|
### 5. Change the Assignment
|
|
|
|
In admin:
|
|
|
|
- Change treatment from `on` to `off`, or to a custom treatment like `demo`.
|
|
- Save.
|
|
|
|
Expected next runtime response:
|
|
|
|
```text
|
|
source: database
|
|
```
|
|
|
|
The changed bodyshop's cache should have been invalidated.
|
|
Any open browser tab for that bodyshop should update without a manual refresh.
|
|
|
|
### 6. Change the Definition
|
|
|
|
In admin:
|
|
|
|
- Change the flag description, active state, or default treatment.
|
|
- Save.
|
|
|
|
Expected behavior:
|
|
|
|
- Redis global feature flag cache version increments.
|
|
- Next runtime request for any bodyshop reads from database and stores under the new versioned key.
|
|
- Any open browser tab should receive a global socket event and refetch.
|
|
|
|
### 7. Confirm the Footer Test Indicator
|
|
|
|
The bodyshop frontend footer has a temporary manual test hook:
|
|
|
|
```text
|
|
client/src/components/global-footer/global-footer.component.jsx
|
|
```
|
|
|
|
When `TEST_FLAG` resolves to treatment `on`, the footer displays:
|
|
|
|
```text
|
|
Test Feature Flag Enabled
|
|
```
|
|
|
|
When `TEST_FLAG` is `off`, missing, globally inactive, outside its schedule window, or unavailable with no browser cache,
|
|
the footer indicator is hidden.
|
|
|
|
## Troubleshooting
|
|
|
|
### Admin Cannot See Flags
|
|
|
|
Check:
|
|
|
|
- Backend is restarted.
|
|
- Hasura migrations are applied.
|
|
- Hasura metadata is applied.
|
|
- `/adm/feature-flags` is registered in `server/routes/adminRoutes.js`.
|
|
- Admin user passes `validateAdminMiddleware`.
|
|
|
|
### Runtime Route Returns 403
|
|
|
|
The backend verifies bodyshop access with the user's Hasura permissions.
|
|
|
|
Check:
|
|
|
|
- The Firebase token is valid.
|
|
- The user has an active association to the bodyshop.
|
|
- The bodyshop id in the route is correct.
|
|
|
|
### Runtime Route Returns No Custom Flag
|
|
|
|
Check:
|
|
|
|
- The flag definition exists in `feature_flags`.
|
|
- The flag definition has `active = true`.
|
|
- The bodyshop assignment exists in `bodyshop_feature_flags`.
|
|
- The assignment dates are not excluding the current time.
|
|
- The bodyshop id is the UUID, not `imexshopid`.
|
|
|
|
### Assignment Save Fails with Date Constraint
|
|
|
|
If both dates are present:
|
|
|
|
```text
|
|
deactiveDate > activeDate
|
|
```
|
|
|
|
must be true.
|
|
|
|
### Config Save Fails
|
|
|
|
Config must be valid JSON.
|
|
|
|
Valid:
|
|
|
|
```json
|
|
{
|
|
"limit": 10
|
|
}
|
|
```
|
|
|
|
Invalid:
|
|
|
|
```json
|
|
{
|
|
limit: 10
|
|
}
|
|
```
|
|
|
|
### Treatment Save Fails
|
|
|
|
Treatment must be non-empty text.
|
|
|
|
Valid:
|
|
|
|
```text
|
|
on
|
|
off
|
|
control
|
|
variant-a
|
|
```
|
|
|
|
Invalid:
|
|
|
|
```text
|
|
|
|
```
|
|
|
|
### Cache Does Not Seem to Invalidate
|
|
|
|
Check whether the route response has:
|
|
|
|
```text
|
|
source: redis
|
|
```
|
|
|
|
or:
|
|
|
|
```text
|
|
source: database
|
|
```
|
|
|
|
In development, look for `feature-flags-route-hit` debug logs.
|
|
|
|
For assignment changes:
|
|
|
|
- Ensure the `cache_bodyshop_feature_flags` Hasura event trigger exists.
|
|
- Ensure event secret is correct.
|
|
- Ensure the event includes `bodyshopid`.
|
|
|
|
For definition changes:
|
|
|
|
- Ensure the `cache_feature_flags` Hasura event trigger exists.
|
|
- Ensure event secret is correct.
|
|
- Confirm Redis version changes:
|
|
|
|
```text
|
|
bodyshop-feature-flags:version
|
|
```
|
|
|
|
### Old Cache Keys Remain in Redis
|
|
|
|
This is expected.
|
|
|
|
Old versioned keys are intentionally not deleted during global invalidation. They become invisible because the runtime
|
|
route reads the new version. They expire naturally after `FEATURE_FLAGS_CACHE_TTL`, currently 1 hour.
|
|
|
|
## Operational Notes
|
|
|
|
### Renaming Flags
|
|
|
|
Flag names are primary keys and are not editable in the admin UI.
|
|
|
|
If a flag must be renamed:
|
|
|
|
1. Create the new flag.
|
|
2. Reassign bodyshops to the new flag.
|
|
3. Update code references.
|
|
4. Delete the old flag once no code uses it.
|
|
|
|
Do not rename a flag directly in the database unless all client code references are updated at the same time.
|
|
|
|
### Deleting Flags
|
|
|
|
Deleting a flag definition cascades assignments.
|
|
|
|
This is usually what we want, but be careful with production flags. If unsure, set `active = false` first and observe
|
|
behavior before deleting.
|
|
|
|
### Default Treatments
|
|
|
|
Defaults apply only when a bodyshop has no explicit assignment for a flag.
|
|
|
|
Example:
|
|
|
|
- `feature_flags.default_treatment = off`
|
|
- no `bodyshop_feature_flags` row
|
|
- runtime treatment is `off`
|
|
|
|
If an assignment exists, assignment treatment wins.
|
|
|
|
### Active Flag Definition vs Assignment Schedule
|
|
|
|
Both layers matter:
|
|
|
|
- Definition `active = false` means the flag does not appear in runtime definitions.
|
|
- Assignment dates determine whether a bodyshop's assigned treatment is currently active.
|
|
|
|
For demo scheduling, keep the definition active and place the window on the assignment.
|
|
|
|
### Bodyshop ID vs Imex Shop ID
|
|
|
|
Runtime route uses bodyshop UUID:
|
|
|
|
```text
|
|
/feature-flags/bodyshops/:bodyshopId
|
|
```
|
|
|
|
Harness export/import maps from `imexshopid` because Harness target keys used that value.
|
|
|
|
Do not mix these up:
|
|
|
|
- Runtime and database assignments use `bodyshopid` UUID.
|
|
- Migration/import matching uses `imexshopid`.
|
|
|
|
## Relevant Files
|
|
|
|
Bodyshop backend:
|
|
|
|
```text
|
|
server/feature-flags/feature-flags.js
|
|
server/routes/featureFlagRoutes.js
|
|
server/admin/adminops.js
|
|
server/routes/adminRoutes.js
|
|
server/utils/redisHelpers.js
|
|
server/graphql-client/queries.js
|
|
server.js
|
|
```
|
|
|
|
Bodyshop client:
|
|
|
|
```text
|
|
client/vite.config.js
|
|
client/src/feature-flags/splitio-react-replacement.jsx
|
|
client/src/feature-flags/README.md
|
|
```
|
|
|
|
Hasura:
|
|
|
|
```text
|
|
hasura/migrations/1778870000000_create_table_public_feature_flags
|
|
hasura/migrations/1778950000000_create_table_public_bodyshop_feature_flags
|
|
hasura/migrations/1779040000000_allow_custom_feature_flag_treatments
|
|
hasura/metadata/tables.yaml
|
|
```
|
|
|
|
Admin panel:
|
|
|
|
```text
|
|
../bodyshop-admin/src/components/feature-flags/feature-flags.component.jsx
|
|
../bodyshop-admin/src/components/bodyshop-manage/bodyshop-manage.component.jsx
|
|
../bodyshop-admin/src/components/router/router.component.jsx
|
|
../bodyshop-admin/src/components/main-sider/main-sider.component.jsx
|
|
```
|
|
|
|
Harness export:
|
|
|
|
```text
|
|
scripts/export-harness-feature-flags.js
|
|
harness-feature-flags-export
|
|
```
|
|
|
|
## Future Improvements
|
|
|
|
These are not required for the current rollout, but they are worth considering.
|
|
|
|
### Audit Trail
|
|
|
|
Feature flags control production behavior. It would be useful to record:
|
|
|
|
- who created a flag
|
|
- who edited a definition
|
|
- who assigned or removed a bodyshop
|
|
- old value
|
|
- new value
|
|
- timestamp
|
|
|
|
This can be a dedicated audit table or integrated with the existing audit logging pattern if appropriate.
|
|
|
|
### Export Completeness
|
|
|
|
The current Harness export path should be repeated for every project/environment/key that matters, or replaced with an
|
|
Admin API export if Harness access allows it.
|
|
|
|
### More Tests
|
|
|
|
Useful additional automated coverage:
|
|
|
|
- A fuller admin route integration test that exercises create, update, delete, and assignment replacement together.
|
|
- A browser-level smoke test that toggles `TEST_FLAG` in admin and observes the footer update without a page refresh.
|
|
- Importer coverage for multiple Harness environments once we have more than one export key.
|