DEV Community

TrippDonovan5461
TrippDonovan5461

Posted on

Node.js Feature Flags in Express: API Telemetry for Percentage Rollout, User Targeting

The hard part of a feature flag in a nightly healthtech pipeline is not turning a boolean on. It is keeping the rollout signal clean enough to trust when the pipeline touches structured patient-adjacent data and a noisy batch can make a healthy change look broken.

Short answer: keep flag evaluation separate from pipeline work, make percentage rollout and user targeting explicit decisions, and emit the same structured outcome fields for both the control and candidate paths. A simple Express API is enough for the application boundary; it is not enough to replace a measurement plan.

The before-and-after model is small. Before, deployment and exposure happen together: every worker gets the new behavior at once. After, deployment puts both behaviors in place, while a flag selects the candidate path for a controlled slice. Logs and metrics then tell you whether that slice deserves more exposure.

That distinction matters.

What should a Node.js Express feature flags API example measure?

Start with the decision you need to make after the nightly run. For this example, the candidate path changes how a service searches structured logs emitted by a data pipeline. The useful question is not “did the flag return true?” It is “did eligible traffic get the new path without reducing signal quality or increasing noise?”

Define one outcome record for both branches. A practical record can contain a run identifier, pipeline stage, flag key, selected path, user cohort, outcome, and duration. Keep values bounded. A flag key such as log-search-v2 is useful; an unbounded query string or full medical payload is not. The record should describe the decision, never copy sensitive data into an observability stream just because it was nearby.

The request path reads like a diagram in words: authenticated identity -> targeting eligibility -> flag evaluation -> old or new search path -> comparable structured event. Authentication must happen before targeting. A query parameter called userId is not an identity boundary.

Here is the application boundary. The FlagClient interface is intentionally tiny: the control plane can own percentage allocation, while the Express process supplies an authenticated targeting context and records the result. No undocumented response shape is hidden in the example.

import express, { Request, Response } from "express";

const app = express();

type RequestWithIdentity = Request & {
  userId?: string;
};

type FlagContext = {
  userId: string;
  pipeline: "nightly-log-search";
};

type FlagDecision = {
  enabled: boolean;
  variant: "control" | "candidate";
};

interface FlagClient {
  evaluate(key: string, context: FlagContext): Promise<FlagDecision>;
}

declare const flagClient: FlagClient;

function requireIdentity(
  request: RequestWithIdentity,
  response: Response,
  next: () => void,
): void {
  const identity = request.header("x-authenticated-user");
  if (!identity) {
    response.status(401).json({ error: "Authentication required" });
    return;
  }

  request.userId = identity;
  next();
}

function emitDecision(event: Record<string, string>): void {
  process.stdout.write(`${JSON.stringify(event)}\n`);
}

async function searchControl(runId: string): Promise<number> {
  return runId.length;
}

async function searchCandidate(runId: string): Promise<number> {
  return runId.length;
}

app.get(
  "/nightly-log-search/:runId",
  requireIdentity,
  async (request: RequestWithIdentity, response: Response) => {
    const userId = request.userId;
    const runId = request.params.runId;

    if (!userId) {
      response.status(401).json({ error: "Authentication required" });
      return;
    }

    const startedAt = Date.now();
    const context: FlagContext = {
      userId,
      pipeline: "nightly-log-search",
    };

    try {
      const decision = await flagClient.evaluate("log-search-v2", context);
      const count = decision.enabled
        ? await searchCandidate(runId)
        : await searchControl(runId);

      emitDecision({
        event: "nightly_log_search",
        run_id: runId,
        flag_key: "log-search-v2",
        variant: decision.variant,
        outcome: "success",
        duration_ms: String(Date.now() - startedAt),
        result_count: String(count),
      });

      response.json({ variant: decision.variant, resultCount: count });
    } catch (error) {
      emitDecision({
        event: "nightly_log_search",
        run_id: runId,
        flag_key: "log-search-v2",
        variant: "control",
        outcome: "flag_evaluation_failed",
        error_code: error instanceof Error ? error.name : "unknown_error",
      });
      response.status(503).json({ error: "Flag evaluation unavailable" });
    }
  },
);

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The two search functions stand in for real pipeline code. The important part is the shape of the decision event. In production, use the service's authenticated identity rather than the demonstration header, redact or hash identifiers according to the data policy, and make the fallback policy explicit. Here the fallback reports an evaluation failure instead of quietly pretending that false was returned.

Three words: measure both paths.

How do percentage rollout and user targeting change the signal?

They answer different questions. Percentage rollout controls how much eligible traffic can see the candidate. User targeting defines who is eligible. A team can target an internal cohort and then expose 10 percent of that cohort; it should not treat “targeted” as a synonym for “10 percent.”

For a nightly data pipeline, the target might be an authenticated service identity, a tenant, or a worker cohort. Choose a stable key. If the same entity moves between control and candidate on every evaluation, the comparison becomes noise. The allocation system should provide stable assignment when that is part of its contract. The application should not quietly replace that contract with a new hash function in a route handler.

The first release should have a hold condition before it has a larger percentage. Compare the candidate and control over the same run window and at a similar workload. Useful measures include search success rate, records returned, duration, malformed-log rate, and the proportion of events classified as unknown. Raw counts alone can mislead when the candidate cohort is smaller.

Decision signal What to compare Stop or continue question
Completeness Records found and required event fields Did the candidate lose evidence?
Noise Unknown or duplicate classifications Is the new signal harder to act on?
Performance Duration for equivalent runs Did the search add unacceptable delay?
Reliability Error and timeout rate by variant Can the next exposure step be defended?

For example, suppose the control path finds 2,400 structured events during a nightly run and the candidate path finds 2,170. That difference is a stop signal only after checking the denominator, the same stage boundaries, and the event schema. The candidate may have removed duplicates, or it may have dropped records. A useful run record lets the engineer follow the chain from the flag decision to the stage, query shape, result count, and completion status without reading the payload itself. The team can then sample a small set of bounded event identifiers under its data policy, compare the two paths, and decide whether the lower count is improved precision or lost recall. If the candidate also produces a higher unknown classification rate, the evidence gets stronger: the new behavior is changing both volume and meaning. If it produces fewer events with the same classifications and duration, the interpretation is different. This is why a percentage alone is a poor release metric. It describes exposure, not quality. Write the hold rule in advance, record the decision in the run review, and raise exposure only when the comparison remains interpretable.

This is where observability earns its place. Flag state says which branch ran. A structured event says what that branch produced. A dashboard can compare them. An alert can tell an on-call engineer that the candidate's error rate crossed a threshold. None of those should be inferred from a single boolean.

For browser-facing parts of the same system, Core Web Vitals add a separate user-facing view: LCP, CLS, and INP are designed to describe loading, visual stability, and interaction responsiveness, with the 75th percentile used in the web.dev guidance. A batch log-search rollout may never need those measures, so do not add them as decorative telemetry. Match the signal to the decision.

Polling also affects interpretation. A service that refreshes flag state periodically can observe a stale assignment for a bounded interval. Set that interval from the rollback objective and the cost of stale configuration. I'm not sure which interval fits a particular pipeline without its run duration, worker count, and rollback tolerance. Those inputs belong in the rollout design, not in a generic code sample.

A rollout is an experiment with failure modes

The most common failure is a misleading comparison. The candidate emits richer logs, so it appears noisier even though both branches have the same operational result. Or the candidate omits an event, so the dashboard looks calmer while the pipeline has lost evidence. Normalize event names and required fields before changing the percentage.

Another failure is evaluation on the wrong side of the trust boundary. A client-supplied user identifier can let a caller choose a favorable cohort. Authenticate first, derive the targeting context from that identity, and log a cohort label rather than the raw identifier when raw identity is unnecessary.

A third failure is configuration staleness. A worker may keep its previous decision longer than the operator expects. Document the refresh behavior, the permitted stale window, and the conservative behavior when evaluation cannot complete. A rollback control that does not reach workers within the stated window is not a rollback plan.

Nightly jobs add a quiet failure mode: there may be no request traffic to create a fresh signal. Emit a start event, a completion event, and a heartbeat for the run. Check that the count of completed runs is itself observable. A dashboard full of yesterday's green data is not evidence that tonight's job executed.

Finally, keep sensitive structured logs out of the flag context unless the data policy explicitly permits them. Use a tenant or cohort identifier that the evaluation system is allowed to process. The feature flag chooses behavior; it should not become an accidental second data store.

When is a simple Express flag API the wrong boundary?

The simple boundary is a good fit when the service needs a small number of server-side decisions, stable targeting, a known refresh policy, and an engineering team that owns the surrounding logs, metrics, and alert delivery. It keeps application code understandable because the branch decision and its evidence are visible in one request path.

The catch is governance. A local interface like the one above does not, by itself, provide change history, approval workflow, evaluation analytics, dependency management, deletion recovery, or a guaranteed realtime update channel. It is not suitable when those controls are acceptance criteria for release management. Choose a dedicated feature-management system or build those controls deliberately, then verify them against a testable checklist.

It is also a poor fit when a flag has become permanent business policy. Long-lived flags accumulate branches and make telemetry harder to compare. Give each temporary release flag an owner, an expiry review, and a removal issue. If the team cannot name the removal condition, the flag is already carrying too much responsibility.

The decision rule is straightforward: select the smallest control plane that meets the governance and propagation requirements, then pair it with observability that measures the actual risk. Do not choose a flag API because its dashboard is attractive. Choose it because the team can explain who is eligible, which percentage is exposed, what signal will stop the rollout, and how a rollback reaches every worker.

References

Top comments (0)