Node.js API Feature Flags: Enabled Checking Explained for Game Sessions
A feature flag check is easy to write. The hard part is proving which answer a game session received after a player reports a broken match.
For a one-person SaaS, I would record one structured decision event at the flag evaluation boundary, with the flag key, returned state, and a stable session identifier. I would not log every internal lookup, request payload, or retry. That keeps the evidence useful enough to reconstruct an incident without turning normal gameplay into a wall of noise.
The implementation below uses a generic Node.js interface. It does not depend on a flag vendor or a particular storage product. The important design choice is the event contract. This approach has a limit: it cannot reconstruct a decision that was never emitted, and it cannot replace a durable configuration history when the flag definition itself changes.
The trade-off is deliberate. This event contract does not fit a high-volume path that needs every rule evaluation for statistical analysis; that path should use a separate sampled stream. It fits incident reconstruction, where one authoritative decision is more useful than several noisy lookups.
A minimal check can be as small as this:
type FlagCheck = { flagKey: string; enabled: boolean; sessionId: string }; function checkFlag(flag: FlagCheck, emit: (event: FlagCheck) => void): boolean { emit(flag); return flag.enabled; } What evidence does a flag check need?
The answer is smaller than most teams expect. To replay a decision, an event needs to connect four things: the flag being evaluated, the result, the evaluation context, and the time at which the decision was made.
A gaming service might evaluate ranked-matchmaking-v2\ for a player joining a match. A useful record could contain:
-
flag_key\: the stable name of the decision -
enabled\: the boolean result used by the application -
session_id\: the game session or request correlation identifier -
subject_id\: a privacy-reviewed player or account reference -
variant\: the selected value when the flag has more than two outcomes -
timestamp\: the event time in a standard format
The first three fields answer the immediate support question: which decision did this session receive? The remaining fields explain why two players may have received different results.
I would also add an event name such as feature_flag.evaluated\ and a schema version. Those fields make filtering and later changes explicit. They are more valuable than copying the entire evaluation context into every log line.
OpenTelemetry treats logs as a signal that can carry structured attributes and correlate with other signals. That is the useful boundary here: emit a structured event that can be joined to a trace or other session evidence, rather than sprinkling strings through the game code.
A minimal Node.js implementation
The application should decide whether a flag is enabled. The recorder should observe that decision and emit a small event. Keeping those responsibilities separate matters because a logging failure should not change gameplay.
\`ts
type FlagContext = {
flagKey: string;
sessionId: string;
subjectId?: string;
variant?: string;
};
type FlagDecision = FlagContext & {
enabled: boolean;
};
type FlagEvent = {
name: "feature_flag.evaluated";
schemaVersion: 1;
timestamp: string;
attributes: {
"feature.flag_key": string;
"feature.enabled": boolean;
"game.session_id": string;
"game.subject_id"?: string;
"feature.variant"?: string;
};
};
type EventSink = (event: FlagEvent) => void;
export function checkFeatureFlag(
context: FlagContext,
enabled: boolean,
emit: EventSink,
): boolean {
const decision: FlagDecision = { ...context, enabled };
try {
emit({
name: "feature_flag.evaluated",
schemaVersion: 1,
timestamp: new Date().toISOString(),
attributes: {
"feature.flag_key": decision.flagKey,
"feature.enabled": decision.enabled,
"game.session_id": decision.sessionId,
...(decision.subjectId
? { "game.subject_id": decision.subjectId }
: {}),
...(decision.variant
? { "feature.variant": decision.variant }
: {}),
},
});
} catch {
// Observability must not block a player's flag decision.
}
return decision.enabled;
}
`\
A real flag evaluator can supply the enabled\ value. The example is deliberately boring: it records the value the application actually used, not a second lookup that could disagree with the request path. The try\ block is also intentional. If the event sink is unavailable, the service continues with the decision already in memory. That is a product behavior choice, not a license to ignore the failed telemetry. The sink should count its own dropped events through a separate health signal.
A call site can stay close to the business decision:
\`ts
const rankedMatchmaking = checkFeatureFlag(
{
flagKey: "ranked-matchmaking-v2",
sessionId: match.sessionId,
subjectId: player.accountReference,
variant: "treatment",
},
flagStore.isEnabled("ranked-matchmaking-v2", player),
eventSink,
);
if (rankedMatchmaking) {
return matchmaking.enqueueRanked(match);
}
return matchmaking.enqueueStandard(match);
`\
This is an API example for checking whether a flag is enabled, but the boolean is only half the result. The emitted event captures the other half: what the service believed at that exact decision point.
Why is less telemetry better for incident replay?
The failure mode is familiar. A team starts with one log line for flag loading, another for cache lookup, another for targeting, and one more for the branch that follows. A single match can then produce several records that look like decisions. During an incident, an engineer has to guess which one controlled the player-visible behavior.
Gaming makes this expensive. A session may contain many requests, reconnects, background refreshes, and retry paths. Logging every evaluation can create a large stream while still failing to answer the key question: which evaluation changed the outcome?
The useful compromise is to log at the boundary where the boolean or variant enters application behavior. If the same flag is checked three times in one request, the service can choose one of two policies: record all evaluations and attach an evaluation sequence, or record only the decision that gates the player-visible operation. The second policy has lower noise. The first is better when reevaluation itself is a suspected failure. Pick the policy per decision, and document it in the event schema.
Do not put raw rule expressions, full player profiles, authentication tokens, or request bodies in this event by default. They increase privacy and retention risk without necessarily improving replay. A pseudonymous subject reference and a session identifier are usually a better starting point. The exact retention period belongs to the system's privacy and incident-response requirements, not to this logging helper.
Signal quality also means preserving negative evidence. An event with enabled: false\ is not an absence of data. It can explain why a player entered the standard matchmaking path, and it can show that a rollout exclusion worked as configured.
How should the event join the rest of the incident?
A flag event becomes useful when its identifiers agree with the rest of the system. Use the same session or correlation identifier in the request trace, match record, and relevant error event. If the identifiers are generated independently, the incident timeline becomes a manual spreadsheet exercise. That is not a good use of a solo maintainer's revenue-per-hour budget.
The event timestamp should be generated close to evaluation, in UTC, using an unambiguous format such as the ISO 8601 value produced by toISOString()\. Ordering still cannot be inferred from timestamps alone when events come from separate processes, so a trace or request identifier should carry the relationship.
A practical replay workflow looks like this:
- Start with the customer-visible symptom and locate the session or match identifier.
- Retrieve the flag evaluation event for that identifier.
- Compare the recorded flag state and variant with the branch that ran.
- Join nearby errors, deployment metadata, and configuration-change events.
- Check whether the event is missing, duplicated, or inconsistent before changing the flag.
That last check matters. A missing event can mean the code path was not instrumented, the sink dropped the record, or the request never reached evaluation. Those are different operational conclusions. A count of emitted and dropped events helps distinguish them.
What I would change at scale
The small implementation is enough for one service and a clear event sink. At larger volume, I would keep the application contract stable and move complexity behind the sink. Batching, asynchronous export, sampling of repeated evaluations, and backpressure belong there. The flag decision should not wait on a network round trip.
Sampling needs care. Randomly dropping flag events can remove the one session needed for an incident replay. A better rule is to retain all events for error-linked sessions when that linkage is available, while sampling repetitive healthy evaluations. That policy trades storage volume for reconstruction quality. It should be tested with a replay exercise, not judged from a dashboard's event count.
I would add contract tests for three cases: an enabled decision, a disabled decision, and an event-sink failure. The tests should assert that the returned boolean remains correct when emission fails, and that the event attributes match the decision supplied to the application. A schema test should reject accidental renaming of feature.flag_key\ or game.session_id\; those names are part of the incident workflow once people depend on them.
The next useful step is not another dashboard. It is a small fixture containing a session identifier, two flag decisions, and one player-visible error. Ask an engineer to reconstruct the path using only retained evidence. If the answer requires guessing which log line was authoritative, the event contract is still too noisy.
The decision rule is straightforward: emit enough structured evidence to identify the player-visible branch, preserve correlation identifiers, and let observability fail open for gameplay while reporting its own loss. That is the balance I would choose when shipping weekly and outsourcing undifferentiated telemetry plumbing.
Sources
- OpenTelemetry, “Logs signal concepts”: https://opentelemetry.io/docs/concepts/signals/logs/
- OpenTelemetry, “Context propagation”: https://opentelemetry.io/docs/concepts/context-propagation/
- IETF, RFC 3339, “Date and Time on the Internet: Timestamps”: https://www.rfc-editor.org/rfc/rfc3339