Skip to content

FeatureFlag multiple groupsets #3311

Description

@danoswaltCL

Feature: Support multiple groupsets in /featureflag requests.

Summary

Allow a client to request feature flag evaluations for multiple groups in a single call, each keyed by a client-supplied groupsetId, instead of requiring one request per group — while still resolving to a single "main" flag set for the common case of checking one flag for the user as a whole.

Use Case

As a client, I want to give UpGrade a list of schoolIds associated with my user for a given app-context, and get back which schools qualify for a feature — in the same request as checking a flag for the user overall, since both are typically needed together on page load.

The direct use-case comes from the requirements listed here: https://carnegielearning.atlassian.net/browse/RPT-8216

Which boil down to: "feature set may vary per school as user navigates....no additional fetch on each navigation" is where i read an assumption of this ability we don't yet support, multiple "feature-sets" w/o multiple fetches.

Problem

/featureflag responses currently return one set of flags for the entire request. The groups provided (groupsForSession) are run through inclusion/exclusion logic per active flag as a single set — not evaluated individually per group member.

Example

Suppose the ANALYTICS_HUB context contains two flags, SHOW_PANEL and SHOW_SCHOOL_LEVEL_FEATURE. Everyone should see the main panel, but the "school_level_feature" is only rolling out to schoolId_01 and schoolId_02.

Flag Inclusion List Exclusion List
SHOW_PANEL everyone nobody
SHOW_SCHOOL_LEVEL_FEATURE [schoolId_01, schoolId_02] everyone else — user must be associated with schoolId_01 or schoolId_02

Request:

{
  "context": "ANALYTICS_HUB",
  "groupsForSession": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] },
  "includeStoredUserGroups": false
}

Response:

["SHOW_PANEL"]

Because schoolId_03 is part of the group set, the whole set is evaluated as "excluded" for SHOW_SCHOOL_LEVEL_FEATURE — no flag string is returned, even though schoolId_01 and schoolId_02 should qualify.

Consequence: to get individual schoolId-level results, a client must currently send multiple separate requests — one per schoolId.

Proposed Solution

This was prototyped end-to-end (backend + JS client library + a small Angular playground app) and demoed in refinement, where this approach was agreed as the direction to build. The design settled on two explicit, mutually exclusive request shapes rather than one shape that's always a list — see Design Notes for why.

useSingleGroupSet — unchanged behavior, new name

The existing single-groupset behavior (today's groupsForSession/includeStoredUserGroups), just renamed for symmetry with the new multi-groupset shape below. Response is a flat string[], exactly as today.

{
  "context": "ANALYTICS_HUB",
  "useSingleGroupSet": {
    "groups": { "schoolId": ["schoolId_01"] },
    "includeStoredUserGroups": false
  }
}
["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"]

useMultipleGroupSets — the new capability

An optional mainGroupset (the "whole user" evaluation — everything the caller passed in, evaluated as one set, exactly like useSingleGroupSet would) plus one or more subGroupsets, each a fully independent evaluation keyed by a caller-supplied groupsetId. All of it is resolved server-side in one request/one DB round-trip per user.

Request:

{
  "context": "ANALYTICS_HUB",
  "useMultipleGroupSets": {
    "mainGroupset": {
      "groups": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] }
    },
    "subGroupsets": [
      { "groupsetId": "schoolId_01", "groups": { "schoolId": ["schoolId_01"] } },
      { "groupsetId": "schoolId_02", "groups": { "schoolId": ["schoolId_02"] } },
      { "groupsetId": "schoolId_03", "groups": { "schoolId": ["schoolId_03"] } }
    ]
  }
}

Response (shape changes only when useMultipleGroupSets is used — see below):

{
  "mainGroupset": ["SHOW_PANEL"],
  "subGroupsets": {
    "schoolId_01": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"],
    "schoolId_02": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"],
    "schoolId_03": ["SHOW_PANEL"]
  }
}

mainGroupset is optional — a request can supply only subGroupsets if there's nothing meaningful to evaluate for "the user as a whole."

Response contract

  • No useMultipleGroupSets in the request → flat string[], same as today. This includes useSingleGroupSet requests and the deprecated groupsForSession shape.
  • useMultipleGroupSets in the request → { mainGroupset?: string[]; subGroupsets: Record<string, string[]> }.

Validation rules

  • useSingleGroupSet and useMultipleGroupSets are mutually exclusive on a single request.
  • useMultipleGroupSets.subGroupsets must contain at least one entry; each entry requires an explicit groupsetId (no auto-generation) and its own groups; groupsetIds must be unique within the request; a capped max entry count applies.

Client Library

interface ISingleGroupSetOptions {
  groups: Record<string, string[]>;
  includeStoredUserGroups?: boolean; // optional, defaults to false
}

interface ISubGroupSetOptions extends ISingleGroupSetOptions {
  groupsetId: string; // required — caller always supplies it, backend doesn't do anything with this, it is purely for the caller to know how to retrieve the feature flags for this specific groupset. recommended to just use the groupId or a composite of concatenated groupIds themselves as the keys.
}

interface IMultipleGroupSetsOptions {
  mainGroupset?: ISingleGroupSetOptions;   // optional — the hasFeatureFlag(key)-with-no-id default
  subGroupsets: ISubGroupSetOptions[];     // required, at least one entry
}

Configure (optional — set up ahead of time, or pass ad-hoc per call)

new UpgradeClient(userId, hostUrl, context, {
  featureFlagGroupOptions: {
    useMultipleGroupSets: {
      mainGroupset: { groups: { schoolId: ['schoolId_01', 'schoolId_02', 'schoolId_03'] } },
      subGroupsets: [
        { groupsetId: 'schoolId_01', groups: { schoolId: ['schoolId_01'] } },
        { groupsetId: 'schoolId_02', groups: { schoolId: ['schoolId_02'] } },
        { groupsetId: 'schoolId_03', groups: { schoolId: ['schoolId_03'] } },
      ],
    },
  },
});

Fetch — one call, one network request, regardless of how many groupsets are configured:

await upgradeClient.getAllFeatureFlags();

Retrieve — reads straight from the cache the call above populated, no further network calls:

await upgradeClient.hasFeatureFlag('SHOW_PANEL);                  // resolves to mainGroupset
await upgradeClient.hasFeatureFlag('SHOW_SCHOOL_LEVEL_FEATURE', 'schoolId_01');     // resolves to that named subGroupset

If useMultipleGroupSets is configured without a mainGroupset, hasFeatureFlag(key) with no id throws (nothing to default to) — pass a groupsetId explicitly instead. This matches today's behavior for an ambiguous/unconfigured lookup.

Design Notes: why two shapes

An earlier pass at this considered always accepting a list of groupsets (no special-cased "main" one). That fails a very common real case: checking one flag "for the user as a whole" alongside other flags scoped to specific groups. With no privileged entry, hasFeatureFlag(key) with no id becomes ambiguous, which either forces every caller to invent an id for "the whole user" case or forces a second network round trip just to resolve it. Making mainGroupset a first-class, optional part of useMultipleGroupSets solves this directly, and keeping useSingleGroupSet as a separate, simpler shape (rather than "a list with one item") keeps the common case as simple as it is today.

Backward Compatibility

The deprecated request/library surface (groupsForSession + includeStoredUserGroups at the request's top level; featureFlagUserGroupsForSession / setFeatureFlagUserGroupsForSession in the JS client library) continues to work unchanged, translating internally to useSingleGroupSet. Existing integrations require no changes.

Out of Scope

The Java and Python client libraries are not part of this iteration — they only ever send the deprecated flat request shape today, which is unaffected by this change (the response for that shape stays a flat string[]). Extending useSingleGroupSet/useMultipleGroupSets support to those libraries can be tracked as separate follow-up work if/when needed.

Acceptance Criteria

  • /featureflag accepts useSingleGroupSet and useMultipleGroupSets (mutually exclusive), alongside the still-supported deprecated top-level fields.
  • useMultipleGroupSets.subGroupsets requires ≥1 entry, each with a required unique groupsetId.
  • Response shape is conditional: flat string[] unless useMultipleGroupSets was used, in which case { mainGroupset?: string[]; subGroupsets: Record<string, string[]> }.
  • All groupsets in a useMultipleGroupSets request are resolved in a single request/DB round-trip per user (not N sequential lookups).
  • JS client library: featureFlagGroupOptions.useSingleGroupSet / .useMultipleGroupSets, getAllFeatureFlags(), and hasFeatureFlag(key, groupsetId?) behave per the rules above.
  • Deprecated groupsForSession/includeStoredUserGroups/featureFlagUserGroupsForSession continue to work unchanged.
  • Java and Python client libraries are explicitly out of scope for this issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions