Skip to main content
Adventure Blind by Design

Outcome by Cohort

Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register an AuditHook. Transaction context comes from a HandlerInterceptor, global context from the COUNTRY environment variable at startup, and invocation context at the call site. The targeting in flags.json already has three branches for species, dose, and country, but none fire yet because the context layers are missing.

Mission Objective

  • curl /?species=zyklop returns 'enhanced' regardless of dose or country
  • With COUNTRY=de, curl /?dose=standard returns 'sharp'; with COUNTRY=at, the same call falls through to the default
  • curl /?dose=underdose returns 'clouded'; curl /?species=zyklop&dose=underdose returns 'enhanced' (species takes precedence)
  • Every evaluation produces an [AUDIT] log line naming the flag, the resolved variant, the reason, and the attributes that drove the outcome
  • The response is never 'untreated' (that fallback only fires when the SDK cannot reach flagd)

Key Learnings

  • How OpenFeature's transaction-context propagation works in a thread-per-request server, and why a ThreadLocalTransactionContextPropagator is the right primitive for Servlet-based apps
  • The difference between request-scoped context (the subject's species) and global evaluation context (the trial's country), and when each is the right tool
  • How hooks let you attach cross-cutting behaviour, audit logging today and OpenTelemetry tracing tomorrow, without modifying every flag evaluation call site
The Story

The trial is widening. Subjects from outside the lab's local population are getting the wrong reading on their chart, and the lab director has just walked in holding a stack of complaint forms. She wants the audit log to tell her, after the fact, exactly which vision_state the lab recorded for which subject, and she wants the lab to read the chart properly before it records any more bad readings.

The protocol is the same for every subject; the lab is not varying the trial. What differs is the observed outcome: some subjects have a biology that responds enhancedly to the same serum, some absorb less or more than the protocol's standard dose, and the trial is registered in different jurisdictions with different baselines.

Your shift: teach the lab to read each subject's species off the request, attach the trial's country of registration (set on the JVM via the COUNTRY environment variable) to the global context, pass the dose as invocation context at the moment of the flag evaluation, and register an audit hook that records every dose with its variant and reason.

Architecture
HTTP flows through SpeciesInterceptor, Trial, and OpenFeature client left to right, then down through AuditHook and FlagdProvider, connecting via gRPC to a flagd sidecar.

Ready to start?

Launch in a preconfigured devcontainer

Open in Codespaces (opens in new tab)

Free GitHub account required

Walkthrough
  1. Open in GitHub Codespaces (opens in new tab). The devcontainer is pre-configured and starts automatically. When you push from Codespaces, GitHub forks the repository to your account automatically.

    Prefer working locally? Clone the repo and open it in any editor that supports the Dev Containers specification (VS Code, JetBrains IDEs, and others). The devcontainer config will be detected automatically.

  2. Wait ~2-3 minutes for the Java toolchain to install. Use Cmd/Ctrl + Shift + P then View Creation Log to watch progress. When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in adventures/04-blind-by-design/intermediate/.

  3. Open the Ports tab and navigate to each service:

    • Port 8080: Spring Boot lab. The application under test. Access via the Ports tab or curl http://localhost:8080/.
  4. Start the lab and confirm the broken state, where no targeting fires yet:

    ./mvnw spring-boot:run
    curl 'http://localhost:8080/?species=zyklop'
    # returns 'blurry', wrong cohort, targeting can't fire
    

    That "blurry" is the starting point you want: even when the request shouts species=zyklop, the lab has nothing in its evaluation context, so flagd's targeting cannot fire and every subject drops to the default variant. Stop the app and start fixing.

  5. The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the FlagdProvider is wired in Resolver.RPC mode against the flagd sidecar. Open flags.json and inspect the targeting. Three branches exist but none fire because nothing in the app populates species, country, or dose yet:

    "targeting": {
      "if": [
        { "===": [{"var": "species"}, "zyklop"] },        "enhanced",
        { "in":  [{"var": "dose"}, ["underdose", "overdose"]] }, "clouded",
        { "===": [{"var": "country"}, "de"] },             "sharp"
      ]
    }
    

    Your job: populate species, country, and dose on the evaluation context so the targeting fires.

  6. Create a Spring HandlerInterceptor named SpeciesInterceptor: in preHandle, read ?species= and put it on the transaction context; in afterCompletion, clear the context so values do not leak across pooled threads. Register a ThreadLocalTransactionContextPropagator once on the OpenFeature API in a static initializer. Without the propagator the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty.

  7. Update OpenFeatureConfig to: register SpeciesInterceptor with Spring (WebMvcConfigurer.addInterceptors), read the COUNTRY environment variable and set it as the global evaluation context, and register AuditHook globally on the OpenFeature API. The three context layers, global (country), transaction (species from the interceptor), and invocation (dose from Trial), merge before flagd evaluates the rules. Precedence on conflict is invocation over transaction over global.

  8. Update Trial so each evaluation passes dose on the invocation context (the third argument to client.getStringDetails). Default to 'standard' most of the time but occasionally to 'underdose' or 'overdose', that is the lab tech mis-measuring, and it is what makes the improper-dosing branch in flags.json fire at all. Make it overridable via ?dose= so you can test each branch by hand. If the invocation context does not carry dose, the targeting rule sees null and the branch never fires: every non-zyklop request lands on either the country branch or the default.

  9. Implement AuditHook: in after(), write an [AUDIT] log line with flag name, variant, reason, and a fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is 'clouded' so the safety officer can grep for it, otherwise INFO. Implement error() so failed evaluations are not silent. Use a fixed allowlist (List.of("species", "country", "dose")) rather than iterating the whole context: audit logs outlive app logs, and logging only what you decided to log pays off the moment something sensitive lands on the context.

  10. Run the lab with country-specific scripts. These pipe output through tee app.log, which the verifier greps for [AUDIT] lines. If you run ./mvnw spring-boot:run directly, add | tee app.log or the verifier has nothing to grep:

    ./run-germany.sh    # COUNTRY=de  (or: make lab-germany)
    ./run-austria.sh    # COUNTRY=at
    

    Three named launch configs in .vscode/launch.json (Germany / Austria / No country) also let you switch cohorts from the Run and Debug view.

    In another terminal, verify each branch:

    curl -s 'http://localhost:8080/?species=zyklop' | jq .value
    # => "enhanced"
    
    curl -s 'http://localhost:8080/?dose=standard' | jq .value
    # => "sharp" (Germany) / "blurry" (Austria)
    
    curl -s 'http://localhost:8080/?dose=underdose' | jq .value
    # => "clouded"
    
    curl -s 'http://localhost:8080/?species=zyklop&dose=underdose' | jq .value
    # => "enhanced" (species wins)
    

    Check the audit trail:

    grep '\[AUDIT\]' app.log | head
    

    You should see one [AUDIT] flag=vision_state variant=... reason=... species=... country=... dose=... line per request. clouded outcomes log at WARN.

Complete Your Challenge

  • When you push from Codespaces, GitHub forks the repository to your account automatically. If you are working locally, fork the repository on GitHub before pushing.
  • Verify your solution:
    ./verify.sh
    If it passes, it generates a Certificate of Completion you can paste into the discussion.
  • Share your solutions in the challenge thread (opens in new tab) on community.offon.dev.

Toolbox

  • Java 21 (Temurin) - pre-installed in the devcontainer
  • ./mvnw - Spring Boot Maven Wrapper, no global Maven install required
  • curl (opens in new tab) - sends requests to http://localhost:8080/ to test each targeting branch
  • jq (opens in new tab) - pretty-prints the JSON evaluation details
  • tail -f - watches the application log live for [AUDIT] lines