Handling Missing Context Attributes Gracefully

This how-to is part of Context Enrichment Strategies for Targeting. When an enrichment source is slow or unavailable, attributes drop out of the evaluation context silently. The server-side SDK then hands the rule engine a partial map, and the rule — designed for a complete context — silently falls through to the default variant. Users see unexpected behavior; no exception fires.

Missing attributes rarely announce themselves. A rule checking tenantTier == "enterprise" on a context that lacks tenantTier evaluates to the default variant without raising an error. At scale, this reads as a mysterious targeting regression rather than a missing key. The steps below give you a detection layer, a runtime sanitizer, and a schema contract that together make the failure visible and recoverable.

Prerequisites

Missing-attribute fallback flow Context arrives at a validator; present attributes pass through, absent attributes are filled from a defaults map, and the sanitized context reaches the rule engine. Raw context may have gaps Validate complete? Defaults map fill gaps + warn Rule engine complete context complete gaps found
Present attributes pass through directly; absent ones are filled from a defaults map and logged before reaching the rule engine.

Step 1 — Detect missing attributes before they reach the SDK

Instrument the context at the enrichment boundary. Log a warning — and increment a metric — for every attribute that is absent or null. This converts a silent failure into an observable signal.

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

const REQUIRED_KEYS: (keyof EvaluationContext)[] = [
  'targetingKey', 'tenantId', 'region', 'environment',
];

function detectMissingAttributes(ctx: EvaluationContext): string[] {
  return REQUIRED_KEYS.filter(
    (k) => ctx[k] === undefined || ctx[k] === null || ctx[k] === '',
  );
}

// In middleware, before the evaluation call:
const missing = detectMissingAttributes(context);
if (missing.length > 0) {
  logger.warn('Missing targeting attributes', {
    missing,
    correlationId: context.correlationId,
  });
  metrics.increment('flag.context.missing_keys', missing.length);
}

Cross-link the warning log with the correlationId so you can trace exactly which request produced the gap. In the context enrichment pipeline, detection runs immediately after the enrich step and before the sanitize step.

Step 2 — Distinguish absent, null, and empty-string

The rule engine treats these three differently. Absent means the key does not exist in the map; null means it was explicitly set to nothing; "" (empty string) satisfies an equality check like tenantTier == "". Getting these mixed up breaks deterministic targeting.

type TargetingContext struct {
    UserID string  `json:"user_id"`
    Tier   *string `json:"tier,omitempty"` // pointer: nil = absent, "" = explicitly empty
}

var ctx TargetingContext
if err := json.Unmarshal(data, &ctx); err != nil {
    return err
}

// nil pointer → key absent from map → rule evaluates to default variant
// empty-string pointer → key present as "" → equality rules fire on it
if ctx.Tier == nil {
    defaultTier := "free"
    ctx.Tier = &defaultTier // fill absent with safe default
}

Using pointer types for optional fields is the idiomatic Go approach. A plain string with omitempty collapses both absent and empty to the same "", which breaks == "pro" rules without any error.

Step 3 — Fill gaps with a sanitizer that applies safe defaults

A sanitizer runs after detection, merges defaults for any absent key, and passes the now-complete context to the SDK. The defaults must be explicitly documented — they determine the flag resolution behavior for every request that lost an attribute.

# Python — openfeature SDK
CONTEXT_DEFAULTS: dict = {
    "region":      "unknown",
    "tenantTier":  "free",
    "betaOptIn":   False,
    "environment": "prod",
}

def sanitize_context(ctx: dict) -> dict:
    """Fill absent or None keys with safe defaults. ctx values win; defaults only fill gaps."""
    filled = {**CONTEXT_DEFAULTS, **{k: v for k, v in ctx.items() if v is not None}}

    valid_tiers = {"free", "pro", "enterprise"}
    if filled.get("tenantTier") not in valid_tiers:
        filled["tenantTier"] = "free"  # reject invalid values too

    return filled

safe_ctx  = sanitize_context(raw_context)
result    = client.get_boolean_value("api.search.semantic-rerank", False, safe_ctx)

Document each default value next to the flag definition in your flag taxonomy. Anyone reading the flag’s targeting rules should be able to tell what the sanitizer delivers when a source is absent.

Step 4 — Enforce a schema contract at the API or gateway layer

Runtime sanitizers are a safety net, not a primary defense. A JSON Schema contract enforced at the API gateway or service mesh ingress rejects malformed payloads before they reach enrichment, eliminating an entire class of missing-attribute bugs.

# context-schema.yaml — enforced at the API gateway
context_schema:
  type: object
  required:
    - userId
    - environment
  properties:
    userId:
      type: string
      pattern: "^u_[a-zA-Z0-9]+$"
    environment:
      type: string
      enum: ["dev", "staging", "prod"]
    tenantTier:
      type: string
      enum: ["free", "pro", "enterprise"]
      default: "free"
    region:
      type: string
      default: "us-east-1"
  additionalProperties: false

Running this validation in CI — against synthetic contexts that exercise every absent-key path — proves that the sanitizer fills correctly and the schema rejects invalids before they ever reach production.

Verification

Run the sanitizer against a context that is missing every optional key and assert that it produces the expected defaults:

# flagd dry-run: minimal context — confirm defaults fire correct variant
curl -sf -X POST http://localhost:8013/schema.v1.Service/ResolveBoolean \
  -H 'Content-Type: application/json' \
  -d '{
    "flagKey": "api.search.semantic-rerank",
    "context": { "targetingKey": "anon-0001", "environment": "prod" }
  }' | jq -e '.reason == "DEFAULT" or .reason == "TARGETING_MATCH"'

Also assert in your test suite that detectMissingAttributes fires a metric when tenantTier is absent, and that sanitize_context does not overwrite a value that is present.

Gotchas & Edge Cases

Troubleshooting & FAQ

Why does the flag return the default variant even though I can see the attribute in the request body?

The attribute was present in the HTTP body but was stripped at the API gateway, message broker, or by schema additionalProperties: false. Log the full serialized context at the SDK evaluation boundary — not earlier — to confirm what actually reached the rule engine.

How do I distinguish an intentional default from a silent fallback caused by a missing attribute?

Inspect the evaluation reason field in the SDK response. DEFAULT with no error code means the SDK used its code-supplied default; DEFAULT with errorCode: GENERAL or a missing key in the context usually means the rule fell through. Emit the reason as a metric label so you can graph intentional-default versus fallback-default rates separately.

Do I need the sanitizer if I already have JSON Schema validation at the gateway?

Yes, as a defense-in-depth layer. Gateway validation catches malformed inbound payloads; the sanitizer handles attributes that disappear inside your service — for example, an enrichment source that times out after the gateway already passed the request through. They protect different points in the pipeline.