Context Enrichment Strategies for Targeting

This guide is part of the Backend Evaluation & Server-Side SDKs series. A server-side SDK evaluates flags against an evaluation context — a key-value map describing the current request. A thin or incorrectly assembled context silently breaks targeting rules; an over-populated context leaks PII to vendor logs and audit records. This guide covers how to build, enrich, sanitize, and pass that context correctly every time.

The failure mode that makes this worth getting right is that both errors are silent. A missing attribute does not raise an exception — the rule engine simply does not match, and the flag resolves to its default variant with reason DEFAULT or ERROR depending on the provider. A leaked attribute does not raise anything either — it flows straight into the provider’s evaluation logs, its analytics exports, and any audit trail the vendor keeps, where it sits until a compliance review or a breach surfaces it. Neither shows up in your test suite unless you write the assertion for it explicitly. That asymmetry — cheap to get wrong, invisible when wrong — is why the enrichment pipeline deserves the same care you give request validation or auth middleware, and why the rest of this guide treats sanitization as a hard boundary rather than a nice-to-have.

Problem Framing: What Context Enrichment Is and Is Not

A bare context might contain only a targetingKey. That is enough to route traffic by user ID but useless for rules that check tenantTier, region, or betaOptIn. Enrichment is the act of populating those additional attributes before the evaluation call. The pipeline has three stages: assemble (collect attributes from the request and your data layer), sanitize (normalize types, enforce schema, strip or hash PII), and evaluate (pass the clean context to OpenFeature).

This guide covers the enrichment pipeline and the OpenFeature evaluation context schema. It does not cover distributed caching for flag rule sets, or how to tune the rule engine that processes the context once it arrives.

It is worth being precise about the boundary, because “enrichment” gets used loosely. Enrichment is not caching — caching keeps the flag rules warm so the engine does not fetch them per evaluation, whereas enrichment assembles the context the warm rules run against. The two are orthogonal: you can cache rules perfectly and still resolve the wrong variant because the context was thin, and you can build a flawless context that evaluates slowly because the rule set was cold. Enrichment is also not targeting-rule authoring — you are producing the inputs the rules consume, not the rules themselves. Keeping those responsibilities separate is what lets you reason about a wrong variant: if the rule is correct and the cache is warm, the bug is almost always in the context, and this pipeline is where you look first.

Context-assembly pipeline A request passes through four stages — raw request, enrichment, sanitization, evaluation — before a flag variant is returned. Request userId · headers Enrich tier · region · flags Sanitize hash PII · validate Evaluate OpenFeature SDK → variant
Each request passes through enrichment (add attributes) and sanitization (normalize + hash PII) before the OpenFeature SDK runs the evaluation.

Prerequisites

Dependencies the enrichment pipeline builds on Five prerequisites stacked as a foundation under the enrichment pipeline: server SDK, a reachable provider, a documented taxonomy, an identity source, and a PII classification list. Enrichment pipeline Server SDK + provider Flag taxonomy attribute names Identity source JWT / session Entitlement svc tier / access PII list sensitive keys
The pipeline rests on five prerequisites — miss any one and enrichment either fails closed or leaks. Confirm all five before wiring the first call.

Core Concept & Architecture

OpenFeature defines the evaluation context as a flat or nested map with one reserved key: targetingKey. Every other attribute is application-defined. The shape your rules expect must match what the enrichment pipeline delivers — if a rule checks tenantTier == "enterprise" but the context key is subscriptionTier, the rule evaluates to the default variant silently.

The canonical attribute taxonomy for server-side targeting:

Category Example keys Source
Identity targetingKey, userId, tenantId JWT / session
Entitlement tenantTier, betaOptIn, featureAccess Entitlement service
Request region, environment, correlationId Request headers / middleware
Derived isInternalUser, accountAgedays Computed at enrichment time

Correlation IDs deserve special treatment: carry a request-scoped correlationId through the context so every flag evaluation in a trace can be joined back to the originating request. This is the cheapest way to answer “which variant did this request see?” during an incident.

A subtlety worth internalizing early: the evaluation context is not the same thing as your domain model, and you should resist the temptation to pour the whole user object into it. Every attribute you add is an attribute a targeting rule could reference, which means it is also an attribute someone will eventually reference by accident, and one more field the provider records against each evaluation. Treat the context as a deliberately narrow projection — the specific attributes your live targeting rules actually read, plus the correlation ID and environment markers you need for observability, and nothing else. A context of eight well-chosen keys is easier to reason about, cheaper to serialize on every call, and far safer than one that mirrors a forty-field user record.

The distinction between derived and source attributes matters more than it looks. Source attributes — tenantId, region — come straight from an upstream system and change only when that system changes them. Derived attributes — isInternalUser, accountAgeDays — are computed at enrichment time from other inputs, which means their correctness depends on the computation being deterministic and stable across your fleet. If two service instances compute accountAgeDays from clocks that disagree, or one rounds down and the other rounds up, the same user can straddle a rule boundary and flap between variants request to request. Pin the computation to a single well-defined rule (integer days from a stored createdAt, floored, in UTC) and unit-test the boundary explicitly, because a flapping derived attribute produces exactly the kind of “works on my box, not in prod” bug that is miserable to trace.

Evaluation context attribute taxonomy by source Four attribute categories arranged as quadrants — identity, entitlement, request, and derived — each labelled with example keys and the source that supplies them. Identity targetingKey, userId, tenantId source: JWT / session Entitlement tenantTier, betaOptIn, featureAccess source: entitlement service Request region, environment, correlationId source: headers / middleware Derived isInternalUser, accountAgeDays source: computed at enrichment
Every targeting attribute belongs to one of four categories, each with a distinct source — knowing the source tells you where a missing attribute went wrong.

Step-by-Step Implementation

Step 1 — Assemble the base context from the request

Extract the minimum required attributes from the authenticated request before touching downstream services. This keeps the fast path cheap and delays enrichment I/O until it is confirmed necessary.

import { EvaluationContext } from '@openfeature/server-sdk';

function buildBaseContext(req: AuthenticatedRequest): EvaluationContext {
  return {
    targetingKey: req.userId,          // required by OpenFeature
    tenantId:    req.tenantId,
    correlationId: req.headers['x-correlation-id'] ?? crypto.randomUUID(),
    environment: process.env.APP_ENV,  // e.g. "prod"
    region:      req.headers['x-forwarded-region'] ?? 'us-east-1',
  };
}

Pitfall: do not fall back to a random UUID as the targetingKey — bucketing becomes non-deterministic and percentage-based rollouts with sticky bucketing break. Use a stable identifier; fall back to an anonymous session ID if the user is unauthenticated, not a fresh random. The reason is mechanical: providers hash the targetingKey into a 0–100 bucket to decide which side of a percentage rollout a subject lands on, so a key that changes every request lands in a new bucket every request. A user in a 10 % rollout would see the feature roughly one call in ten and lose it the next — worse than never shipping it, because the flicker looks like a bug in the feature itself rather than a bug in your context. An anonymous session ID that survives for the length of the session gives you consistent-per-session behavior, which is the best you can do before the user authenticates.

Keep this base builder synchronous and side-effect-free. It reads only from the request object and environment, so it cannot fail in a way that blocks the request, and it establishes the correlationId before any downstream call — which matters because if an enrichment source times out three lines later, you still want that failure logged against a correlation ID you can trace. Assembling the correlation ID first, not last, is a small ordering choice that pays for itself the first time you debug a partial context.

Step 2 — Enrich with downstream attributes

Call enrichment sources in parallel so their latencies overlap rather than stack. Cap each call with a per-source timeout and fall back to safe defaults on failure — a partial context is better than a blocked request.

import { OpenFeature, EvaluationContext } from '@openfeature/server-sdk';

async function enrichContext(
  base: EvaluationContext,
  entitlementSvc: EntitlementService,
): Promise<EvaluationContext> {
  const [entitlement] = await Promise.allSettled([
    entitlementSvc.get(base.targetingKey as string, { timeout: 30 }),
  ]);

  return {
    ...base,
    tenantTier:    entitlement.status === 'fulfilled' ? entitlement.value.tier  : 'free',
    betaOptIn:     entitlement.status === 'fulfilled' ? entitlement.value.beta  : false,
    isInternalUser: (base.targetingKey as string).endsWith('@internal.example.com'),
  };
}

Pitfall: Promise.all throws on the first rejection and drops the other results. Use Promise.allSettled so a single failed enrichment source does not discard attributes from sources that succeeded.

There is a second, quieter pitfall in the same code: the per-source timeout has to be enforced by you, not assumed from the client. Many HTTP and gRPC clients default to timeouts measured in seconds, or to no timeout at all, which means a single slow entitlement service can pin the request for far longer than the 30 ms the code implies. Pass the timeout explicitly to each call, and prefer an AbortController (or the client’s native deadline) over a Promise.race wrapper — a raced-out promise abandons the request but the underlying socket keeps waiting, so under load you leak connections until the pool is exhausted and everything stalls. The timeout is not just a latency guard; it is what keeps a degraded dependency from becoming an outage.

Decide deliberately what a failed enrichment means for each attribute. Defaulting tenantTier to 'free' on failure is fail-closed for a feature you gate to paying tenants — the worst case is a paying user briefly not seeing a premium feature, which is annoying but safe. Defaulting betaOptIn to false is likewise conservative. But be careful with attributes that grant access when absent: if a rule reads isBlocked and you default it to false on lookup failure, a moderation outage silently un-blocks everyone. Write each default down next to the attribute and ask “if this source is down for an hour, what does this default do?” — the answer should always be the safe direction, and for a few attributes that will mean failing the request rather than defaulting at all.

Step 3 — Sanitize: normalize types and apply PII boundaries

Before the context reaches the OpenFeature SDK, pass it through a sanitizer that enforces the schema, converts types, and hashes or removes sensitive identifiers. This is the redaction boundary between raw user data and the evaluation engine.

import { createHash } from 'crypto';

function sanitizeContext(ctx: EvaluationContext): EvaluationContext {
  const result: EvaluationContext = { ...ctx };

  // Hash the targetingKey so the raw user ID never reaches vendor telemetry
  result.targetingKey = createHash('sha256')
    .update(String(ctx.targetingKey))
    .digest('hex')
    .slice(0, 16);

  // Strip any raw email or phone that may have leaked in from upstream
  delete (result as Record<string, unknown>)['email'];
  delete (result as Record<string, unknown>)['phone'];

  // Normalize tier to the expected enum
  const validTiers = new Set(['free', 'pro', 'enterprise']);
  if (!validTiers.has(result.tenantTier as string)) {
    result.tenantTier = 'free';
  }

  return result;
}

Pitfall: the sanitizer runs before every evaluation call. Keep it synchronous and allocation-light — cloning the object with { ...ctx } is fine; deep-copying a nested graph on each call is not.

Two properties make a sanitizer trustworthy. First, it must be allowlist-oriented rather than denylist-oriented for the truly sensitive fields. The example above deletes email and phone, which works only as long as you remember every sensitive key an upstream service might inject — and upstream teams add fields without telling you. For anything you classify as PII, prefer building the outgoing context from a known set of permitted keys and copying those across, so a new ssn or dateOfBirth field that appears upstream is dropped by default instead of forwarded by default. A denylist fails open; an allowlist fails closed, and for PII you always want it to fail closed. Second, the sanitizer must be idempotent — running it twice on the same context must produce the same result — because in practice contexts get re-sanitized when code paths compose, and hashing an already-hashed targetingKey a second time would change the bucketing and quietly break sticky rollouts. Guard the hash with a marker or a length check so a second pass is a no-op.

Hashing the targetingKey is a genuine trade-off, not a free win, and you should make it consciously. Once the raw ID is hashed, the vendor’s dashboards can no longer show you “user 4821 saw variant B” — they show a 16-character hex string, and you need your own join table to map it back. That is exactly what you want for PII minimization, but it means your internal debugging must lean on the correlationId and your own logs rather than the vendor UI. Truncating the SHA-256 digest to 16 hex characters (64 bits) keeps collisions astronomically unlikely at normal user counts while shortening what travels over the wire; do not truncate to 8 characters (32 bits), where the birthday bound makes collisions plausible once you pass a few tens of thousands of distinct subjects and two users start sharing a bucket. If you need the mapping to be reversible for support workflows, tokenize through a lookup table you control instead of hashing — the deeper trade-offs live in masking PII in the evaluation context.

Step 4 — Pass the context to OpenFeature and evaluate

With a clean, enriched context, the evaluation call is a single method. Keep the call-site thin; all context logic belongs in the pipeline, not scattered across call sites.

Note the layering OpenFeature gives you here, because it changes where you put each attribute. The SDK merges context from several levels — a global context set on the API, a client-level context, an optional transaction/request-scoped context, and the invocation context you pass to the call itself — with the most specific level winning on key collisions. Genuinely static attributes like environment or the service name belong on the global or client context, set once at startup, so you are not re-attaching them on every request. Per-request attributes like targetingKey, tenantTier, and correlationId belong on the invocation context built by this pipeline. Getting that split right means the pipeline only assembles what actually varies per request, and it removes a whole class of bug where a stale global value shadows the fresh per-request one. Just remember the precedence direction: if the same key appears at two levels, the more specific level overrides, so an accidental region on the global context is harmless only as long as every request also sets its own.

The safe default in the call — false here — is load-bearing and should be chosen per flag, not copied blindly. It is the value the SDK returns when the provider is unreachable, the flag key is unknown, or evaluation errors out, and it is the behavior your users get during exactly the moments you are least able to intervene. Pick the default that keeps the system safe when everything else is broken: false for a feature that adds risk, but sometimes true for a kill-switch-style flag where the “on” state is the stable, already-shipped path and “off” is the emergency behavior. Write the intended default into the flag’s documentation so the value at the call site and the value in the flag definition never drift apart.

const client = OpenFeature.getClient('checkout');

async function resolveFlag(req: AuthenticatedRequest): Promise<boolean> {
  const base    = buildBaseContext(req);
  const rich    = await enrichContext(base, entitlementSvc);
  const context = sanitizeContext(rich);

  return client.getBooleanValue(
    'checkout.payments.express-pay',
    false,           // safe default
    context,
  );
}

Step 5 — Emit telemetry without leaking context

Attach the correlationId and the resolved variant to your trace span. Never emit the full context object to a log line or a telemetry backend — the sanitized targetingKey hash is safe; the raw userId is not.

The tempting shortcut here — span.setAttributes({ ...context }) to “capture everything for debugging” — is precisely the leak the pipeline exists to prevent. Spreading the whole context onto a span copies every attribute, including any raw field that slipped past an incomplete sanitizer, into your tracing backend, where it is retained for weeks and readable by anyone with dashboard access. Enumerate the specific keys you want on the span, the way the snippet above does, and never spread. If you genuinely need the full context to reproduce a decision, log it behind an explicit debug flag that is off in production, or capture it in an OpenFeature evaluation hook where you can apply the same redaction rules the sanitizer uses — the server-side SDK integration patterns guide shows where hooks sit in the lifecycle. Treat your telemetry pipeline as another untrusted sink, exactly like the vendor’s logs, because from a PII-exposure standpoint it is one.

Gotchas & Edge Cases

span.setAttributes({
  'flag.key':         'checkout.payments.express-pay',
  'flag.variant':     variant,
  'flag.correlation': context.correlationId as string,
  // Do NOT log context.targetingKey or any unsanitized attribute
});
Parallel enrichment on the request critical path The base context is assembled, enrichment sources run in parallel with per-source timeouts, results merge into a sanitized context, and the SDK evaluates — overlapping I/O rather than stacking it. Assemble base from request entitlement · 30ms segments · 25ms geo · 15ms Sanitize allSettled merge Evaluate total ≈ 30ms
Because the three enrichment sources run concurrently under their own timeouts, total latency tracks the slowest source (~30 ms), not their sum.

Verification & Testing

Validate the pipeline end-to-end with a synthetic context that exercises each enrichment source in isolation.

# Dry-run against flagd: confirm the enriched context matches targeting rules
curl -sf -X POST http://localhost:8013/schema.v1.Service/ResolveBoolean \
  -H 'Content-Type: application/json' \
  -d '{
    "flagKey": "checkout.payments.express-pay",
    "context": {
      "targetingKey": "a1b2c3d4e5f60001",
      "tenantTier":   "enterprise",
      "region":       "us-east-1",
      "environment":  "prod"
    }
  }' | jq -e '.reason == "TARGETING_MATCH"'

For unit testing, seed the context builder with known inputs and assert that the sanitized output has no raw PII keys and that defaults fill correctly when an enrichment source returns an error.

Write the negative assertions as first-class tests, not afterthoughts, because they are the ones that catch regressions no one is looking for. The single most valuable test in this suite asserts what is absent: given a base context that carries a raw email, the sanitized output must not contain an email key, nor a phone, nor an un-hashed targetingKey. If you adopt the allowlist approach, invert it — assert the output keys are a subset of the permitted set, so any newly forwarded field fails the test the moment someone adds it upstream. Pair that with a fault-injection test that forces each enrichment source to reject or time out and asserts the resulting context still carries safe defaults and still evaluates; a source that throws should degrade the variant, never the request. Round it out with a determinism test that runs the same input through the pipeline twice and asserts byte-identical output — that is your guard against a non-idempotent sanitizer or a wobbling derived attribute.

Beyond unit tests, keep a small set of golden contexts checked into the repository — a canonical JSON fixture per representative user shape (free anonymous, pro authenticated, enterprise internal) — and replay them against a local flagd instance in CI using the curl dry-run above. Golden fixtures catch the class of bug where a rule change or a taxonomy rename shifts a whole segment to the wrong variant: the fixture’s expected reason stops matching and the build goes red before the change reaches production. They are also the fastest way to onboard a new targeting rule, because you write the fixture that should match first and watch it fail until the rule and the context agree.

Context test matrix: input to expected evaluation reason A table mapping synthetic contexts to the expected evaluation reason: enterprise tier matches, free tier defaults, missing key defaults, and a raw PII key fails the sanitizer assertion. synthetic context expected reason assert tenantTier=enterprise TARGETING_MATCH tenantTier=free DEFAULT tenantTier missing DEFAULT (safe) raw email present sanitizer strips key
A minimal matrix: each synthetic context asserts both the resolved reason and that the sanitizer left no raw PII behind.

Troubleshooting & FAQ

Why does a rule target tenantTier but always fall back to the default variant?

The most common cause is a key mismatch: the rule checks tenantTier but the context carries subscriptionTier or tier. Enable debug logging on the SDK and inspect the raw context object that reaches the provider. Compare the exact key names against what the rule definition expects.

How do I avoid blocking the request path with enrichment I/O?

Make enrichment calls parallel (Promise.allSettled) and set short per-source timeouts (30–50 ms). Fall back to defaults on timeout so the evaluation still runs. If attributes for a specific flag only matter when a rule needs them, consider lazy enrichment — resolve those attributes only when the flag’s rule tree actually references them.

Can I reuse the same context object across multiple flag evaluations in one request?

Yes — build and sanitize once per request, then pass the same context to every flag call. Avoid re-enriching on each getBooleanValue call; that turns a per-request overhead into a per-flag overhead.

What should I do if the correlationId is missing from upstream requests?

Generate one at the entry point of your service and inject it into the context. Propagate it downstream via the x-correlation-id header. Never leave this absent — without it, you cannot join flag decisions to traces during incident investigation.

Should the evaluation context be flat or nested?

Prefer flat unless a rule genuinely needs structure. Flat contexts (tenantTier, region) are the easiest to reason about, log, and diff, and every provider supports equality and set-membership rules against top-level keys. Nested objects are legitimate when you have a natural grouping a rule will traverse — a device sub-object with os and version, for example — but confirm your provider’s rule language can reach the nested path before you rely on it. A common failure is authoring a rule against device.os while the SDK or provider only indexes top-level keys, which resolves to the default with no error. When in doubt, flatten with a prefix (deviceOs) and keep the rules simple.

How do I keep the context schema in sync with what the targeting rules expect?

Treat the attribute taxonomy as a shared contract and version it alongside the flags. Document each attribute’s exact key name, type, and allowed values in the same place your flag taxonomy lives, and validate the sanitized context against that schema in tests. When someone adds a rule that reads a new attribute, the schema change and the enrichment change land in the same review, so the pipeline never ships a rule it cannot feed. The alternative — discovering the mismatch in production when a segment silently defaults — is far more expensive than a schema check.

Does adding more context attributes slow down evaluation?

Marginally, and rarely in the way people fear. The cost of a larger context is dominated by serializing and transmitting it to the provider on each call, not by the rule engine reading extra keys — an in-process provider like flagd in embedded mode barely notices a few extra fields. The real cost is the enrichment I/O required to populate those attributes, which is why the guidance is to add only the attributes live rules actually consume. If an attribute is present in the JWT you already parse, adding it to the context is nearly free; if it requires a downstream call, that call is the expense, not the attribute.

Can I mutate the evaluation context inside an OpenFeature hook?

Only in the before stage, and only with care. OpenFeature hooks expose a before phase whose return value is merged into the context for that evaluation, which is a legitimate place to inject a last-mile attribute — a freshly minted correlation ID, say. Do not use hooks to perform slow enrichment I/O, because a before hook runs inside the evaluation call and any latency there lands squarely on the request path with no timeout protection of its own. Keep hook-based context changes cheap and synchronous, and leave the network-bound enrichment in the pipeline where you can parallelize and bound it.

Performance & Scale Considerations

Context enrichment adds to the request critical path only if you serialize I/O. Parallel fetches with timeouts keep the overhead bounded. Measure enrichment latency as a distinct span in your traces and budget it explicitly — for most services, 10–30 ms is acceptable; above 50 ms, evaluate whether the attributes can be pre-populated by the server-side SDK integration at session-init time rather than per-request.

If a flag’s rules only need attributes already present in the JWT or session token, the enrichment call for that flag is zero cost. Segment flags by which enrichment sources they require and skip calls that a given evaluation does not need.

The highest-leverage optimization is usually to move enrichment off the per-request path entirely. If the attributes a request needs are stable for the life of a session — tenant tier, region, internal-user status rarely change between two clicks — assemble and sanitize the context once at session establishment and cache it against the session, then reuse it for every request in that session. This turns N enrichment round-trips into one and is the single biggest win available for high-traffic endpoints. The trade-off is staleness: a session-cached tier will lag a mid-session upgrade until the session refreshes, so scope this cache to attributes whose change latency you can tolerate, and bypass it for the rare attribute that must reflect a write from moments ago.

Finally, instrument the enrichment stage as its own span with its own histogram, separate from the flag evaluation itself. The two failure modes — slow enrichment and slow evaluation — have completely different fixes, and a single blended “flag latency” metric hides which one is hurting you. When enrichment p99 climbs, the span attribution tells you which source regressed, so you can tighten that source’s timeout or move its attribute to session-init without guessing. Budget the stage explicitly, alert on the budget, and treat a breach as a signal to reshape the pipeline rather than to widen the timeout.

Enrichment latency budget on the request path A horizontal budget bar: token-only attributes cost zero, parallel enrichment fits a ten-to-thirty millisecond band, and serial enrichment overshoots the fifty millisecond ceiling. 0ms 30ms 50ms budget token-only attributes · ~0ms parallel enrichment · 10–30ms serial enrichment · overshoots budget
Parallel enrichment keeps the added latency inside a 50 ms budget; serialized I/O blows through it. Attributes already in the token cost nothing.