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
Ready to start?
Launch in a preconfigured devcontainer
Free GitHub account required
Walkthrough
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.
Wait ~2-3 minutes for the Java toolchain to install. Use
Cmd/Ctrl + Shift + PthenView Creation Logto watch progress. When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready inadventures/04-blind-by-design/intermediate/.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/.
- Port 8080: Spring Boot lab. The application under test. Access via the Ports tab or curl
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 fireThat
"blurry"is the starting point you want: even when the request shoutsspecies=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.The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the
FlagdProvideris wired inResolver.RPCmode against the flagd sidecar. Openflags.jsonand inspect the targeting. Three branches exist but none fire because nothing in the app populatesspecies,country, ordoseyet:"targeting": { "if": [ { "===": [{"var": "species"}, "zyklop"] }, "enhanced", { "in": [{"var": "dose"}, ["underdose", "overdose"]] }, "clouded", { "===": [{"var": "country"}, "de"] }, "sharp" ] }Your job: populate
species,country, anddoseon the evaluation context so the targeting fires.Create a Spring
HandlerInterceptornamedSpeciesInterceptor: inpreHandle, read?species=and put it on the transaction context; inafterCompletion, clear the context so values do not leak across pooled threads. Register aThreadLocalTransactionContextPropagatoronce 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.Update
OpenFeatureConfigto: registerSpeciesInterceptorwith Spring (WebMvcConfigurer.addInterceptors), read theCOUNTRYenvironment variable and set it as the global evaluation context, and registerAuditHookglobally on the OpenFeature API. The three context layers, global (country), transaction (speciesfrom the interceptor), and invocation (dosefromTrial), merge before flagd evaluates the rules. Precedence on conflict is invocation over transaction over global.Update
Trialso each evaluation passesdoseon the invocation context (the third argument toclient.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 inflags.jsonfire at all. Make it overridable via?dose=so you can test each branch by hand. If the invocation context does not carrydose, the targeting rule seesnulland the branch never fires: every non-zyklop request lands on either the country branch or the default.Implement
AuditHook: inafter(), 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. Implementerror()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.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:rundirectly, add| tee app.logor the verifier has nothing to grep:./run-germany.sh # COUNTRY=de (or: make lab-germany) ./run-austria.sh # COUNTRY=atThree 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 | headYou should see one
[AUDIT] flag=vision_state variant=... reason=... species=... country=... dose=...line per request.cloudedoutcomes 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:
If it passes, it generates a Certificate of Completion you can paste into the discussion../verify.sh - 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