diff --git a/.devcontainer/00-blind-by-design_01-beginner/devcontainer.json b/.devcontainer/00-blind-by-design_01-beginner/devcontainer.json new file mode 100644 index 00000000..dc179b2b --- /dev/null +++ b/.devcontainer/00-blind-by-design_01-beginner/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "๐Ÿงช Adventure 00 | ๐ŸŸข Beginner (Stand up the lab)", + "dockerComposeFile": "docker-compose.yml", + "service": "workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/00-blind-by-design/beginner", + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_01-beginner/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_01-beginner/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "vmware.vscode-spring-boot", + "vscjava.vscode-spring-boot-dashboard", + "redhat.vscode-xml" + ] + }, + "codespaces": { + "openFiles": [ + "adventures/planned/00-blind-by-design/docs/beginner.md", + "adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java", + "adventures/planned/00-blind-by-design/beginner/flags.json" + ] + } + }, + "forwardPorts": [8080, 8013, 8014, 8015, 8016], + "portsAttributes": { + "8080": { "label": "Lab (Spring Boot)", "onAutoForward": "notify" }, + "8013": { "label": "flagd gRPC eval", "onAutoForward": "ignore" }, + "8014": { "label": "flagd management/metrics", "onAutoForward": "ignore" }, + "8015": { "label": "flagd sync (IN_PROCESS)", "onAutoForward": "ignore" }, + "8016": { "label": "flagd OFREP", "onAutoForward": "ignore" } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/00-blind-by-design_01-beginner/docker-compose.yml b/.devcontainer/00-blind-by-design_01-beginner/docker-compose.yml new file mode 100644 index 00000000..5ca946fc --- /dev/null +++ b/.devcontainer/00-blind-by-design_01-beginner/docker-compose.yml @@ -0,0 +1,40 @@ +# Multi-container devcontainer for Beginner. The lab itself runs in +# `workspace`; flagd runs as a sibling so participants meet the realistic +# shape ("the SDK talks RPC to a separate flag service") from level 1 +# instead of running file-mode in-process and throwing it away for +# Intermediate. +# +# Both services bind-mount the same workspace at the same path. flagd +# watches the participant's flags.json directly โ€” edit it in the IDE, +# the file watcher reloads, the next request sees the new variant. + +services: + workspace: + image: mcr.microsoft.com/devcontainers/java:1-21 + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:cached + command: sleep infinity + environment: + # The flagd Java provider reads these env vars by default when + # FlagdOptions.builder() is invoked without an explicit host/port. + # Pre-set so a vanilla `Resolver.RPC` config Just Works inside the + # devcontainer โ€” and a participant who runs the lab from their host + # machine after `docker compose up flagd` will hit `localhost:8013` + # via the published port. + - FLAGD_HOST=flagd + - FLAGD_PORT=8013 + + flagd: + image: ghcr.io/open-feature/flagd:latest + container_name: side-effects-beginner-flagd + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + command: + - start + - --uri + - file:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}/adventures/planned/00-blind-by-design/beginner/flags.json + ports: + - "8013:8013" + - "8014:8014" + - "8015:8015" + - "8016:8016" diff --git a/.devcontainer/00-blind-by-design_01-beginner/post-create.sh b/.devcontainer/00-blind-by-design_01-beginner/post-create.sh new file mode 100755 index 00000000..f20e6752 --- /dev/null +++ b/.devcontainer/00-blind-by-design_01-beginner/post-create.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/beginner" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "00-blind-by-design" "beginner" +track_codespace_created + +# Install gum (used by the verify.sh output helpers). +"$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 # https://github.com/charmbracelet/gum/releases + +# jq is needed by verify.sh; the Java devcontainer image is debian-based. +if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends jq +fi + +# Java 21 is provided by the devcontainer image (mcr.microsoft.com/devcontainers/java:1-21-bullseye). +# Pre-fetch Maven dependencies so the IDE is responsive immediately. +echo "โœจ Resolving Maven dependencies for the lab..." +cd "$CHALLENGE_DIR" +chmod +x ./mvnw +./mvnw -q -B -DskipTests dependency:go-offline || true + +echo "โœ… Post-create complete." diff --git a/.devcontainer/00-blind-by-design_01-beginner/post-start.sh b/.devcontainer/00-blind-by-design_01-beginner/post-start.sh new file mode 100755 index 00000000..3121d4dd --- /dev/null +++ b/.devcontainer/00-blind-by-design_01-beginner/post-start.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/beginner" + +cat </dev/null 2>&1; then + code "$REPO_ROOT/adventures/planned/00-blind-by-design/docs/beginner.md" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/Trial.java" \ + 2>/dev/null || true +fi diff --git a/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json b/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json new file mode 100644 index 00000000..a19770d0 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "๐Ÿงช Adventure 00 | ๐ŸŸก Intermediate (Outcome by cohort)", + "dockerComposeFile": "docker-compose.yml", + "service": "workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/00-blind-by-design/intermediate", + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "vmware.vscode-spring-boot", + "vscjava.vscode-spring-boot-dashboard" + ] + }, + "codespaces": { + "openFiles": [ + "adventures/planned/00-blind-by-design/docs/intermediate.md", + "adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java", + "adventures/planned/00-blind-by-design/intermediate/flags.json" + ] + } + }, + "forwardPorts": [8080, 8013, 8014, 8015, 8016], + "portsAttributes": { + "8080": { "label": "Lab (Spring Boot)", "onAutoForward": "notify" }, + "8013": { "label": "flagd gRPC eval", "onAutoForward": "ignore" }, + "8014": { "label": "flagd management/metrics", "onAutoForward": "ignore" }, + "8015": { "label": "flagd sync (IN_PROCESS)", "onAutoForward": "ignore" }, + "8016": { "label": "flagd OFREP", "onAutoForward": "ignore" } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml b/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml new file mode 100644 index 00000000..b8893427 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/docker-compose.yml @@ -0,0 +1,41 @@ +# Multi-container devcontainer for Intermediate. The lab itself runs in +# `workspace`; flagd runs as a sibling so participants who finish the +# FILE-mode path early can flip the FlagdProvider to RPC mode and talk +# to flagd:8013 without any Docker-in-Docker dance. +# +# Both services bind-mount the same workspace at the same path. flagd +# watches the participant's flags.json directly โ€” edit it in the IDE, +# the file watcher reloads. + +services: + workspace: + image: mcr.microsoft.com/devcontainers/java:1-21 + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:cached + command: sleep infinity + environment: + # Pre-set FLAGD_HOST so participants who switch to RPC mode in + # OpenFeatureConfig do not have to hard-code the hostname. + - FLAGD_HOST=flagd + - FLAGD_PORT=8013 + # The trial's country of registration. Used by OpenFeatureConfig + # via System.getenv("COUNTRY") to populate the global eval context. + # `de` by default so F5 / Spring Boot Dashboard runs already exercise + # the country-targeting branch. Override with run-austria.sh or + # `COUNTRY=at ./mvnw spring-boot:run` to flip. + - COUNTRY=de + + flagd: + image: ghcr.io/open-feature/flagd:latest + container_name: side-effects-intermediate-flagd + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + command: + - start + - --uri + - file:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}/adventures/planned/00-blind-by-design/intermediate/flags.json + ports: + - "8013:8013" + - "8014:8014" + - "8015:8015" + - "8016:8016" diff --git a/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh b/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh new file mode 100755 index 00000000..378e1181 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/post-create.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "00-blind-by-design" "intermediate" +track_codespace_created + +"$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 # https://github.com/charmbracelet/gum/releases + +# jq is needed by verify.sh; the Java devcontainer image is debian-based. +if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends jq +fi + +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/intermediate" + +# Make the Maven wrapper executable so the participant can just `./mvnw ...` +if [[ -f "$CHALLENGE_DIR/mvnw" ]]; then + chmod +x "$CHALLENGE_DIR/mvnw" +fi + +echo "โœจ Pre-warming the Maven dependency cache so the first ./mvnw is fast..." +( cd "$CHALLENGE_DIR" && ./mvnw -q -DskipTests dependency:go-offline ) || \ + echo "โš ๏ธ Dependency pre-warm skipped (network or wrapper not ready yet)" + +echo "โœ… Post-create complete." diff --git a/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh b/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh new file mode 100755 index 00000000..695b7911 --- /dev/null +++ b/.devcontainer/00-blind-by-design_02-intermediate/post-start.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/intermediate" + +cat </dev/null 2>&1; then + code "$REPO_ROOT/adventures/planned/00-blind-by-design/docs/intermediate.md" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java" \ + "$CHALLENGE_DIR/flags.json" \ + 2>/dev/null || true +fi diff --git a/.devcontainer/00-blind-by-design_03-expert/devcontainer.json b/.devcontainer/00-blind-by-design_03-expert/devcontainer.json new file mode 100644 index 00000000..44fb84e5 --- /dev/null +++ b/.devcontainer/00-blind-by-design_03-expert/devcontainer.json @@ -0,0 +1,41 @@ +{ + "name": "๐Ÿงช Adventure 00 | ๐Ÿ”ด Expert (Phase 3 โ€” read the chart)", + "dockerComposeFile": "docker-compose.yml", + "service": "workspace", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/adventures/planned/00-blind-by-design/expert", + "postCreateCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_03-expert/post-create.sh", + "postStartCommand": "bash /workspaces/${localWorkspaceFolderBasename}/.devcontainer/00-blind-by-design_03-expert/post-start.sh", + "customizations": { + "vscode": { + "extensions": [ + "vscjava.vscode-java-pack", + "redhat.vscode-yaml", + "ms-azuretools.vscode-docker" + ] + }, + "codespaces": { + "openFiles": [ + "adventures/planned/00-blind-by-design/docs/expert.md", + "adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java", + "adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java", + "adventures/planned/00-blind-by-design/expert/flags.json" + ] + } + }, + "forwardPorts": [8080, 3000, 4317, 4318, 9090, 3200, 8013, 8014, 8015, 8016], + "portsAttributes": { + "8080": { "label": "Spring Boot lab", "onAutoForward": "notify" }, + "3000": { "label": "Grafana", "onAutoForward": "notify" }, + "4317": { "label": "OTLP gRPC", "onAutoForward": "ignore" }, + "4318": { "label": "OTLP HTTP", "onAutoForward": "ignore" }, + "9090": { "label": "Prometheus", "onAutoForward": "ignore" }, + "3200": { "label": "Tempo HTTP API", "onAutoForward": "ignore" }, + "8013": { "label": "flagd gRPC eval", "onAutoForward": "ignore" }, + "8014": { "label": "flagd management/metrics", "onAutoForward": "ignore" }, + "8015": { "label": "flagd sync (IN_PROCESS)", "onAutoForward": "ignore" }, + "8016": { "label": "flagd OFREP", "onAutoForward": "ignore" } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + } +} diff --git a/.devcontainer/00-blind-by-design_03-expert/docker-compose.yml b/.devcontainer/00-blind-by-design_03-expert/docker-compose.yml new file mode 100644 index 00000000..842b3fce --- /dev/null +++ b/.devcontainer/00-blind-by-design_03-expert/docker-compose.yml @@ -0,0 +1,69 @@ +# Multi-container devcontainer for Expert. The lab itself runs in +# `workspace`; flagd, the Grafana LGTM stack, and the k6 loadgen run as +# sibling services. No Docker-in-Docker โ€” the devcontainer attaches to +# `workspace` and the rest of the stack is already up. +# +# Inside `workspace`, services are reachable by service name +# (flagd:8013, lgtm:4317, etc.). FLAGD_HOST and OTEL_EXPORTER_OTLP_ENDPOINT +# are pre-set so the participant does not have to hard-code hostnames. +# Codespaces also forwards each port to localhost on the host so verify.sh +# and curl can keep using localhost:NNNN unchanged. + +services: + workspace: + image: mcr.microsoft.com/devcontainers/java:1-21 + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:cached + command: sleep infinity + environment: + - FLAGD_HOST=flagd + - FLAGD_PORT=8013 + - OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4317 + - OTEL_EXPORTER_OTLP_PROTOCOL=grpc + - OTEL_SERVICE_NAME=fun-with-flags-java-spring + # Trial country of registration. Read by OpenFeatureConfig via + # System.getenv("COUNTRY") and put on the global eval context. + - COUNTRY=de + + flagd: + image: ghcr.io/open-feature/flagd:latest + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + command: + - start + - --uri + - file:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}/adventures/planned/00-blind-by-design/expert/flags.json + ports: + - "8013:8013" + - "8014:8014" + - "8015:8015" + - "8016:8016" + + lgtm: + image: grafana/otel-lgtm:latest + ports: + - "3000:3000" # Grafana UI (admin / admin) + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "9090:9090" # Prometheus query API (verify.sh) + - "3200:3200" # Tempo HTTP API (verify.sh) + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - ../..:/workspaces/${localWorkspaceFolderBasename:-open-ecosystem-challenges}:ro + - ../../adventures/planned/00-blind-by-design/expert/dashboards:/otel-lgtm/grafana/dashboards:ro + + loadgen: + image: grafana/k6:latest + command: ["run", "--quiet", "/scripts/script.js"] + volumes: + - ../../adventures/planned/00-blind-by-design/expert/loadgen/k6:/scripts:ro + environment: + # The script idles while loadgen_active is "off". Flip it in flags.json + # to start hammering the lab. + - BASE_URL=http://workspace:8080 + - FLAGD_URL=http://flagd:8013 + restart: unless-stopped + depends_on: + - flagd diff --git a/.devcontainer/00-blind-by-design_03-expert/post-create.sh b/.devcontainer/00-blind-by-design_03-expert/post-create.sh new file mode 100755 index 00000000..ee115d49 --- /dev/null +++ b/.devcontainer/00-blind-by-design_03-expert/post-create.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/scripts/tracker.sh" +set_tracking_context "00-blind-by-design" "expert" +track_codespace_created + +# gum is used by the verify.sh / output.sh helpers +"$REPO_ROOT/lib/shared/init.sh" --version v0.17.0 # https://github.com/charmbracelet/gum/releases + +# jq is needed by verify.sh; the Java devcontainer image is debian-based. +if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends jq +fi + +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/expert" + +# Make the Maven wrapper executable so the participant can just `./mvnw ...` +if [[ -f "$CHALLENGE_DIR/mvnw" ]]; then + chmod +x "$CHALLENGE_DIR/mvnw" +fi + +echo "โœจ Pre-warming the Maven dependency cache so the first ./mvnw is fast..." +( cd "$CHALLENGE_DIR" && ./mvnw -q -DskipTests dependency:go-offline ) || \ + echo "โš ๏ธ Dependency pre-warm skipped (network or wrapper not ready yet)" + +echo "โœ… Phase 3 toolchain ready (gum + Java 21). flagd / lgtm / loadgen run as sibling devcontainer services." diff --git a/.devcontainer/00-blind-by-design_03-expert/post-start.sh b/.devcontainer/00-blind-by-design_03-expert/post-start.sh new file mode 100755 index 00000000..454d9b5e --- /dev/null +++ b/.devcontainer/00-blind-by-design_03-expert/post-start.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -e + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHALLENGE_DIR="$REPO_ROOT/adventures/planned/00-blind-by-design/expert" + +cat </dev/null 2>&1; then + code "$REPO_ROOT/adventures/planned/00-blind-by-design/docs/expert.md" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java" \ + "$CHALLENGE_DIR/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java" \ + "$CHALLENGE_DIR/flags.json" \ + 2>/dev/null || true +fi diff --git a/adventures/planned/00-blind-by-design/.gitignore b/adventures/planned/00-blind-by-design/.gitignore new file mode 100644 index 00000000..2bbe1f43 --- /dev/null +++ b/adventures/planned/00-blind-by-design/.gitignore @@ -0,0 +1,8 @@ +target/ + +# The repo root .gitignore excludes .vscode/ globally. Re-include the +# launch + task configs that ship as part of this scenario โ€” they are not +# editor preferences, they are the F5 / Run-and-Debug entry points the +# participant uses to start the lab. +!*/.vscode/ +!*/.vscode/** diff --git a/adventures/planned/00-blind-by-design/README.md b/adventures/planned/00-blind-by-design/README.md new file mode 100644 index 00000000..67ee8e41 --- /dev/null +++ b/adventures/planned/00-blind-by-design/README.md @@ -0,0 +1,12 @@ +# ๐Ÿงช Adventure 00: Blind by Design + +A research lab is testing a vision-enhancement serum on volunteers. The serum is supposed to take ordinary eyes and produce sharper, even enhanced sight. The lab is a Spring Boot service; OpenFeature is the chart system; `flags.json` decides what reading the lab records for each subject. The protocol is the same for everyone โ€” what differs is the observed outcome, because subjects come in with different biology, dose adherence, and trial-jurisdiction baseline. The flagship Phase 3 trial โ€” a new amplifier algorithm โ€” has started showing trouble: subjects stabilise slower, and roughly one in ten emerge blind. The dashboard that should be tracking all of this is dark. Your mission across three levels: stand up the lab, read the chart by cohort, then turn on the lights and roll back the trial before more subjects lose their sight. + +**Technologies:** OpenFeature Java SDK, flagd, Spring Boot, Grafana LGTM (Tempo + Prometheus + Loki), Testcontainers + +The entire **infrastructure is pre-provisioned in your Codespace**. +**You don't need to set up anything locally. Just focus on solving the problem.** + +## ๐Ÿš€ Ready to Start? + +[Choose your level](https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/) and begin learning! diff --git a/adventures/planned/00-blind-by-design/beginner/.mvn/wrapper/maven-wrapper.properties b/adventures/planned/00-blind-by-design/beginner/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..13b218bf --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/adventures/planned/00-blind-by-design/beginner/.vscode/launch.json b/adventures/planned/00-blind-by-design/beginner/.vscode/launch.json new file mode 100644 index 00000000..9e149819 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "๐Ÿงช Run the Lab", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json b/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json new file mode 100644 index 00000000..1d483f30 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "๐Ÿงช Verify Solution", + "type": "shell", + "command": "./verify.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "group": { "kind": "test", "isDefault": true } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/beginner/flags.json b/adventures/planned/00-blind-by-design/beginner/flags.json new file mode 100644 index 00000000..b04d1531 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/flags.json @@ -0,0 +1,3 @@ +{ + "flags": {} +} diff --git a/adventures/planned/00-blind-by-design/beginner/mvnw b/adventures/planned/00-blind-by-design/beginner/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/adventures/planned/00-blind-by-design/beginner/mvnw.cmd b/adventures/planned/00-blind-by-design/beginner/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/adventures/planned/00-blind-by-design/beginner/pom.xml b/adventures/planned/00-blind-by-design/beginner/pom.xml new file mode 100644 index 00000000..eb67c50a --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + dev.openfeature.demo.java + demo + 0.0.1-SNAPSHOT + demo + Demo project for OpenFeature with Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java new file mode 100644 index 00000000..53fb812b --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java @@ -0,0 +1,13 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Laboratory { + + public static void main(String[] args) { + SpringApplication.run(Laboratory.class, args); + } + +} diff --git a/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java new file mode 100644 index 00000000..1e20a475 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/src/main/java/dev/openfeature/demo/java/demo/Trial.java @@ -0,0 +1,15 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Trial { + + @GetMapping("/") + public String observeSubject() { + // The lab is reading from a hard-coded label, not from the chart. + // Wire OpenFeature in and resolve the "vision_state" flag from flags.json instead. + return "untreated"; + } +} diff --git a/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties b/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties new file mode 100644 index 00000000..2109a440 --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=demo diff --git a/adventures/planned/00-blind-by-design/beginner/verify.sh b/adventures/planned/00-blind-by-design/beginner/verify.sh new file mode 100755 index 00000000..bd46b2af --- /dev/null +++ b/adventures/planned/00-blind-by-design/beginner/verify.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../../lib/scripts/loader.sh" + +OBJECTIVE="By the end of this level, you should: + +- See curl http://localhost:8080/ return a vision_state reading resolved from flags.json (not the hard-coded fallback) +- Confirm the response payload includes the OpenFeature evaluation details (variant, reason, value) +- Edit flags.json to change the defaultVariant, save, and have the next request return the new variant without restarting the app" + +DOCS_URL="https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/beginner" + +APP_URL="http://localhost:8080/" +FLAGS_FILE="$SCRIPT_DIR/flags.json" + +print_header \ + 'Adventure 00: Blind by Design' \ + 'Level 1: Stand up the lab' \ + 'Smoke Test Verification' + +check_prerequisites curl jq + +print_sub_header "Running smoke tests..." + +# Track test results across all checks +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +# 1. The Spring Boot lab is reachable on :8080. +print_test_section "Checking the lab is reachable on $APP_URL..." +RESPONSE=$(curl -sS --max-time 5 "$APP_URL" 2>/dev/null || echo "") + +if [[ -z "$RESPONSE" ]]; then + print_error_indent "Lab did not respond on $APP_URL" + print_hint "Start the app with: ./mvnw spring-boot:run" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("lab_unreachable") + print_test_summary "stand up the lab" "$DOCS_URL" "$OBJECTIVE" + exit +fi +print_success_indent "Lab responded on $APP_URL" +TESTS_PASSED=$((TESTS_PASSED + 1)) + +# 2. Response is FlagEvaluationDetails JSON containing flag_key="vision_state". +print_test_section "Checking the response is an OpenFeature evaluation for 'vision_state'..." +FLAG_KEY=$(echo "$RESPONSE" | jq -r '.flagKey // .flag_key // empty' 2>/dev/null || echo "") + +if [[ "$FLAG_KEY" != "vision_state" ]]; then + print_error_indent "Response did not include 'flagKey':'vision_state'" + print_info_indent "Actual response: $RESPONSE" + print_hint "Wire client.getStringDetails(\"vision_state\", ...) in Trial and return the FlagEvaluationDetails." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flag_key_missing") +else + print_success_indent "Response carries OpenFeature evaluation details for 'vision_state'" + TESTS_PASSED=$((TESTS_PASSED + 1)) +fi + +# 3. The resolved value is NOT the literal "untreated" fallback. +print_test_section "Checking the value is resolved from a provider, not the hard-coded fallback..." +VALUE=$(echo "$RESPONSE" | jq -r '.value // empty' 2>/dev/null || echo "") +REASON=$(echo "$RESPONSE" | jq -r '.reason // empty' 2>/dev/null || echo "") + +if [[ "$VALUE" == "untreated" ]]; then + print_error_indent "Value is still the hard-coded fallback 'untreated' (reason=$REASON)" + print_hint "Configure a FlagdProvider in RPC mode (talks to the flagd sidecar on flagd:8013) and add a 'vision_state' flag to flags.json." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("fallback_value") +elif [[ -z "$VALUE" ]]; then + print_error_indent "No 'value' field in the response" + print_info_indent "Actual response: $RESPONSE" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("value_missing") +else + print_success_indent "Resolved value '$VALUE' (reason=$REASON)" + TESTS_PASSED=$((TESTS_PASSED + 1)) +fi + +# 4. flags.json is hot-reloaded: flip defaultVariant and confirm the response changes. +print_test_section "Checking that flags.json drives the response (hot-reload swap)..." +if [[ ! -f "$FLAGS_FILE" ]]; then + print_error_indent "flags.json not found at $FLAGS_FILE" + print_hint "Drop a flags.json next to pom.xml with a 'vision_state' flag (variants: blurry, clouded)." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flags_json_missing") +else + ORIGINAL_VARIANT=$(jq -r '.flags.vision_state.defaultVariant // empty' "$FLAGS_FILE") + if [[ -z "$ORIGINAL_VARIANT" ]]; then + print_error_indent "Could not read .flags.vision_state.defaultVariant from flags.json" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flags_json_invalid") + else + # Pick a different variant from the file. + OTHER_VARIANT=$(jq -r --arg cur "$ORIGINAL_VARIANT" '.flags.vision_state.variants | keys[] | select(. != $cur)' "$FLAGS_FILE" | head -n1) + if [[ -z "$OTHER_VARIANT" ]]; then + print_error_indent "flags.json only defines a single variant; need at least two for the swap test." + print_hint "Add a 'clouded' variant alongside 'blurry'." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("single_variant") + else + BACKUP="$(mktemp)" + cp "$FLAGS_FILE" "$BACKUP" + # Restore on any exit path. + trap 'cp "$BACKUP" "$FLAGS_FILE" 2>/dev/null || true; rm -f "$BACKUP"' EXIT + + # Capture the current value, then swap. + BEFORE_VALUE=$(curl -sS --max-time 5 "$APP_URL" | jq -r '.value // empty') + jq --arg v "$OTHER_VARIANT" '.flags.vision_state.defaultVariant = $v' "$FLAGS_FILE" > "$FLAGS_FILE.tmp" && mv "$FLAGS_FILE.tmp" "$FLAGS_FILE" + + # Wait up to ~5s for the file watcher to pick up the change. + AFTER_VALUE="$BEFORE_VALUE" + for _ in 1 2 3 4 5; do + sleep 1 + AFTER_VALUE=$(curl -sS --max-time 5 "$APP_URL" | jq -r '.value // empty') + if [[ "$AFTER_VALUE" != "$BEFORE_VALUE" ]]; then + break + fi + done + + # Restore. + cp "$BACKUP" "$FLAGS_FILE" + rm -f "$BACKUP" + trap - EXIT + + if [[ "$AFTER_VALUE" != "$BEFORE_VALUE" && -n "$AFTER_VALUE" ]]; then + print_success_indent "Hot-reload works: response changed from '$BEFORE_VALUE' to '$AFTER_VALUE' after editing flags.json" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "Editing flags.json did not change the response (still '$AFTER_VALUE')" + print_hint "flagd's file watcher should pick up the edit. Confirm flagd is running (docker compose ps) and that flags.json sits where the compose file mounts it." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("hot_reload_failed") + fi + fi + fi +fi + +print_test_summary "stand up the lab" "$DOCS_URL" "$OBJECTIVE" diff --git a/adventures/planned/00-blind-by-design/docs/beginner.md b/adventures/planned/00-blind-by-design/docs/beginner.md new file mode 100644 index 00000000..c1c4a493 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/beginner.md @@ -0,0 +1,183 @@ +# ๐ŸŸข Beginner: Stand up the lab + +Wire the OpenFeature Java SDK and the flagd contrib provider into a Spring Boot service so flag evaluations are resolved by a flagd sidecar against a `flags.json` file. Author your first flag, then prove that editing `flags.json` flips the response on the **next** request โ€” no app restart, no flagd restart, no redeploy. + +The Spring Boot service is already running on `:8080`; a flagd container is already running on `:8013`; `flags.json` is an empty skeleton (`{"flags": {}}`). The SDK is **not** wired in yet โ€” that's your job. + +## ๐Ÿงช The story (optional) + +The lab is on its first shift and it isn't reading the chart. Every subject who walks through the door gets the same hard-coded reading on their record โ€” no matter what the lab director just signed off on. The label coming out of the lab is a literal string baked into the controller, not a reading pulled from the chart. + +Your mission: replace that hard-coded label with an OpenFeature client, point that client at the **flagd sidecar** that already runs next to your Codespace, and let `flags.json` drive what gets recorded as the subject's `vision_state`. While you're at it, prove the lab can change what it records **without restarting anything** โ€” edit `flags.json`, save, and the next subject through the door has the new reading on their chart. + +## ๐Ÿ—๏ธ Architecture + +This level runs as two containers side-by-side in your Codespace โ€” the Spring Boot lab and a flagd sidecar. + +- **The lab** โ€” a Spring Boot 4 service on `http://localhost:8080/` with one endpoint, `GET /`. Today it returns a hard-coded `"untreated"` literal from `Trial`. +- **The chart** โ€” a `flags.json` file in the level folder, mounted **read-only** into the flagd sidecar. The participant edits it through the IDE; flagd's file watcher picks up the change. +- **The flagd sidecar** โ€” `ghcr.io/open-feature/flagd:latest`, started by the devcontainer compose stack. It serves flag evaluations over **gRPC on `:8013`**, watches `flags.json` on disk, and reloads when it changes. +- **The chart system** โ€” the OpenFeature Java SDK plus the **flagd contrib provider** in `Resolver.RPC` mode. The provider reads `FLAGD_HOST=flagd` / `FLAGD_PORT=8013` from the environment (the compose file pre-sets them), so there is no host or port to hard-code. + +## ๐ŸŽฏ Objective + +By the end of this level, you should: + +- Have `curl http://localhost:8080/` return a `vision_state` reading **resolved from `flags.json`** (not the hard-coded `"untreated"` fallback) +- Confirm the response payload includes the **OpenFeature evaluation details** โ€” flag key, variant, reason, value +- Edit `flags.json` to change the `defaultVariant`, save, and have the **next** request return the new variant **without restarting the app** + +## ๐Ÿง  What You'll Learn + +- How an OpenFeature client and provider work together โ€” the SDK is provider-agnostic and the flagd provider plugs in via dependency only +- What "remote provider" means in practice โ€” the SDK calls a separate flag service (flagd) over gRPC; the SDK does not parse `flags.json` itself +- What `flags.json` looks like for flagd (`state`, `variants`, `defaultVariant`) +- Why hot-reload of the flag file matters operationally โ€” configuration without redeploy + +## ๐Ÿงฐ Toolbox + +Your Codespace comes pre-configured with the following tools to help you solve the challenge: + +- [`./mvnw`](https://maven.apache.org/wrapper/): The Maven wrapper checked in next to `pom.xml`. Builds and runs the Spring Boot lab. +- [`curl`](https://curl.se/): Hits `http://localhost:8080/` and shows you what reading the lab is recording. +- [`jq`](https://jqlang.org/): Pretty-prints and filters the JSON evaluation details that come back from the SDK. +- A **flagd sidecar** โ€” already running in the devcontainer's compose stack. The flagd sidecar is on `:8013`; the other ports aren't used here. + +## โฐ Deadline + +_TBD โ€” to be announced at challenge launch._ +> โ„น๏ธ You can still complete the challenge after this date, but points will only be awarded for submissions before the +> deadline. + +## ๐Ÿ’ฌ Join the discussion + +Share your solutions and questions in the challenge thread on the Open Ecosystem Community. +_Discussion link will be added when this adventure goes live._ + +## ๐Ÿ“ Solution Walkthrough + +> โš ๏ธ **Spoiler Alert:** The following walkthrough contains the full solution to the challenge. We encourage you to try +> solving it on your own first. Consider coming back here only if you get stuck or want to check your approach. + +Need the answer key? Follow the [step-by-step beginner solution walkthrough](./solutions/beginner.md) for the final +`pom.xml` dependencies, `OpenFeatureConfig`, `flags.json`, and `Trial`. + +## โœ… How to Play + +### 1. Start Your Challenge + +- Click the "Fork" button in the top-right corner of the GitHub repo or use + [this link](https://github.com/dynatrace-oss/open-ecosystem-challenges/fork). +- From your fork, click the green **Code** button โ†’ **Codespaces hamburger menu** โ†’ **New with options**. +- Select the **Adventure 00 | ๐ŸŸข Beginner (Stand up the lab)** configuration. + +> โš ๏ธ **Important:** The challenge will not work if you choose another configuration (or the default). + +The Codespace will install a Java 21 toolchain and resolve the Maven dependencies. Once it is ready you'll have a +terminal in +`adventures/planned/00-blind-by-design/beginner/`. + +### 2. Access the UIs + +Open the **Ports** tab in the bottom panel. You should see: + +- **8080 โ€” Lab (Spring Boot).** Click the forwarded address. You should see the current hard-coded response: `untreated`. +- **8013 โ€” flagd gRPC.** This is the flagd sidecar. Nothing to click yet, but knowing it's there is the point: the lab + is going to talk to this in step 3. + +### 3. Implement the Objective + +You are turning a hard-coded label into a real protocol-driven reading. Work through the steps in this order โ€” each +step makes the next one possible. + +#### a. Add the OpenFeature SDK and the flagd provider to `pom.xml` + +The lab needs two dependencies: the OpenFeature Java SDK and the flagd contrib provider. GroupIds, artifactIds, and current versions are in the +[OpenFeature Java SDK docs](https://openfeature.dev/docs/reference/technologies/server/java/) and the +[flagd Java provider readme](https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd). + +#### b. Configure the OpenFeature provider + +Create a Spring `@Configuration` class that, at startup, builds a `FlagdProvider` in **RPC mode** and registers it on the global OpenFeature API. The [flagd Java provider readme](https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd) covers `FlagdOptions` / `Resolver.RPC` usage. + +You don't need to set host or port โ€” the devcontainer pre-sets `FLAGD_HOST=flagd` and `FLAGD_PORT=8013` in the environment, and the provider reads those automatically. + +#### c. Author the `vision_state` flag in `flags.json` + +The level ships an empty `flags.json` next to `pom.xml` (`{"flags": {}}`) so the flagd sidecar has a valid file to mount at boot. Open it and add a flag named `vision_state` with **two string variants** (e.g. `blurry` and `clouded`) so you have something to flip in the verification step. The schema (`state`, `variants`, `defaultVariant`) is in the [flagd flag-definitions reference](https://flagd.dev/reference/flag-definitions/). + +Save โ€” flagd's file watcher picks the change up within about a second; no restart needed. + +#### d. Read the chart from `Trial` + +Replace the hard-coded `return "untreated";` with an OpenFeature evaluation of the `vision_state` flag, using `"untreated"` as the fallback. **Return the full evaluation details** (not just the value) so the response carries the flag key, variant, value, and reason โ€” that's what the verifier checks. + +The Java SDK's evaluation methods are documented in the [OpenFeature Java SDK reference](https://openfeature.dev/docs/reference/technologies/server/java/). + +#### e. Restart the lab, then prove hot-reload + +You have two ways to start the lab: + +- **Click โ–ถ in VS Code.** The Spring Boot Dashboard panel (one of the recommended extensions in this devcontainer) lists `Laboratory` with a **Run** button. Or press **F5** with `Laboratory.java` open and pick **Java** as the debugger โ€” Spring's main class is detected automatically; no launch.json needed. +- **From the terminal** in the level folder: + + ```bash + ./mvnw spring-boot:run + ``` + +In another terminal: + +```bash +curl -s http://localhost:8080/ | jq +``` + +You should see `"value": "blurry"` and `"flagKey": "vision_state"`. Now, **without stopping the app or the flagd +sidecar**, edit `flags.json` and change `"defaultVariant": "blurry"` to `"defaultVariant": "clouded"`. Save, then +re-run the `curl`. The value should flip to `"clouded"` โ€” that's flagd's file watcher noticing the change on disk +and serving the new variant on the next gRPC evaluation. Nothing redeployed; nothing restarted. + +### 4. Verify Your Solution + +Once you think you've solved the challenge, it's time to verify! + +#### Run the Smoke Test + +Run the provided smoke test script (the lab must still be running on `:8080`): + +```bash +adventures/planned/00-blind-by-design/beginner/verify.sh +``` + +The script will: + +1. Confirm `http://localhost:8080/` is reachable. +2. Confirm the response is OpenFeature evaluation details for the `vision_state` flag. +3. Confirm the value is **not** the hard-coded `"untreated"` fallback. +4. Swap `defaultVariant` in `flags.json`, wait for the file watcher, confirm the response changes, then restore the + original file. + +If the test passes, your solution is very likely correct! ๐ŸŽ‰ + +## โœ… Verification + +A passing run looks roughly like this: + +```text +โœ… PASSED: All 4 checks passed + +It looks like you successfully completed this level! ๐ŸŒŸ +``` + +```json +{ + "flagKey": "vision_state", + "value": "blurry", + "variant": "blurry", + "reason": "STATIC", + "errorCode": null, + "errorMessage": null, + "flagMetadata": {} +} +``` + +If you see `"value": "blurry"` (or `"clouded"`) and `"flagKey": "vision_state"`, you're ready for the ๐ŸŸก Intermediate level โ€” **Outcome by cohort**. diff --git a/adventures/planned/00-blind-by-design/docs/expert.md b/adventures/planned/00-blind-by-design/docs/expert.md new file mode 100644 index 00000000..4b5334f5 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/expert.md @@ -0,0 +1,292 @@ +# ๐Ÿ”ด Expert: Phase 3 โ€” read the chart + +Three sub-tasks: + +1. **Wire the OpenTelemetry meter provider** and register the OpenFeature `MetricsHook` so flag evaluations show up as Prometheus counters. +2. **Author a `ContextSpanHook`** of your own โ€” a small `Hook` that copies the merged evaluation context (`species`, `country`, `dose`) onto the active OTel span as `feature_flag.context.` so traces correlate variants with the context that drove them. +3. **Diagnose and roll back a misbehaving fractional rollout.** The `vision_amplifier_v2` flag is at 100% on; it's adding 200ms latency and a 10% HTTP 5xx rate. Identify it on the Grafana dashboard and roll it back via `flags.json` โ€” no redeploy. + +Spans are already flowing into Tempo from the OpenFeature `TracesHook`, but the metrics half is dead โ€” the `MeterProvider` has no exporter and the `MetricsHook` was never registered. The dashboard the operator wants to triage from is empty. The k6 loadgen is idle, waiting for a flag flip to turn it on. + +The level passes when (a) `feature_flag_evaluation_requests_total` is non-zero in Prometheus, (b) Tempo spans for `fun-with-flags-java-spring` carry `feature_flag.context.*` attributes, (c) `vision_amplifier_v2` is rolled back to 100% off, and (d) the HTTP 5xx rate over the last minute is below 1%. + +## ๐Ÿงช The story (optional) + +The trial just went wide. Phase 3 of the new vision amplifier โ€” `vision_amplifier_v2` โ€” was approved for the full cohort yesterday morning. The promise was straightforward: subjects emerge with sharper eyesight than they walked in with. By mid-afternoon the audit log was screaming. Subjects were stabilising 200ms slower, and roughly one in ten of them was emerging **blind** โ€” containment failure recorded as an HTTP 500. The lab director pulled up the **Feature Flag Metrics** dashboard expecting to triage visually. The dashboard was dark. Someone had wired up traces but never finished the metrics half. There is no chart to read. The lab is studying eyesight and the lab itself cannot see. + +Your job, in order: **turn on the lights**, find the bad arm of the trial, and **halt enrolment** on the amplifier โ€” all without redeploying the lab. That last constraint is the whole point of feature flags: when a rollout starts misbehaving in production, you need an operational lever that does not take twenty minutes to pull. Save the file, watch the dose drop, watch the 5xx rate fall back to baseline, watch the next batch of subjects walk out seeing. + +## โฐ Deadline + +Coming Soon +> โ„น๏ธ You can still complete the challenge after this date, but points will only +> be awarded for submissions before the deadline. + +## ๐Ÿ“ Solution Walkthrough + +> โš ๏ธ **Spoiler Alert:** The following walkthrough contains the full solution +> to the challenge. We encourage you to try solving it on your own first. +> Consider coming back here only if you get stuck or want to check your +> approach. + +If you get stuck, follow the +[step-by-step solution walkthrough](./solutions/expert.md). + +## ๐Ÿ’ฌ Join the discussion + +Share your solutions and questions in the +[challenge thread](https://community.open-ecosystem.com/c/open-ecosystem-challenges/) +in the Open Ecosystem Community. + +## ๐Ÿ—๏ธ Architecture + +Four containers and one Spring Boot process, all on a shared Docker network. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” OTLP/gRPC :4317 โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Spring Boot โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚ grafana/otel-lgtm โ”‚ +โ”‚ fun-with-flags- โ”‚ flag eval + HTTP โ”‚ - Grafana :3000 โ”‚ +โ”‚ java-spring โ”‚ โ”‚ - Prometheus :9090 โ”‚ +โ”‚ :8080 โ”‚ โ”‚ - Tempo :3200 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ OpenFeature SDK :8013 โ”‚ scrape / pull + โ”‚ (RPC mode) โ”‚ +โ”Œโ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ flagd โ”‚ โ—€โ”€โ”€โ”€โ”€ poll loadgen flag โ”€โ”€โ”‚ k6 loadgen โ”‚ +โ”‚ :8013 (gRPC + HTTP โ”‚ โ”‚ HTTP GET /?userId=โ€ฆ โ”‚ +โ”‚ eval gateway)โ”‚ โ”‚ (the lab interceptor โ”‚ +โ”‚ :8014 management / โ”‚ โ”‚ sets userId as the โ”‚ +โ”‚ metrics โ”‚ โ”‚ targetingKey, which โ”‚ +โ”‚ :8015 sync stream โ”‚ โ”‚ is what fractional โ”‚ +โ”‚ :8016 OFREP โ”‚ โ”‚ rollouts bucket on) โ”‚ +โ”‚ flags.json mounted โ”‚ โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐ŸŽฏ Objective + +By the end of this level, you should have: + +- The OpenTelemetry **meter provider** wired and the OpenFeature **`MetricsHook`** registered +- Verified: the **`SpeciesInterceptor`** carried over from Intermediate is wiring `?userId=` as the OpenFeature **`targetingKey`** on every request, so the `vision_amplifier_v2` fractional rollout buckets per subject rather than landing every request in the same bucket *(you don't write this โ€” verify it via the dashboard's variant-distribution panel after step 5)* +- A **`ContextSpanHook`** of your own โ€” a small `Hook` that copies the merged evaluation context (`species`, `country`, `dose`) onto the active span as `feature_flag.context.` โ€” registered alongside `TracesHook`/`MetricsHook` +- **At least one trace** for service `fun-with-flags-java-spring` visible in Tempo +- Spans tagged with **`feature_flag.context.dose=underdose`** searchable in Tempo and lining up with `feature_flag.variant=clouded` on the same span +- The **`feature_flag_evaluation_requests_total`** counter non-zero in Prometheus +- The **`vision_amplifier_v2`** fractional rollout flipped back to **100% off / 0% on** +- The HTTP 5xx rate over the last minute below **1%** + +## ๐Ÿ“š Concepts you'll touch + +If you came in fresh on OpenTelemetry SDK plumbing or flagd's fractional rule, read this section first. + +### OpenTelemetry **TracerProvider** vs **MeterProvider** + +Spans are per-request timing (one trace per HTTP call, with nested events), counters are aggregate population stats (rate of evaluations across all requests, distribution of variants). In this lab the trace half is wired and Tempo already shows spans; the metrics half is dead and the dashboard is dark โ€” that's the gap you close. + +OTel ships two parallel pipelines, one for **traces** (spans, distributed timing) and one for **metrics** (counters, histograms). Each has its own provider, its own SDK, its own exporter. In this level the `TracerProvider` is already wired (spans are flowing into Tempo). The `MeterProvider` is not โ€” that is your fix. Both providers register globally via `GlobalOpenTelemetry`, so once you wire the meter, the OpenFeature `MetricsHook` finds it without any further plumbing. + +### OpenFeature `TracesHook` and `MetricsHook` + +The OpenFeature OTel contrib library ships two hooks that turn every flag evaluation into telemetry: + +- **`TracesHook`** โ€” emits a span event (`feature_flag.evaluation`) on the active span with `feature_flag.key`, `feature_flag.variant`, and `feature_flag.reason` attributes. This is why flag evaluations show up nested inside HTTP request spans in Tempo. +- **`MetricsHook`** โ€” emits four counters per evaluation: `feature_flag_evaluation_requests_total`, `_success_total`, `_error_total`, and an active-count up/down counter. These power the dashboard panels. + +Both hooks need a global `OpenTelemetry` instance. The `TracesHook` works once you have a `TracerProvider`; the `MetricsHook` needs a `MeterProvider`. + +### Authoring your own hook to enrich spans with context + +The `AuditHook` carried over from Intermediate already records the same context attributes (species / country / dose) into a durable `[AUDIT]` log line โ€” that is the safety officer's tool, useful weeks later for forensic follow-up. What it does not give you is **real-time correlation in the dashboard**: log lines do not show up alongside `feature_flag.variant` on a Tempo span. So `TracesHook` is great at recording **what** happened (the variant, the reason), `AuditHook` records the audit-archive view, and there is still a gap โ€” the evaluation context attributes that drove the decision are not on the span. The two hooks stay; you add a third for the on-call's view. + +The OpenFeature `Hook` interface is the right place to fix that. The shape is roughly: + +```text +before(hookCtx) { + span = active OTel span + for each allowlisted key in merged eval context: + span.setAttribute("feature_flag.context." + key, value) +} +``` + +The `before` hook receives a `HookContext` whose `getCtx()` returns the **merged** evaluation context (global + transaction + invocation), which is exactly what drove the flag's resolution โ€” so the attributes you copy off it line up with what the variant decision actually saw. Span attributes go on `Span.current()` because that is the active HTTP request span; the OpenFeature hook fires inside that span's scope. + +Register it next to `TracesHook` / `MetricsHook` in `OpenFeatureConfig`. Now every flag evaluation tags its parent span with the context attributes the lab cares about. In Tempo: **Search โ†’ Service: fun-with-flags-java-spring โ†’ +Tag โ†’ `feature_flag.context.dose=underdose`** lights up exactly the requests where a tech mis-dosed, with the resolved variant on the same span event. + +The full implementation, including imports and a couple of subtle correctness notes, is in [solutions/expert.md](./solutions/expert.md). + +> โš ๏ธ **Allowlist, don't iterate.** Use a fixed allowlist for the same reason the `AuditHook` does โ€” see [Intermediate's PII note](./intermediate.md#3c-an-audithook) and the [OpenTelemetry security guidance](https://opentelemetry.io/docs/security/). + +### `flagd` `fractional` operation + `targetingKey` + +`fractional` is flagd's bucketing operation. Given a list of `[variant, percent]` pairs, it deterministically assigns each evaluation to one variant based on a hash of the **targeting key** on the evaluation context. Same key โ†’ same bucket โ†’ same variant, every request. Different keys spread across the percentages. **If no targeting key is set, every evaluation hashes the same way and the rollout collapses โ€” every request lands in the same bucket and the percentages do nothing.** + +You already wired this up in Intermediate. The **`SpeciesInterceptor`** you wrote there reads `?userId=...` from each request and constructs an `ImmutableContext(userId, attributes)` โ€” by SDK convention the first `String` argument to `ImmutableContext` **is** the OpenFeature `targetingKey`. Expert ships the same interceptor byte-for-byte; the lab is already serving fractional rollouts correctly without you touching it. (Intermediate didn't have a flag that used the targetingKey; this is where it pays off.) + +The k6 loadgen demonstrates this end-to-end: it generates a fresh random `userId` per request, which means the interceptor produces a different targeting key per request, which means the fractional rollout spreads across the percentages exactly as configured. The dashboard's variant-distribution panel reflects that split directly. + +## ๐Ÿง  What You'll Learn + +- How the OpenFeature OpenTelemetry hooks (`TracesHook` and `MetricsHook`) join + flag evaluations to the rest of an application's telemetry without a + separate ingestion path +- How to **author your own `Hook`** โ€” a tiny class that copies merged-eval-context + attributes onto the active OTel span โ€” to close the loop between *why* a + flag resolved the way it did and *what* the operator sees in Tempo +- How [`fractional`](https://flagd.dev/reference/custom-operations/fractional-operation/) + rollout in flagd buckets users by `targetingKey` โ€” same key, same bucket, every + request โ€” and how to read that bucketing off a dashboard +- How a **flag flip** is a faster operational lever than a redeploy when a + rollout is misbehaving โ€” the difference between a one-line config change and + a twenty-minute deployment + +## ๐Ÿงฐ Toolbox + +Your Codespace comes pre-configured with the following tools: + +- [`curl`](https://curl.se/): HTTP client for hitting the lab, flagd, and Prometheus +- [`./mvnw`](https://maven.apache.org/wrapper/): The Maven wrapper to build and run the Spring Boot lab +- A browser pointed at [`http://localhost:3000`](http://localhost:3000) for Grafana (admin / admin) +- [`jq`](https://jqlang.github.io/jq/): Pretty-print and filter JSON from `curl` + +flagd, the Grafana LGTM stack, and the k6 loadgen are **sibling devcontainer services** โ€” they come up automatically when the Codespace boots. There is no `docker compose up` step. Inside the workspace they are reachable as `flagd`, `lgtm`, and `loadgen`; on the host they are forwarded to the same `localhost:NNNN` ports that `verify.sh` and the docs assume. + +## โœ… How to Play + +### 1. Start Your Challenge + +> ๐Ÿ“– **First time?** Check out the [Getting Started Guide](../../start-a-challenge) +> for detailed instructions on forking, starting a Codespace, and waiting for +> infrastructure setup. + +Quick start: + +- Fork the repo +- Create a Codespace +- Select **"Adventure 00 | ๐Ÿ”ด Expert (Phase 3 โ€” read the chart)"** +- Wait ~2-3 minutes for the sibling containers (flagd, Grafana LGTM, k6 + loadgen) to come up. They are part of the devcontainer compose, so they + start automatically โ€” no `docker compose up` step. +- Once the IDE attaches to the workspace, start the Spring Boot lab. Click + **Run** on `Laboratory` in the Spring Boot Dashboard panel (or press + **F5** with `Laboratory.java` open), or run `./mvnw spring-boot:run` + from the integrated terminal. + +### 2. Access the UIs + +Open the **Ports** tab in the bottom panel and click through to: + +#### Spring Boot lab (Port `8080`) + +The application under test. Open `http://localhost:8080/` to get a vision_state reading +back. Add a `userId` query parameter (e.g. `?userId=subject-42`) to give the +fractional rollout a stable bucketing key. + +#### Grafana (Port `3000`) + +The single window into the LGTM stack. Login is `admin` / `admin` (skip the +"change your password" prompt). + +- **Dashboards โ†’ Fun With Flags โ€” Feature Flag Metrics** โ€” the dashboard the + director keeps reloading. Empty for now. +- **Explore โ†’ Tempo** โ€” search by service `fun-with-flags-java-spring` + to see flag evaluations as span events nested inside HTTP request spans. + Traces work even before you wire up metrics. + +#### Prometheus (Port `9090`) + +Exposed by the LGTM container. Useful for `curl`-driven debugging: +`curl 'http://localhost:9090/api/v1/query?query=feature_flag_evaluation_requests_total'`. + +#### Tempo (Port `3200`) + +Tempo's own HTTP API. The `verify.sh` script uses +`http://localhost:3200/api/search?tags=service.name=fun-with-flags-java-spring` +to assert traces are flowing. + +#### flagd + +flagd is on `:8013` (gRPC eval) โ€” same as Beginner; the other ports (`8014` management/metrics, `8015` sync, `8016` OFREP) aren't used in this level. + +#### OTLP receivers (Ports `4317` / `4318`) + +The Spring Boot app exports traces (and, after you finish the wiring, metrics) +to the LGTM stack on `4317` (gRPC) and `4318` (HTTP). + +### 3. Implement the Objective + +There are three sub-tasks, in order: + +#### 3a. Wire the OpenTelemetry meter provider + +Open +`adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java`. +The `@Bean` method already calls `AutoConfiguredOpenTelemetrySdk.builder()`, +which produces an `OpenTelemetry` instance with **both** a `SdkTracerProvider` +and a `SdkMeterProvider` โ€” but only the tracer provider has an exporter. +The meter provider is told `otel.metrics.exporter=none`, so any metrics it +records go nowhere. + +Flip `otel.metrics.exporter` to `otlp` so the SDK attaches an +`OtlpGrpcMetricExporter`. The cleanest way is to update both the default in +`OpenTelemetryConfig.java` and the value in +`src/main/resources/application.properties`. While you're there, set +`otel.metric.export.interval=10000` so the dashboard updates within ten +seconds of new traffic instead of waiting a minute. + +#### 3b. Register `MetricsHook(OpenTelemetry)` on the OpenFeature API + +Open `OpenFeatureConfig.java`. The `TracesHook` is already registered; +`MetricsHook` is not. `MetricsHook` needs the `OpenTelemetry` instance to grab +the meter provider, so inject the bean via constructor injection and +`api.addHooks(new MetricsHook(openTelemetry));` next to the `TracesHook` call. + +If you compile and run after this step, the **Fun With Flags โ€” Feature Flag +Metrics** dashboard in Grafana stays empty โ€” there is no traffic. Move on. + +#### 3c. Turn on the loadgen, find the bad rollout, roll it back + +Edit `flags.json` in the expert directory and flip `loadgen_active`'s +`defaultVariant` from `"off"` to `"on"`. flagd watches the file and picks up +changes within a second. The k6 loadgen container has been polling +`loadgen_active` every two seconds โ€” it will notice and start hammering +`http://workspace:8080/` with five virtual users (the workspace service name resolves inside the compose network). + +Now open the dashboard. When the loadgen turns on you should see latency creep up around 200ms and 5xx rate around 10%; if those don't move, the loadgen flag isn't actually live yet. + +That's the diagnosis: the fractional rollout for `vision_amplifier_v2` is +inverted. The flag definition currently reads: + +```json +"fractional": [ + ["off", 0], + ["on", 100] +] +``` + +Edit `flags.json` again โ€” flip the percentages so `off` gets `100` and `on` +gets `0`. Save. Within one or two seconds flagd reloads. Because the +`SpeciesInterceptor` is wiring `?userId=` through to the OpenFeature +`targetingKey` on every request, and the loadgen generates a fresh `userId` +per request, the fractional rollout responds immediately โ€” every subject +re-buckets against the new percentages and the population moves to the safe +variant. Watch the latency p99 panel collapse back to baseline and the 5xx +rate fall to zero. + +**No deploy. No rebuild. No restart of the lab.** + +### 4. Verify Your Solution + +Once the dashboard is healthy, run the verifier: + +```bash +adventures/planned/00-blind-by-design/expert/verify.sh +``` + +The script asserts the lab, flagd, and LGTM are reachable, that +`vision_amplifier_v2` evaluates to `false` for a probe user, that the +`feature_flag_evaluation_requests_total` Prometheus counter is non-zero, that +Tempo has at least one trace for `fun-with-flags-java-spring`, and that the +HTTP 5xx rate over the last minute is below 1%. + +If everything turns green, your solution is solid. ๐ŸŽ‰ diff --git a/adventures/planned/00-blind-by-design/docs/index.md b/adventures/planned/00-blind-by-design/docs/index.md new file mode 100644 index 00000000..84c0930b --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/index.md @@ -0,0 +1,48 @@ +# ๐Ÿงช Adventure 00: Blind by Design + +Three levels of OpenFeature with **flagd** as the provider, in a Java + Spring Boot service. Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert) โ€” all without redeploying. + +The entire **infrastructure is pre-provisioned in your Codespace** โ€” no local setup required. + +## ๐Ÿช The Backstory + +OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is **flagd** โ€” it serves flag definitions from a JSON file, locally or remotely, and the OpenFeature SDK in your application calls it on every evaluation. + +In this adventure, the lab uses OpenFeature exactly the way a real engineering team would: a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the targeting rules in `flags.json` decide what reading every subject ends up with. By the end, you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled back a misbehaving Phase 3 trial without redeploying. + +## ๐ŸŽฎ Choose Your Level + +Each level is a standalone challenge with its own Codespace that builds on the story while being technically independent โ€” pick your level and start wherever you feel comfortable. + +### ๐ŸŸข Beginner: Stand up the lab + +- **Status:** ๐Ÿšง Coming Soon +- **Topics:** OpenFeature Java SDK, flagd as a sidecar (`Resolver.RPC`), Spring Boot + +Wire OpenFeature into a Spring Boot service so the lab's `vision_state` reading is resolved by a flagd sidecar against a `flags.json` instead of a hard-coded literal. + +[**Start the Beginner Challenge**](./beginner.md){ .md-button .md-button--primary } + +### ๐ŸŸก Intermediate: Outcome by cohort + +- **Status:** ๐Ÿšง Coming Soon +- **Topics:** OpenFeature targeting, transaction context, hooks, Spring `HandlerInterceptor` + +Add request-scoped context, a global runtime context, an invocation context at the call site, and an audit hook so the lab records the right reading per subject cohort. + +[**Start the Intermediate Challenge**](./intermediate.md){ .md-button .md-button--primary } + +### ๐Ÿ”ด Expert: Phase 3 โ€” read the chart + +- **Status:** ๐Ÿšง Coming Soon +- **Topics:** OpenTelemetry traces + metrics, custom hooks, Grafana LGTM, fractional rollout, OpenFeature OTel hooks + +Finish wiring OpenTelemetry through to the Grafana LGTM stack, write a `ContextSpanHook` that puts the merged eval context onto Tempo spans, find the misbehaving Phase 3 amplifier in the dashboard, and roll it back without redeploying. + +[**Start the Expert Challenge**](./expert.md){ .md-button .md-button--primary } + +## ๐Ÿงช The story (optional) + +A research lab is testing a vision-enhancement serum on volunteers. The **lab** is a Spring Boot service. **OpenFeature** is the chart system. The protocol the lab is following is fixed; what differs per subject is the **`vision_state`** the lab records โ€” `blurry`, `sharp`, `enhanced`, or `clouded` โ€” because subjects don't all arrive with the same biology, the same dose adherence, or the same trial-jurisdiction baseline. + +The flagship Phase 3 trial โ€” a new vision-amplifier algorithm โ€” has started showing trouble: subjects stabilise slower, and roughly one in ten emerge **blind**. The dashboard that should be tracking all of this is dark, because the lab forgot to wire the metric exporter. Your mission across three levels: **stand up the lab**, **read the chart by cohort**, then **turn on the lights and roll back the trial** before more subjects lose their sight. diff --git a/adventures/planned/00-blind-by-design/docs/intermediate.md b/adventures/planned/00-blind-by-design/docs/intermediate.md new file mode 100644 index 00000000..01921048 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/intermediate.md @@ -0,0 +1,235 @@ +# ๐ŸŸก Intermediate: Outcome by cohort + +Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register a custom `Hook`: + +- **Transaction context** (request-scoped) โ€” populated by a Spring `HandlerInterceptor` that reads `?species=` and `?userId=`, and clears on `afterCompletion` so values don't leak across pooled threads. +- **Global context** (process-scoped) โ€” set once at startup from the `COUNTRY` environment variable. +- **Invocation context** (call-site) โ€” passed as a third argument to `client.getStringDetails(...)`, carrying the per-evaluation `dose` attribute. +- **Audit `Hook`** โ€” fires after every flag evaluation, writes an `[AUDIT]` log line with a fixed PII-safe attribute allowlist. + +The broken-state lab already has the SDK and flagd provider wired in `Resolver.RPC` mode. The targeting in `flags.json` already carries three branches โ€” `species == zyklop`, improper-`dose` for non-zyklops, `country == de` โ€” but none of those attributes are in the eval context yet, so every request lands on the default variant. Your job is to make the targeting fire by wiring the three context layers and the audit hook. + +## ๐Ÿงช The story (optional) + +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 into the lab 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**, because subjects don't all start from the same place โ€” some 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 + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Spring Boot lab (this challenge) โ”‚ +โ”‚ โ”‚ +โ”‚ HTTP โ”€โ”€โ–บ SpeciesInterceptor โ”€โ”€โ–บ Trial โ”€โ”€โ–บ OpenFeature client โ”‚ +โ”‚ (transaction ctx: (invocation ctx: (global ctx: โ”‚ +โ”‚ species โ† ?species= dose โ† computed country โ† โ”‚ +โ”‚ targetingKey at call site, $COUNTRY env) โ”‚ +โ”‚ โ† ?userId=) overridable โ”‚ +โ”‚ with ?dose=) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ–ผ โ”‚ +โ”‚ AuditHook โ”‚ +โ”‚ (audit log) โ”‚ +โ”‚ โ”‚ โ”‚ +โ”‚ โ–ผ โ”‚ +โ”‚ FlagdProvider โ”‚ +โ”‚ (Resolver.RPC) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ gRPC :8013 + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ flagd (sibling container) โ”‚ + โ”‚ reads + watches flags.json โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +The lab and a flagd sidecar run as siblings in the devcontainer's compose stack. The OpenFeature client uses `Resolver.RPC` to reach `flagd:8013`; flagd is the one watching `flags.json` and serving evaluations from it. The targeting rules live entirely inside `flags.json`; your job is to make sure the attributes the rules reference (`species`, `country`, `dose`) are populated on every evaluation. + +## ๐ŸŽฏ Objective + +By the end of this level, you should have: + +- A Spring `HandlerInterceptor` that reads `?species=` from each incoming request, sets it on the OpenFeature **transaction context** for the duration of the request, and clears it on completion +- The same interceptor reads `?userId=` and sets it as the OpenFeature **`targetingKey`** โ€” no Intermediate flag uses it yet, but it's the bucketing key for any fractional rollout downstream (Expert's `vision_amplifier_v2` is the obvious one) and it's the canonical PII identifier the AuditHook deliberately won't log +- A **global evaluation context** that carries `country` from the `COUNTRY` environment variable (`System.getenv("COUNTRY")`) the lab was started with +- A `Trial` controller that, on each evaluation, passes the **`dose`** as **invocation context** โ€” `"standard"` most of the time, `"underdose"` or `"overdose"` when the lab tech mis-measures (overridable with `?dose=`) +- A custom `Hook` registered on the OpenFeature API that logs every flag evaluation with the flag key, variant, and reason +- `curl /?species=zyklop` โ†’ `"enhanced"` โ€” zyklop biology dominates regardless of dose or country +- `curl /?dose=standard` โ†’ `"sharp"` (with `COUNTRY=de`) โ€” proper dose, country branch fires +- `curl /?dose=underdose` โ†’ `"clouded"` โ€” improper dosing causes side effects in non-zyklop subjects +- `curl /?species=zyklop&dose=underdose` โ†’ `"enhanced"` โ€” zyklop biology survives bad dosing +- The response is never the literal fallback `"untreated"` +- The application log shows at least one line emitted by your `AuditHook` per request + +> ๐Ÿ“‹ **Run with `tee app.log`.** The verifier greps `[AUDIT]` lines from `app.log` next to `pom.xml`. The `./run-germany.sh` / `./run-austria.sh` scripts handle this for you; if you run `./mvnw spring-boot:run` directly, pipe through `| tee app.log` or the verifier has nothing to grep. + +## ๐Ÿ“š Concepts you'll touch + +- **Spring `HandlerInterceptor`** โ€” per-request hook that runs `preHandle` before your controller and `afterCompletion` after the response. See [Spring's `HandlerInterceptor` Javadoc](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html). +- **Three OpenFeature context layers** โ€” *global* (set once at startup, every request sees it), *transaction* (request-scoped, cleared at `afterCompletion`), *invocation* (passed at the call site). They merge before evaluation; **invocation > transaction > global** on conflict. + The transaction layer needs a **`ThreadLocalTransactionContextPropagator`** registered once on `OpenFeatureAPI` at startup โ€” without it, the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty. +- **`targetingKey`** โ€” a special slot on the eval context that flag implementations use as the bucketing key for fractional rollouts. The SDK exposes it via `ec.getTargetingKey()` rather than `ec.getValue("targetingKey")`; the `ImmutableContext(targetingKey, attributes)` constructor sets it explicitly. In real apps it's typically a stable user id โ€” i.e. the canonical PII identifier you do **not** want flowing into audit logs. +- **`Hook`** โ€” interceptor for flag evaluations. `before`/`after`/`error`/`finallyAfter` fire around every `client.getXxxDetails(...)`. `HookContext.getCtx()` exposes the **merged** context โ€” that's what makes an audit trail useful instead of a "got here" log line. + +For an end-to-end summary of how the three layers fit together once the level is solved, see [solutions/intermediate.md โ†’ Why This Layout Works](./solutions/intermediate.md#-why-this-layout-works). + +## ๐Ÿง  What You'll Learn + +- 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, OpenTelemetry tracing tomorrow โ€” without modifying every flag evaluation call site + +## ๐Ÿงฐ Toolbox + +Your Codespace comes pre-configured with the following tools: + +- [Java 21](https://adoptium.net/) toolchain (Temurin) +- The Spring Boot Maven Wrapper (`./mvnw`) โ€” no global Maven install required +- `curl` and `jq` for poking at the lab +- `tail -f` for watching the application log live + +The flagd sibling that the Beginner level introduced is still running here โ€” the broken-state `OpenFeatureConfig` already targets it via `Resolver.RPC` (`flagd:8013` from the workspace, `localhost:8013` from your host). + +## โฐ Deadline + +> ๐Ÿšง **Coming Soon** โ€” this level is in the planned bucket. Final deadline will be announced when the adventure goes live. + +## ๐Ÿ’ฌ Join the discussion + +> ๐Ÿšง **Coming Soon** โ€” community thread will be linked here at launch. + +## โœ… How to Play + +### 1. Start Your Challenge + +> ๐Ÿ“– **First time?** Check out the [Getting Started Guide](../../start-a-challenge) for detailed instructions on forking, starting a Codespace, and waiting for infrastructure setup. + +Quick start: + +- Fork the repo +- Create a Codespace +- Select "Adventure 00 | ๐ŸŸก Intermediate (Outcome by cohort)" +- Wait ~2-3 minutes for the Java toolchain to install (`Cmd/Ctrl + Shift + P` โ†’ `View Creation Log` to view progress) + +When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in `adventures/planned/00-blind-by-design/intermediate/`. + +### 2. Inspect the Starting Point + +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 sibling. The `flags.json` shipping with this level is the targeting-rich version โ€” all three branches (open `intermediate/flags.json` and you'll see this verbatim): + +```json +"targeting": { + "if": [ + { "===": [{"var": "species"}, "zyklop"] }, "enhanced", + { "in": [{"var": "dose"}, ["underdose", "overdose"]] }, "clouded", + { "===": [{"var": "country"}, "de"] }, "sharp" + ] +} +``` + +The catch: nothing in the application populates `species`, `country`, or `dose` yet. Every request lands with an empty evaluation context, so none of the branches fire and every subject walks out with `"blurry"` (the default variant) โ€” even when they show up as a zyklop. + +Boot the lab as-is to confirm the symptom โ€” either click **Run** on `Laboratory` in the Spring Boot Dashboard panel (or press **F5** with `Laboratory.java` open), or, from the terminal: + +```bash +cd adventures/planned/00-blind-by-design/intermediate +./mvnw spring-boot:run +``` + +In another terminal: + +```bash +curl 'http://localhost:8080/?species=zyklop' +# => {"value":"blurry", ...} โ† wrong cohort, no targeting fired +``` + +Stop the app (`Ctrl+C`) and start fixing. + +### 3. Implement the Objective + +You need three pieces. + +#### 3a. A `SpeciesInterceptor` + +Create a Spring `HandlerInterceptor` that: + +- In `preHandle`, reads both `?species=` and `?userId=` from the request, puts `species` on the **transaction context**, and sets `userId` as the **`targetingKey`**. (See [`ImmutableContext` constructors in the OpenFeature Java SDK](https://openfeature.dev/docs/reference/technologies/server/java/) โ€” there's a constructor that takes the targetingKey explicitly.) +- In `afterCompletion`, clears the transaction context. Servlet threads are pooled โ€” if you don't clear, the previous request's species or targetingKey leaks into whichever request lands on the thread next. +- In a static initialiser, registers a `ThreadLocalTransactionContextPropagator` once on the OpenFeature API. Without it the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty. + +> โ„น๏ธ The Intermediate `verify.sh` doesn't exercise the `?userId=` branch (no Intermediate flag uses `targetingKey`). If you skip that branch, Intermediate still passes โ€” but the Expert level's variant-distribution panel will collapse to a single bucket. The wiring is forward-looking on purpose. + +#### 3b. Wire the interceptor + global context + hook in `OpenFeatureConfig` + +Update `OpenFeatureConfig` to: + +- Register your `SpeciesInterceptor` with Spring (`WebMvcConfigurer.addInterceptors`). +- Read `COUNTRY` from the environment and set it as the **global** evaluation context โ€” merged into every flag evaluation regardless of request. +- Register your `AuditHook` (you'll write that next) globally on the OpenFeature API. + +#### 3c. An `AuditHook` + +Create a `Hook` that, on `after(...)`, reads the merged evaluation context off `HookContext.getCtx()` and writes an `[AUDIT]` log line naming the flag, the resolved variant, the reason, and the attributes that drove the outcome. Two design decisions worth thinking about: + +- When the resolved variant is `clouded`, log at **`WARN`** so the safety officer can grep for it; otherwise `INFO`. Also implement `error(...)` โ€” failed evaluations shouldn't disappear silently. +- Use a **fixed allowlist** of attribute keys, not the whole context. That's what the PII callout below is about. + +> โš ๏ธ **Audit-log PII note.** Use a **fixed allowlist** (`List.of("species", "country", "dose")`) โ€” never iterate the whole eval context. +> +> You just wired `?userId=` as the **targetingKey** in step 3a. That's the canonical example of something that lives on the eval context but does **not** belong in an audit log: it's typically a stable user id, often joins to email and account data, and audit logs are retained longer than app logs and shipped off-host to SIEMs (where redacting after the fact is hard). The allowlist is what keeps the targetingKey out of `[AUDIT]` lines even though `HookContext.getCtx()` can see it. Same discipline the Expert OTel hook will need; see [OpenTelemetry's security guidance](https://opentelemetry.io/docs/security/). + +The order matters less than you'd think โ€” Spring will pick up `OpenFeatureConfig` as a `@Configuration` class on boot, the `@PostConstruct` will run once, and from then on every evaluation the `Trial` performs will see both contexts and trigger your hook. + +### 4. Run the Lab + +```bash +cd adventures/planned/00-blind-by-design/intermediate +./run-germany.sh # COUNTRY=de โ€” exercises the country-targeting branch +``` + +`./run-austria.sh` (`COUNTRY=at`) ships alongside it for the no-targeting case. Three named launch configs in `.vscode/launch.json` (Germany / Austria / No country) give you one-click cohort switching from the **Run and Debug** view. + +### 5. Verify Each Cohort by Hand + +In another terminal โ€” exercise all three context layers and the precedence between them: + +```bash +# Transaction context โ€” species wins, regardless of country / dose +curl -s 'http://localhost:8080/?species=zyklop' | jq .value +# => "enhanced" + +# Global context โ€” country=de from the env. Pin ?dose=standard so the +# random dose pick can't trip the improper-dose branch. +curl -s 'http://localhost:8080/?dose=standard' | jq .value +# => "sharp" (when running ./run-germany.sh โ€” COUNTRY=de) +# => "blurry" (when running ./run-austria.sh โ€” COUNTRY=at: no targeting branch fires, default applies) + +# Invocation context โ€” improper dose for a non-zyklop subject +curl -s 'http://localhost:8080/?dose=underdose' | jq .value +# => "clouded" + +# Precedence โ€” species-zyklop is evaluated before improper-dose in flags.json +curl -s 'http://localhost:8080/?species=zyklop&dose=underdose' | jq .value +# => "enhanced" +``` + +Tail the log to see the audit trail: + +```bash +grep '\[AUDIT\]' app.log | head +``` + +You should see one `[AUDIT] flag=vision_state variant=โ€ฆ reason=โ€ฆ species=โ€ฆ country=โ€ฆ dose=โ€ฆ` line per `curl` call. `clouded` outcomes log at `WARN` with the "improper dosing or off-protocol cohort, follow-up required" suffix. + +### 6. Run the Verification Script + +```bash +adventures/planned/00-blind-by-design/intermediate/verify.sh +``` + +The script checks that the app is reachable, the zyklop and German cohorts return the right values, and the log file contains audit-hook lines. + +> ๐Ÿงช **Spoiler ahead?** A full walkthrough lives in [solutions/intermediate.md](./solutions/intermediate.md). Try it on your own first โ€” the cohorts will thank you. diff --git a/adventures/planned/00-blind-by-design/docs/solutions/beginner.md b/adventures/planned/00-blind-by-design/docs/solutions/beginner.md new file mode 100644 index 00000000..c71f9a15 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/solutions/beginner.md @@ -0,0 +1,177 @@ +# ๐ŸŸข Beginner โ€” Solution Walkthrough: Stand up the lab + +> โš ๏ธ **Spoiler Alert:** This page walks through the full solution. If you want to figure it out yourself, head back to +> the [Beginner challenge](../beginner.md) and only return when you're stuck. + +You are taking a Spring Boot service that returns a hard-coded label and turning it into a lab that reads its +prescription from `flags.json` through OpenFeature. There are four moving parts to get right: the dependencies, the +provider configuration, the flag file, and the controller. Below is the answer key for each. + +## 1. Add the OpenFeature SDK and the flagd provider + +The `pom.xml` you start with has only the Spring starters. Add the OpenFeature SDK and the flagd contrib provider โ€” +these are the two libraries the lab needs to evaluate flags. + +Open `pom.xml` and add the following inside ``: + +```xml + + dev.openfeature + sdk + 1.14.2 + + + dev.openfeature.contrib.providers + flagd + 0.11.8 + +``` + +The first one is the vendor-neutral OpenFeature client โ€” the API you call from your code. The second one is the +**provider**: the piece that knows how to talk to flagd. The SDK is provider-agnostic on purpose; you swap the +provider, your call sites stay the same. + +## 2. Point the FlagdProvider at the flagd sibling + +The provider has to be registered with OpenFeature before any evaluation can happen. Create a new file +`src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java`: + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenFeatureConfig { + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + } +} +``` + +A few things worth noting: + +- `Resolver.RPC` tells the provider to talk to a flagd process over gRPC. The flagd sibling is already running in your + Codespace (look in the **Ports** tab for the `flagd gRPC` row on `:8013`). +- We do **not** hard-code a host or port. The Java flagd provider reads `FLAGD_HOST` / `FLAGD_PORT` from the + environment when no explicit value is set. The devcontainer's compose file pre-sets `FLAGD_HOST=flagd` so the lab + resolves the sibling by service name; running outside the devcontainer falls back to `localhost:8013` via the + published port. +- `setProviderAndWait` blocks until the provider has finished initializing, which means the first request the + controller serves is already wired up. + +> ๐Ÿ’ก The flagd contrib provider supports three resolver modes: +> +> - `RPC` โ€” one gRPC round-trip per evaluation. Simplest wire model, easiest to reason about. +> - `IN_PROCESS` โ€” the SDK opens a gRPC sync stream and the flag definitions stream **into** the JVM. Evaluations +> then happen locally, with no per-call network hop. This is the most common shape in real production deployments +> (flagd as a sidecar) โ€” we lead with `RPC` here only because the wire model is more explicit and easier to +> debug at level 1. Intermediate has a sidebar on flipping to `IN_PROCESS` against the same flagd sibling. +> - `FILE` โ€” read flags.json from local disk, no flagd at all. Useful for tests and local development without a +> sidecar. + +## 3. Author the flag file + +The broken state already ships a `flags.json` next to `pom.xml` โ€” it just has an empty `flags` object so the flagd +sibling has a valid file to mount at boot. Open it and add the `vision_state` flag definition: + +```json +{ + "flags": { + "vision_state": { + "state": "ENABLED", + "variants": { + "blurry": "blurry", + "clouded": "clouded" + }, + "defaultVariant": "blurry" + } + } +} +``` + +Three required fields per flag in flagd: + +- **`state`** โ€” `"ENABLED"` (or `"DISABLED"` to force the SDK fallback). +- **`variants`** โ€” a map from variant name to value. Two variants here give you something to flip in the verification + step. +- **`defaultVariant`** โ€” which variant gets returned when no targeting rules match. There are no rules at this level, + so this is the variant every request gets. + +Save. flagd is watching this file (the devcontainer mounts it read-only into the flagd sibling and tells it to +`start --uri file:.../flags.json`), so the next evaluation already sees the new flag โ€” no flagd restart, no app +restart. + +## 4. Read the chart from the controller + +Update `src/main/java/dev/openfeature/demo/java/demo/Trial.java` so it asks OpenFeature for the reading +instead of returning a literal: + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Trial { + + @GetMapping("/") + public FlagEvaluationDetails observeSubject() { + Client client = OpenFeatureAPI.getInstance().getClient(); + return client.getStringDetails("vision_state", "untreated"); + } +} +``` + +Two intentional choices here: + +- `"untreated"` is the **fallback** value passed to `getStringDetails`. The SDK only returns it if no provider is + registered, the flag is missing, or the flag is disabled. Once your `OpenFeatureConfig` and `flags.json` are in + place, you should never see this value again โ€” and the smoke test asserts exactly that. +- The handler returns `FlagEvaluationDetails` directly, not just the value. Spring will serialize it to JSON + and the response will carry `flagKey`, `value`, `variant`, `reason`, and any error fields โ€” useful for debugging, + required by the smoke test. + +## 5. Run it and verify + +Restart the lab: + +```bash +./mvnw spring-boot:run +``` + +In another terminal: + +```bash +curl -s http://localhost:8080/ | jq +``` + +You should see `"value": "blurry"` and `"flagKey": "vision_state"`. Edit `flags.json`, change +`"defaultVariant": "blurry"` to `"defaultVariant": "clouded"`, save, and `curl` again โ€” the value flips to +`"clouded"` without restarting the app. That's the **flagd container** noticing the file changed on its read-only +mount and serving the new variant on the next gRPC evaluation. Neither the lab nor flagd had to restart; nothing +was redeployed. + +Run the smoke test from the repo root: + +```bash +adventures/planned/00-blind-by-design/beginner/verify.sh +``` + +When all four checks pass, the lab is reading the chart and you're done with the ๐ŸŸข Beginner level. diff --git a/adventures/planned/00-blind-by-design/docs/solutions/expert.md b/adventures/planned/00-blind-by-design/docs/solutions/expert.md new file mode 100644 index 00000000..7b414c4e --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/solutions/expert.md @@ -0,0 +1,280 @@ +# ๐Ÿ”ด Expert Solution Walkthrough: Phase 3 โ€” read the chart + +Four sub-tasks, in order: wire the meter provider, register `MetricsHook`, +write and register a `ContextSpanHook` of your own, roll the bad flag back. +We'll do them exactly that way. + +> โš ๏ธ **Spoiler Alert:** This walkthrough contains the full solution. Try +> solving it on your own first. + +## ๐Ÿ“‹ Step 1: Read the objective + +> By the end of this level, you should have: +> +> - The OpenTelemetry meter provider wired and the OpenFeature `MetricsHook` registered +> - A `ContextSpanHook` of your own that copies the merged evaluation context +> (`species`, `country`, `dose`) onto the active span as `feature_flag.context.` +> - At least one trace for service `fun-with-flags-java-spring` visible in Tempo +> - Spans tagged with `feature_flag.context.dose=underdose` searchable in Tempo +> - The `feature_flag_evaluation_requests_total` counter non-zero in Prometheus +> - The `vision_amplifier_v2` fractional rollout flipped back to 100% off / 0% on +> - HTTP 5xx rate over the last minute below 1% + +## ๐Ÿ” Step 2: Inspect what's already wired + +Traces work out of the box โ€” the `TracesHook` is registered in +`OpenFeatureConfig.java` and the OTel SDK is exporting via OTLP/gRPC to the +LGTM container at `http://localhost:4317`. Open Grafana โ†’ Explore โ†’ Tempo โ†’ +search for `service.name=fun-with-flags-java-spring` and you should already +see traces. (If you don't, hit `curl http://localhost:8080/` a few times to +generate some.) + +The metrics half, however, is dead. Two reasons: + +1. `application.properties` has `otel.metrics.exporter=none`. The SDK creates + a `SdkMeterProvider` but no exporter is attached, so any counter it + records is dropped. +2. `OpenFeatureConfig.initProvider()` registers `TracesHook` but not + `MetricsHook`. Even if the meter provider could export, no one is + recording flag evaluations as metrics. + +One thing that **is** already wired and matters for this level: the +`SpeciesInterceptor` you wrote in Intermediate. Expert ships it byte-for-byte +unchanged. The relevant part for this level is the line you already wrote +that reads `?userId=โ€ฆ` from the query string and constructs +`new ImmutableContext(userId, attributes)` โ€” by SDK convention, the first +`String` argument **is** the OpenFeature `targetingKey`. That is what makes +the `vision_amplifier_v2` fractional rollout actually bucket per subject; +without it, every evaluation would hash the same way and the percentages +would do nothing. (Intermediate didn't have a flag that used the +targetingKey, so the wiring sat dormant; this is where it pays off.) You +don't write any new code for this in Expert โ€” the rollback in Step 6 takes +effect immediately because the loadgen sends a fresh `userId` per request +into the interceptor you already shipped. + +## ๐Ÿ›  Step 3: Wire the meter provider + +Open `src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java`. +Change the default for `otel.metrics.exporter` from `"none"` to `"otlp"`, and +add a default for `otel.metric.export.interval` so the meter flushes every +ten seconds. The full method: + +```java +@Bean +public OpenTelemetry openTelemetry( + @Value("${otel.service.name:fun-with-flags-java-spring}") String serviceName, + @Value("${otel.exporter.otlp.endpoint:http://localhost:4317}") String otlpEndpoint, + @Value("${otel.exporter.otlp.protocol:grpc}") String otlpProtocol, + @Value("${otel.traces.exporter:otlp}") String tracesExporter, + @Value("${otel.metrics.exporter:otlp}") String metricsExporter, + @Value("${otel.logs.exporter:none}") String logsExporter, + @Value("${otel.metric.export.interval:10000}") String metricExportInterval) { + System.setProperty("otel.service.name", serviceName); + System.setProperty("otel.exporter.otlp.endpoint", otlpEndpoint); + System.setProperty("otel.exporter.otlp.protocol", otlpProtocol); + System.setProperty("otel.traces.exporter", tracesExporter); + System.setProperty("otel.metrics.exporter", metricsExporter); + System.setProperty("otel.logs.exporter", logsExporter); + System.setProperty("otel.metric.export.interval", metricExportInterval); + + autoConfigured = AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .build(); + return autoConfigured.getOpenTelemetrySdk(); +} +``` + +Then update `src/main/resources/application.properties` to match: + +```properties +spring.application.name=demo + +otel.exporter.otlp.endpoint=http://localhost:4317 +otel.exporter.otlp.protocol=grpc +otel.traces.exporter=otlp +otel.metrics.exporter=otlp +otel.logs.exporter=none +otel.service.name=fun-with-flags-java-spring +otel.metric.export.interval=10000 +``` + +> The autoconfigure module reads `otel.metrics.exporter` and, when set to +> `otlp`, attaches an `OtlpGrpcMetricExporter` to the `SdkMeterProvider`. The +> resulting `OpenTelemetry` bean now exposes a working `getMeterProvider()`. + +## ๐Ÿ›  Step 4: Register `MetricsHook` on the OpenFeature API + +Open `OpenFeatureConfig.java`. Inject the `OpenTelemetry` bean via +constructor injection and add `MetricsHook` next to the existing +`TracesHook` call: + +```java +import dev.openfeature.contrib.hooks.otel.MetricsHook; +import dev.openfeature.contrib.hooks.otel.TracesHook; +import io.opentelemetry.api.OpenTelemetry; + +@Configuration +public class OpenFeatureConfig implements WebMvcConfigurer { + + private final OpenTelemetry openTelemetry; + + public OpenFeatureConfig(OpenTelemetry openTelemetry) { + this.openTelemetry = openTelemetry; + } + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + + HashMap attributes = new HashMap<>(); + attributes.put("country", new Value(Optional.ofNullable(System.getenv("COUNTRY")).orElse(""))); + api.setEvaluationContext(new ImmutableContext(attributes)); + + api.addHooks(new AuditHook()); // already wired in broken state + api.addHooks(new TracesHook()); // already wired in broken state + api.addHooks(new MetricsHook(openTelemetry)); // <-- you add this + api.addHooks(new ContextSpanHook()); // <-- you add this + } + + // addInterceptors(...) unchanged +} +``` + +### The `ContextSpanHook` + +A small `Hook` of your own, in a new file `ContextSpanHook.java`, that mirrors the merged evaluation context onto the active span. This is what lets Tempo show "this request had `dose=underdose` and got `variant=clouded`" on the same span. + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.Value; +import io.opentelemetry.api.trace.Span; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class ContextSpanHook implements Hook { + + private static final List TRACKED = List.of("species", "country", "dose"); + + @Override + public Optional before(HookContext ctx, Map hints) { + Span span = Span.current(); + EvaluationContext ec = ctx.getCtx(); + for (String key : TRACKED) { + Value v = ec.getValue(key); + if (v != null && v.asString() != null) { + span.setAttribute("feature_flag.context." + key, v.asString()); + } + } + return Hook.super.before(ctx, hints); + } +} +``` + +Three notes worth calling out: + +- `HookContext.getCtx()` returns the **merged** evaluation context โ€” global + transaction + invocation, in that precedence order. So the hook reads whatever the SDK is about to use, regardless of which layer set the value. +- `Span.current()` returns the no-op span if there is no active OTel context (e.g. in tests without an instrumented HTTP server). `setAttribute` on the no-op span is a safe no-op, so the hook does not need defensive guards. +- **`TRACKED` is a fixed allowlist on purpose โ€” do not iterate.** The merged context typically also carries `targetingKey` (often a stable user id) and, in real apps, things like `email`, account ids, or device identifiers. If you replace the allowlist with `for (String key : ec.asMap().keySet())` you ship that PII straight into Tempo / Prometheus, where it is retained for days and is hard to redact after the fact. Pick the minimum set of keys that helps you correlate, document why each is safe for long-term storage, and add new keys deliberately. The OpenTelemetry [security & privacy guidance](https://opentelemetry.io/docs/security/) covers the broader principle. + +Restart the lab: + +```bash +./mvnw spring-boot:run +``` + +After it boots, hit `curl http://localhost:8080/` a few times. Wait ten to +fifteen seconds and check Prometheus: + +```bash +curl -s 'http://localhost:9090/api/v1/query?query=feature_flag_evaluation_requests_total' | jq +``` + +You should see entries with `feature_flag_key` labels for `vision_state`, +`vision_amplifier_v2`, and `loadgen_active`. The dashboard panels in Grafana +will start drawing within the next refresh interval. + +## ๐Ÿ›  Step 5: Turn on the loadgen and read the chart + +Open `flags.json` and flip `loadgen_active`: + +```json +"loadgen_active": { + "state": "ENABLED", + "variants": { "off": false, "on": true }, + "defaultVariant": "on" +} +``` + +Save. The k6 loadgen polls flagd every two seconds and starts hammering. Now +open Grafana โ†’ **Dashboards โ†’ Fun With Flags โ€” Feature Flag Metrics**. +You'll see: + +- **Evaluations per second** โ€” three flag keys, all live +- **Variant distribution** โ€” `vision_amplifier_v2` is heavily skewed toward `on` +- **HTTP latency** โ€” sitting around 200ms, well above baseline +- **HTTP 5xx rate** โ€” around 10% + +## ๐Ÿ›  Step 6: Roll the rollout back + +The fractional bucket for `vision_amplifier_v2` is inverted. Edit `flags.json`: + +```diff + "vision_amplifier_v2": { + "state": "ENABLED", + "variants": { "off": false, "on": true }, + "defaultVariant": "off", + "targeting": { + "fractional": [ +- ["off", 0], +- ["on", 100] ++ ["off", 100], ++ ["on", 0] + ] + } + } +``` + +Save. flagd reloads within a second. The k6 script generates a fresh +`userId` per request, so the next request is immediately bucketed into +`off`. The dashboard panels recover within seconds. + +## โœ… Step 7: Verify + +Run the verifier: + +```bash +adventures/planned/00-blind-by-design/expert/verify.sh +``` + +All eight checks should pass (lab reachable, flagd reachable, LGTM +reachable, `vision_amplifier_v2` rolled back, Prometheus has the metric +counter, Tempo has traces, Tempo spans carry the `feature_flag.context.*` +attribute, 5xx rate below threshold). The 5xx rate check tolerates a brief +tail of errors from before the rollback, but if you wait a minute it +settles to zero. + +## ๐ŸŽ“ What this exercise demonstrates + +- **Decoupling deployment from release.** Once the flag is in place, rolling + out and rolling back happen via a JSON edit, not a redeploy. That is the + same lever you would pull at 3am when the new pricing engine starts + erroring. +- **Stable bucketing via `targetingKey`.** The k6 script generates a fresh + `userId` per request *on purpose* โ€” it lets us see the rollback take + effect immediately. In a real app, the `userId` is the logged-in user, so + the bucketing is sticky across the user's session and the rollback only + helps users who arrive *after* the flag flip. +- **Two halves of OTel observability.** Traces tell you about a specific + request; metrics tell you about the population. The OpenFeature OTel + hooks expose both for flag evaluations using the same OTel SDK the rest of + the app already exports through. diff --git a/adventures/planned/00-blind-by-design/docs/solutions/intermediate.md b/adventures/planned/00-blind-by-design/docs/solutions/intermediate.md new file mode 100644 index 00000000..ade535f2 --- /dev/null +++ b/adventures/planned/00-blind-by-design/docs/solutions/intermediate.md @@ -0,0 +1,276 @@ +# ๐ŸŸก Intermediate Solution Walkthrough: Outcome by cohort + +This walkthrough shows the target shape of the lab after the level is solved. We'll build it the way a clinical engineer would โ€” read the objective, then drop in each piece in the order the OpenFeature SDK expects it. + +> โš ๏ธ **Spoiler Alert:** The full solution is below. Try the level on your own first. + +## ๐Ÿ“‹ Step 1: Recap the Objective + +You need four pieces of code wired together: + +1. A `SpeciesInterceptor` that captures the `?species=` query parameter into the OpenFeature **transaction context** for the duration of the request. +2. An `AuditHook` that records every flag evaluation with the cohort attributes that drove it. +3. An updated `OpenFeatureConfig` that registers the interceptor, reads `COUNTRY` from the environment and sets it on the **global** evaluation context, and registers the audit hook. +4. An updated `Trial` controller that accepts `?dose=` and passes a `dose` attribute as **invocation context** at the call site of `client.getStringDetails(...)`. + +The flag definition in `flags.json` is already targeting-rich โ€” `species == zyklop`, the improper-`dose` branch, and the `country == de` branch are all in place. + +## ๐Ÿงฉ Step 2: The `SpeciesInterceptor` + +Create `src/main/java/dev/openfeature/demo/java/demo/SpeciesInterceptor.java`: + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; +import dev.openfeature.sdk.Value; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.HashMap; + +public class SpeciesInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String species = request.getParameter("species"); + String userId = request.getParameter("userId"); + HashMap attributes = new HashMap<>(); + if (species != null) { + attributes.put("species", new Value(species)); + } + ImmutableContext evaluationContext = userId != null + ? new ImmutableContext(userId, attributes) + : new ImmutableContext(attributes); + OpenFeatureAPI.getInstance().setTransactionContext(evaluationContext); + return HandlerInterceptor.super.preHandle(request, response, handler); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext()); + HandlerInterceptor.super.afterCompletion(request, response, handler, ex); + } + + static { + OpenFeatureAPI.getInstance().setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); + } +} +``` + +A few details worth calling out: + +- The static initialiser registers a `ThreadLocalTransactionContextPropagator` on the API. Without it the SDK has no way to carry per-request context across the call into the controller โ€” the transaction context would silently be empty. +- `afterCompletion` clears the context. Servlet container threads are pooled, so leaving the previous request's species or `targetingKey` on the thread would leak it into the *next* request unlucky enough to land on the same thread. +- The `ImmutableContext(targetingKey, attributes)` constructor is the explicit way to set the targetingKey alongside other attributes; the `ImmutableContext(attributes)` overload leaves it unset. We branch on whether `userId` is present so a missing `?userId=` doesn't poison the context with a `null` targetingKey. +- No Intermediate flag uses the targetingKey yet โ€” Intermediate's `vision_state` targets attributes, not a fractional bucket. The wiring is forward-looking: Expert's `vision_amplifier_v2` is a fractional rollout that buckets on `targetingKey`, so this interceptor is the same one Expert ships, byte for byte. + +## ๐Ÿงฉ Step 3: The `AuditHook` + +Create `src/main/java/dev/openfeature/demo/java/demo/AuditHook.java`. The lab director wants an audit trail: every evaluation logged with the cohort attributes that drove the outcome, and a warning when a subject's reading comes back `clouded` (improper dosing, the safety officer needs to follow up): + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; + +public class AuditHook implements Hook { + private static final Logger LOG = LoggerFactory.getLogger(AuditHook.class); + + /** Allowlist of context attributes that are safe to drop into the audit log. */ + private static final List AUDITED = List.of("species", "country", "dose"); + + @Override + public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { + StringBuilder ctxLine = new StringBuilder(); + EvaluationContext ec = ctx.getCtx(); + for (String key : AUDITED) { + Value v = ec != null ? ec.getValue(key) : null; + ctxLine.append(' ').append(key).append('=').append(v != null ? v.asString() : "(absent)"); + } + String message = String.format("[AUDIT] flag=%s variant=%s reason=%s%s", + ctx.getFlagKey(), details.getVariant(), details.getReason(), ctxLine); + + if ("clouded".equals(details.getVariant())) { + LOG.warn("{} -- improper dosing or off-protocol cohort, follow-up required", message); + } else { + LOG.info("{}", message); + } + } + + @Override + public void error(HookContext ctx, Exception err, Map hints) { + LOG.warn("[AUDIT] flag evaluation error flag={} err={}", ctx.getFlagKey(), err.toString()); + } +} +``` + +Two things worth pinning down: + +- The hook reads from `HookContext.getCtx()` โ€” the **merged** context the SDK was about to evaluate against. So whether the attribute came from the global eval context (`country`), the transaction context (`species` via `SpeciesInterceptor`), or the invocation context (`dose` from the controller call site), the audit line sees it. +- `AUDITED` is a **fixed allowlist** on purpose. Audit logs are usually retained longer than application logs and are often shipped to a SIEM. Don't iterate over the whole context โ€” `targetingKey` and other PII routinely sit there in real apps. Same discipline that the Expert level's OTel hook needs, just with weaker retention. The OpenTelemetry [security & privacy guidance](https://opentelemetry.io/docs/security/) says it best. + +What you trade up to in the Expert level: the same `Hook` shape but the output goes onto OpenTelemetry spans instead of a log file, so the dashboard can correlate variants with context attrs in real time. + +## ๐Ÿงฉ Step 4: Update `OpenFeatureConfig` + +Replace `src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java` with: + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.HashMap; +import java.util.Optional; + +@Configuration +public class OpenFeatureConfig implements WebMvcConfigurer { + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + + // Read the trial's country of registration from the environment. + // Empty string when unset โ€” flagd's `===` operator simply won't match, + // and the default variant wins. + String country = Optional.ofNullable(System.getenv("COUNTRY")).orElse(""); + HashMap attributes = new HashMap<>(); + attributes.put("country", new Value(country)); + ImmutableContext evaluationContext = new ImmutableContext(attributes); + api.setEvaluationContext(evaluationContext); + + api.addHooks(new AuditHook()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new SpeciesInterceptor()); + } +} +``` + +What changed compared to the broken-state file: + +- The class now `implements WebMvcConfigurer` and overrides `addInterceptors` to register `SpeciesInterceptor`. Spring picks this up automatically because the class is a `@Configuration`. +- The stale `offlineFlagSourcePath("./flags.json")` line on `FlagdOptions` is gone. With `Resolver.RPC` the SDK ignores it anyway โ€” the flagd sibling reads `flags.json` itself; the SDK only talks to flagd over gRPC. Drop it for clarity. +- After `setProviderAndWait`, we read `System.getenv("COUNTRY")`, build a one-attribute `ImmutableContext` with `country` set to that value, and call `api.setEvaluationContext(...)`. This context merges into every evaluation regardless of request. +- We call `api.addHooks(new AuditHook())` to register the audit hook on every evaluation. + +## ๐Ÿงฉ Step 5: Update `Trial` to pass `dose` as invocation context + +This is the third (and last) of the three eval-context layers โ€” and it's the one that has to live at the **call site**, not in a Spring filter or a `@PostConstruct`. The dose is observational: most subjects absorb the standard dose, but a measurable fraction end up underdosed or overdosed (missed doses, fast metabolisers, the usual reasons), and that's known only at the moment the lab takes the reading. Replace `src/main/java/dev/openfeature/demo/java/demo/Trial.java` with: + +```java +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.concurrent.ThreadLocalRandom; + +@RestController +public class Trial { + + @GetMapping("/") + public FlagEvaluationDetails observeSubject(@RequestParam(required = false) String dose) { + Client client = OpenFeatureAPI.getInstance().getClient(); + + // The dose this subject actually absorbed. Caller can pin it via ?dose= + // (handy for testing); otherwise we sample one from the typical + // adherence distribution we see in this lab. + String resolvedDose = (dose != null) ? dose : pickDose(); + HashMap invocationCtx = new HashMap<>(); + invocationCtx.put("dose", new Value(resolvedDose)); + + return client.getStringDetails( + "vision_state", + "untreated", + new ImmutableContext(invocationCtx)); + } + + private static String pickDose() { + double r = ThreadLocalRandom.current().nextDouble(); + if (r < 0.60) return "standard"; + if (r < 0.90) return "underdose"; + return "overdose"; + } +} +``` + +Three things worth pinning down: + +- The `dose` attribute is **observational, not prescriptive**. The lab's protocol calls for a `"standard"` dose every time; what varies per subject is what their body actually ended up with. The targeting branch in `flags.json` reads "if the dose came back underdose or overdose for a non-zyklop, the reading is `clouded`." +- `getStringDetails(...)` takes the invocation `EvaluationContext` as the **third argument**. The SDK merges it on top of the global context (`country`) and the transaction context (`species` from `SpeciesInterceptor`); on conflict, invocation wins. None of those layers conflict in this level โ€” they each carry a different attribute name. +- Returning `FlagEvaluationDetails` (rather than just `details.getValue()`) keeps the response body verbose: flag key, value, variant, reason. The verifier and your own debugging both lean on those fields. + +## โœ… Step 6: Verify + +Boot the lab. The level ships two convenience scripts that pre-set `COUNTRY` and pipe to `app.log`: + +```bash +./run-germany.sh # COUNTRY=de +# or +./run-austria.sh # COUNTRY=at +``` + +Once you've made the changes above, the four curl cases in [the participant doc's verify-by-hand section](../intermediate.md#5-verify-each-cohort-by-hand) should now resolve as documented โ€” with explicit log lines from your `AuditHook`. + +Then check the audit trail: + +```bash +grep '\[AUDIT\]' app.log | head +``` + +You should see one `[AUDIT] flag=vision_state variant=โ€ฆ reason=โ€ฆ species=โ€ฆ country=โ€ฆ dose=โ€ฆ` line per evaluation, and `WARN`-level lines for any `clouded` outcome with the "improper dosing or off-protocol cohort, follow-up required" suffix. + +Run the verification script: + +```bash +adventures/planned/00-blind-by-design/intermediate/verify.sh +``` + +If everything passes, every cohort lands on the right reading and the audit log is recording the cohort attributes that drove each one. + +## ๐Ÿง  Why This Layout Works + +- **Transaction context** is the right home for the subject's species because it's per-request and must not survive into the next request. The `ThreadLocalTransactionContextPropagator` is what makes the SDK pick up that per-thread state on every evaluation. +- **Global evaluation context** is the right home for the trial's country because it's a property of the lab instance itself, not the subject. Setting it once at boot is correct, and reading it from `COUNTRY` in the environment lets the same image serve different trials without rebuilding. +- **Invocation context** is the right home for the dose because it's known only at the moment the lab takes the reading โ€” not on the request, not at startup. Passing it at the call site keeps the controller in charge of attributes whose value the controller is the only one to know. +- **Hooks** are registered globally on the API, so every flag evaluation everywhere in the app picks them up โ€” no need to thread the audit logger through every controller. +- **`targetingKey`** lives on the transaction context too, set from `?userId=`. No Intermediate flag uses it, but it's the bucketing key for any fractional rollout further on โ€” and it's the canonical PII identifier that the audit allowlist deliberately keeps out of `[AUDIT]` lines. + +That separation is the whole reason OpenFeature ships a vendor-neutral context model. The same code reads cleanly whether the provider is flagd in `Resolver.RPC` mode (this level) or `Resolver.IN_PROCESS` mode against the same flagd sibling โ€” for the resolver-modes overview, see [solutions/beginner.md](./beginner.md). diff --git a/adventures/planned/00-blind-by-design/expert/.mvn/wrapper/maven-wrapper.properties b/adventures/planned/00-blind-by-design/expert/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..3ee7848f --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/adventures/planned/00-blind-by-design/expert/.vscode/launch.json b/adventures/planned/00-blind-by-design/expert/.vscode/launch.json new file mode 100644 index 00000000..5c0005f5 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "๐Ÿงช Run the Phase 3 Lab", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/adventures/planned/00-blind-by-design/expert/.vscode/tasks.json b/adventures/planned/00-blind-by-design/expert/.vscode/tasks.json new file mode 100644 index 00000000..1d483f30 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/.vscode/tasks.json @@ -0,0 +1,14 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "๐Ÿงช Verify Solution", + "type": "shell", + "command": "./verify.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "group": { "kind": "test", "isDefault": true } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/expert/dashboards/feature-flags.json b/adventures/planned/00-blind-by-design/expert/dashboards/feature-flags.json new file mode 100644 index 00000000..a293ce92 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/dashboards/feature-flags.json @@ -0,0 +1,135 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 10 }, "unit": "ops" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "id": 1, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (feature_flag_key) (rate(feature_flag_evaluation_requests_total[1m]))", + "legendFormat": "{{feature_flag_key}}", + "refId": "A" + } + ], + "title": "Flag evaluations per second (by flag)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "id": 2, + "options": { "legend": { "displayMode": "table", "placement": "right" }, "pieType": "donut", "reduceOptions": { "calcs": ["lastNotNull"], "values": false } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (feature_flag_variant) (increase(feature_flag_evaluation_success_total[5m]))", + "legendFormat": "{{feature_flag_variant}}", + "refId": "A" + } + ], + "title": "Variant distribution (last 5m)", + "type": "piechart" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 0.01 } ] }, "unit": "ops" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "id": 3, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (feature_flag_key, error_type) (rate(feature_flag_evaluation_error_total[1m]))", + "legendFormat": "{{feature_flag_key}} ({{error_type}})", + "refId": "A" + } + ], + "title": "Evaluation errors per second", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "unit": "short" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "id": 4, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (service_name) (rate(feature_flag_evaluation_requests_total[1m]))", + "legendFormat": "{{service_name}}", + "refId": "A" + } + ], + "title": "Evaluations per service (rate)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Step 7 โ€” HTTP request latency p99 from OTel auto-instrumentation. Watch this rise when a slow rollout cohort gets bigger.", + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "drawStyle": "line", "lineWidth": 2, "fillOpacity": 5 }, "unit": "s" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, + "id": 5, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.99, sum by (le, service_name) (rate(http_server_request_duration_seconds_bucket[1m])))", + "legendFormat": "p99 {{service_name}}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(0.50, sum by (le, service_name) (rate(http_server_request_duration_seconds_bucket[1m])))", + "legendFormat": "p50 {{service_name}}", + "refId": "B" + } + ], + "title": "HTTP request latency (p50, p99)", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "description": "Step 7 โ€” HTTP 5xx rate. Watch this jump when the new code path's error injection kicks in.", + "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.1 }, { "color": "red", "value": 1 } ] }, "unit": "ops" }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "id": 6, + "options": { "legend": { "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi" } }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "sum by (service_name) (rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[1m]))", + "legendFormat": "{{service_name}}", + "refId": "A" + } + ], + "title": "HTTP 5xx per second", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["openfeature", "feature-flags"], + "templating": { "list": [] }, + "time": { "from": "now-15m", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "Fun With Flags โ€” Feature Flag Metrics", + "uid": "fun-with-flags-metrics", + "version": 1, + "weekStart": "" +} diff --git a/adventures/planned/00-blind-by-design/expert/flags.json b/adventures/planned/00-blind-by-design/expert/flags.json new file mode 100644 index 00000000..4ccfb246 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/flags.json @@ -0,0 +1,46 @@ +{ + "flags": { + "vision_state": { + "state": "ENABLED", + "variants": { + "enhanced": "enhanced", + "sharp": "sharp", + "blurry": "blurry", + "clouded": "clouded" + }, + "defaultVariant": "blurry", + "targeting": { + "if": [ + { "===": [{ "var": "species" }, "zyklop"] }, + "enhanced", + { "in": [{ "var": "dose" }, ["underdose", "overdose"]] }, + "clouded", + { "===": [{ "var": "country" }, "de"] }, + "sharp" + ] + } + }, + "vision_amplifier_v2": { + "state": "ENABLED", + "variants": { + "off": false, + "on": true + }, + "defaultVariant": "off", + "targeting": { + "fractional": [ + ["off", 0], + ["on", 100] + ] + } + }, + "loadgen_active": { + "state": "ENABLED", + "variants": { + "off": false, + "on": true + }, + "defaultVariant": "off" + } + } +} diff --git a/adventures/planned/00-blind-by-design/expert/loadgen/k6/script.js b/adventures/planned/00-blind-by-design/expert/loadgen/k6/script.js new file mode 100644 index 00000000..bd648a77 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/loadgen/k6/script.js @@ -0,0 +1,63 @@ +// k6 script that hits the demo's GET / with random species values, but only +// when the OpenFeature flag `loadgen_active` is true. Flip the flag in the +// running flagd's flags.json (defaultVariant: "off" โ†’ "on") and the script +// starts hammering within seconds. Flip it back and it goes idle. +// +// The script targets one app instance via BASE_URL โ€” point it at :8080 of +// whichever folder you're running. FLAGD_URL is flagd's eval endpoint on +// :8013 (the gRPC port also serves HTTP/JSON via gRPC-Gateway, so a plain +// curl-style POST works against the same port the SDK uses). + +import http from 'k6/http'; +import { sleep } from 'k6'; + +export const options = { + vus: 5, // five virtual users; modest load, dashboard stays readable + duration: '24h', // run forever โ€” toggle the flag to start/stop traffic +}; + +const BASE_URL = __ENV.BASE_URL || 'http://host.docker.internal:8080'; +const FLAGD_URL = __ENV.FLAGD_URL || 'http://host.docker.internal:8013'; + +// Pool of subject species. Empty string means "no query parameter" โ€” exercises +// the country-fallback or default branch. The mix is deliberately uneven so the +// variant distribution panel in Grafana looks like real traffic, not a flat split. +const SPECIES = ['zyklop', 'zyklop', 'human', 'human', 'human', 'orc', 'elf', 'goblin', '']; + +// Generate a random user id per request. The Phase 3 `vision_amplifier_v2` flag +// uses a fractional rollout that buckets on the OpenFeature targetingKey, so +// without a stable per-request id every request would land in the same bucket. +function randomUserId() { + return `user-${Math.floor(Math.random() * 100000)}`; +} + +function isLoadgenActive() { + const res = http.post( + `${FLAGD_URL}/flagd.evaluation.v1.Service/ResolveBoolean`, + JSON.stringify({ flagKey: 'loadgen_active', context: {} }), + { headers: { 'Content-Type': 'application/json' }, timeout: '2s' }, + ); + if (res.status !== 200) return false; + try { + return JSON.parse(res.body).value === true; + } catch { + return false; + } +} + +export default function () { + if (!isLoadgenActive()) { + // Flag is off โ€” idle gently. Two seconds is short enough to feel responsive + // when the flag flips on, long enough not to thrash flagd. + sleep(2); + return; + } + + const species = SPECIES[Math.floor(Math.random() * SPECIES.length)]; + const userId = randomUserId(); + const params = [`userId=${userId}`]; + if (species) params.push(`species=${species}`); + const url = `${BASE_URL}/?${params.join('&')}`; + http.get(url, { tags: { species: species || 'default' } }); + sleep(0.1); +} diff --git a/adventures/planned/00-blind-by-design/expert/mvnw b/adventures/planned/00-blind-by-design/expert/mvnw new file mode 100755 index 00000000..9b14e061 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/adventures/planned/00-blind-by-design/expert/mvnw.cmd b/adventures/planned/00-blind-by-design/expert/mvnw.cmd new file mode 100644 index 00000000..155e00b9 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/adventures/planned/00-blind-by-design/expert/pom.xml b/adventures/planned/00-blind-by-design/expert/pom.xml new file mode 100644 index 00000000..69455a5b --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/pom.xml @@ -0,0 +1,101 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + dev.openfeature.demo.java + demo + 0.0.1-SNAPSHOT + demo + Blind by Design - Expert: pharma trial dispenser + + 21 + 1.48.0 + 2.14.0 + + + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-bom + ${opentelemetry.instrumentation.version} + pom + import + + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + + dev.openfeature + sdk + 1.14.2 + + + dev.openfeature.contrib.providers + flagd + 0.11.8 + + + + + dev.openfeature.contrib.hooks + otel + 3.2.1 + + + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + + + io.opentelemetry + opentelemetry-exporter-otlp + + + io.opentelemetry + opentelemetry-sdk-extension-autoconfigure + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/AuditHook.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/AuditHook.java new file mode 100644 index 00000000..ad1ce2a4 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/AuditHook.java @@ -0,0 +1,53 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.EvaluationContext; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.Hook; +import dev.openfeature.sdk.HookContext; +import dev.openfeature.sdk.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; + +/** + * Audit-log hook carried over from the Intermediate level. Writes one line + * per evaluation tagged {@code [AUDIT]}, with the cohort attributes the lab + * director cares about. Variants of {@code clouded} log at {@code WARN} so + * the safety officer can grep for improper-dosing follow-ups. + * + *

This is the durable, weeks-from-now archive view. The Phase 3 task adds + * a {@code ContextSpanHook} for real-time correlation in Tempo โ€” both hooks + * stay registered, they just serve different downstreams.

+ */ +public class AuditHook implements Hook { + + private static final Logger LOG = LoggerFactory.getLogger(AuditHook.class); + + /** Allowlist of context attributes safe to drop into the audit log. */ + private static final List AUDITED = List.of("species", "country", "dose"); + + @Override + public void after(HookContext ctx, FlagEvaluationDetails details, Map hints) { + StringBuilder ctxLine = new StringBuilder(); + EvaluationContext ec = ctx.getCtx(); + for (String key : AUDITED) { + Value v = ec != null ? ec.getValue(key) : null; + ctxLine.append(' ').append(key).append('=').append(v != null ? v.asString() : "(absent)"); + } + String message = String.format("[AUDIT] flag=%s variant=%s reason=%s%s", + ctx.getFlagKey(), details.getVariant(), details.getReason(), ctxLine); + + if ("clouded".equals(details.getVariant())) { + LOG.warn("{} -- improper dosing or off-protocol cohort, follow-up required", message); + } else { + LOG.info("{}", message); + } + } + + @Override + public void error(HookContext ctx, Exception err, Map hints) { + LOG.warn("[AUDIT] flag evaluation error flag={} err={}", ctx.getFlagKey(), err.toString()); + } +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java new file mode 100644 index 00000000..33c27c39 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java @@ -0,0 +1,13 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Laboratory { + + public static void main(String[] args) { + SpringApplication.run(Laboratory.class, args); + } + +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java new file mode 100644 index 00000000..361a7005 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java @@ -0,0 +1,74 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.contrib.hooks.otel.TracesHook; +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.HashMap; +import java.util.Optional; + +/** + * Wires the OpenFeature client to a remote flagd container ({@code Resolver.RPC}, + * default host {@code localhost:8013}) and registers the cross-cutting hooks. + * + *

Half-wired on purpose: the {@link TracesHook} reads the current span from + * the global tracer provider, so flag evaluations show up in Tempo as soon as + * the OpenTelemetry SDK is initialized. The matching {@code MetricsHook} is NOT + * registered here โ€” the meter provider is not exporting yet and the + * "Fun With Flags" dashboard panels in Grafana stay dark. Finishing the wiring + * is the participant's first task in this level.

+ */ +@Configuration +public class OpenFeatureConfig implements WebMvcConfigurer { + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + + String country = Optional.ofNullable(System.getenv("COUNTRY")).orElse(""); + HashMap attributes = new HashMap<>(); + attributes.put("country", new Value(country)); + ImmutableContext evaluationContext = new ImmutableContext(attributes); + api.setEvaluationContext(evaluationContext); + + api.addHooks(new AuditHook()); + api.addHooks(new TracesHook()); + // TODO Phase 3 task #1: register the matching MetricsHook here once + // the meter provider has been wired up in OpenTelemetryConfig. Without + // it the Grafana feature-flag dashboard cannot draw its panels. + // + // TODO Phase 3 task #2: write a small ContextSpanHook that copies the + // merged evaluation context attributes (species, country, dose) onto the + // active OpenTelemetry span โ€” for example as + // `feature_flag.context.` โ€” and register it here. Lets you search + // Tempo for `feature_flag.context.dose=underdose` and see, on the same + // span, which `feature_flag.variant` the lab recorded. Closes the + // loop between why an outcome happened and what the chart knew at + // the time. + // + // โš ๏ธ Use a fixed allowlist of keys; do NOT iterate over the whole + // evaluation context. The merged context routinely carries the + // OpenFeature targetingKey (often a user id) and, in real apps, things + // like email or account identifiers โ€” span attributes are retained + // for days in Tempo/Prometheus and are hard to redact after the fact. + // See https://opentelemetry.io/docs/security/ for the broader rule. + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new SpeciesInterceptor()); + } +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java new file mode 100644 index 00000000..80f21a47 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/OpenTelemetryConfig.java @@ -0,0 +1,73 @@ +package dev.openfeature.demo.java.demo; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Half-wired OpenTelemetry SDK. + * + *

Traces ARE exported to the LGTM stack via OTLP/gRPC at + * {@code http://localhost:4317}. The {@code TracesHook} registered in + * {@link OpenFeatureConfig} attaches every flag evaluation as a span event + * inside the active HTTP request span โ€” open Grafana โ†’ Explore โ†’ Tempo and + * search for service {@code fun-with-flags-java-spring} to see them.

+ * + *

Metrics are NOT exported yet. The autoconfigure module is told + * {@code otel.metrics.exporter=none}, which means the {@code SdkMeterProvider} + * either is not created or has no exporter attached, so the Grafana + * "Fun With Flags โ€” Feature Flag Metrics" dashboard stays empty. To finish + * Phase 3 the participant must:

+ * + *
    + *
  1. Switch {@code otel.metrics.exporter} to {@code otlp} and set a + * reasonable {@code otel.metric.export.interval} so Mimir receives + * evaluation metrics.
  2. + *
  3. Register the matching + * {@code dev.openfeature.contrib.hooks.otel.MetricsHook} on the + * OpenFeature API in {@link OpenFeatureConfig#initProvider()}.
  4. + *
+ */ +@Configuration +public class OpenTelemetryConfig { + + private AutoConfiguredOpenTelemetrySdk autoConfigured; + + @Bean + public OpenTelemetry openTelemetry( + @Value("${otel.service.name:fun-with-flags-java-spring}") String serviceName, + @Value("${otel.exporter.otlp.endpoint:http://localhost:4317}") String otlpEndpoint, + @Value("${otel.exporter.otlp.protocol:grpc}") String otlpProtocol, + @Value("${otel.traces.exporter:otlp}") String tracesExporter, + // Phase 3 TODO: flip this to "otlp" so the meter provider exports. + @Value("${otel.metrics.exporter:none}") String metricsExporter, + @Value("${otel.logs.exporter:none}") String logsExporter) { + // Expose configured values via system properties so the SDK + // autoconfigure module picks them up regardless of how the app + // was launched. + System.setProperty("otel.service.name", serviceName); + System.setProperty("otel.exporter.otlp.endpoint", otlpEndpoint); + System.setProperty("otel.exporter.otlp.protocol", otlpProtocol); + System.setProperty("otel.traces.exporter", tracesExporter); + System.setProperty("otel.metrics.exporter", metricsExporter); + System.setProperty("otel.logs.exporter", logsExporter); + // Phase 3 TODO: once metrics are flipped on, surface a sensible + // export interval here, e.g. 10000 ms, so the dashboard updates + // within ten seconds of new traffic. + + autoConfigured = AutoConfiguredOpenTelemetrySdk.builder() + .setResultAsGlobal() + .build(); + return autoConfigured.getOpenTelemetrySdk(); + } + + @PreDestroy + public void shutdown() { + if (autoConfigured != null) { + autoConfigured.getOpenTelemetrySdk().close(); + } + } +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/SpeciesInterceptor.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/SpeciesInterceptor.java new file mode 100644 index 00000000..a1020ebe --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/SpeciesInterceptor.java @@ -0,0 +1,45 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.ThreadLocalTransactionContextPropagator; +import dev.openfeature.sdk.Value; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.HashMap; + +/** + * Per-request OpenFeature transaction context. Reads {@code species} (drives the + * species targeting branch on {@code vision_state}) and {@code userId} (used as + * the OpenFeature targetingKey, so the fractional rollout on + * {@code vision_amplifier_v2} is sticky per caller). + */ +public class SpeciesInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String species = request.getParameter("species"); + String userId = request.getParameter("userId"); + HashMap attributes = new HashMap<>(); + if (species != null) { + attributes.put("species", new Value(species)); + } + ImmutableContext evaluationContext = userId != null + ? new ImmutableContext(userId, attributes) + : new ImmutableContext(attributes); + OpenFeatureAPI.getInstance().setTransactionContext(evaluationContext); + return HandlerInterceptor.super.preHandle(request, response, handler); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + OpenFeatureAPI.getInstance().setTransactionContext(new ImmutableContext()); + HandlerInterceptor.super.afterCompletion(request, response, handler, ex); + } + + static { + OpenFeatureAPI.getInstance().setTransactionContextPropagator(new ThreadLocalTransactionContextPropagator()); + } +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Trial.java b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Trial.java new file mode 100644 index 00000000..f5c79d1d --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/java/dev/openfeature/demo/java/demo/Trial.java @@ -0,0 +1,59 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.ImmutableContext; +import dev.openfeature.sdk.OpenFeatureAPI; +import dev.openfeature.sdk.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Phase 3 lab. Reads the {@code vision_amplifier_v2} flag and, when the + * fractional rollout puts the caller into the {@code on} bucket, executes the + * deliberately bad new formulation: 200ms slower, 10% chance of a 5xx. The + * baseline {@code vision_state} flag still drives the response body. + * + *

Each evaluation also passes a {@code dose} attribute as invocation + * context โ€” the fraction of clinical staff who under- or over-dose + * subjects shows up here. Most subjects get {@code "standard"}, the rest get + * {@code "underdose"} or {@code "overdose"}, both of which override the cohort + * targeting and yield {@code clouded}.

+ */ +@RestController +public class Trial { + + @GetMapping("/") + public ResponseEntity observeSubject(@RequestParam(required = false) String dose) { + Client client = OpenFeatureAPI.getInstance().getClient(); + boolean newAlgo = client.getBooleanValue("vision_amplifier_v2", false); + if (newAlgo) { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (ThreadLocalRandom.current().nextDouble() < 0.1) { + return ResponseEntity.status(500).body("simulated failure in vision_amplifier_v2"); + } + } + + String resolvedDose = (dose != null) ? dose : pickDose(); + HashMap invocationCtx = new HashMap<>(); + invocationCtx.put("dose", new Value(resolvedDose)); + + return ResponseEntity.ok( + client.getStringDetails("vision_state", "untreated", new ImmutableContext(invocationCtx))); + } + + private static String pickDose() { + double r = ThreadLocalRandom.current().nextDouble(); + if (r < 0.60) return "standard"; + if (r < 0.90) return "underdose"; + return "overdose"; + } +} diff --git a/adventures/planned/00-blind-by-design/expert/src/main/resources/application.properties b/adventures/planned/00-blind-by-design/expert/src/main/resources/application.properties new file mode 100644 index 00000000..186c82e1 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/src/main/resources/application.properties @@ -0,0 +1,11 @@ +spring.application.name=demo + +# Phase 3 OpenTelemetry configuration (OTLP gRPC exporter to the local LGTM stack) +otel.exporter.otlp.endpoint=http://localhost:4317 +otel.exporter.otlp.protocol=grpc +otel.traces.exporter=otlp +# TODO Phase 3 task: flip this from "none" to "otlp" so flag-evaluation +# metrics start exporting to the LGTM stack. +otel.metrics.exporter=none +otel.logs.exporter=none +otel.service.name=fun-with-flags-java-spring diff --git a/adventures/planned/00-blind-by-design/expert/verify.sh b/adventures/planned/00-blind-by-design/expert/verify.sh new file mode 100755 index 00000000..e5b92de0 --- /dev/null +++ b/adventures/planned/00-blind-by-design/expert/verify.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../../lib/scripts/loader.sh" + +OBJECTIVE="By the end of this level, you should have: +- The OpenTelemetry meter provider wired and the OpenFeature MetricsHook registered +- At least one trace for service 'fun-with-flags-java-spring' visible in Tempo +- The 'feature_flag_evaluation_requests_total' counter non-zero in Prometheus +- The 'vision_amplifier_v2' fractional rollout flipped back to 100% off / 0% on +- HTTP 5xx rate over the last minute below 1%" + +DOCS_URL="https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/expert" + +print_header \ + 'Adventure 00: Blind by Design' \ + '๐Ÿ”ด Expert: Phase 3 โ€” read the chart' \ + 'Verification' + +check_prerequisites curl jq + +print_sub_header "Running verification checks..." + +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +APP_URL="http://localhost:8080" +FLAGD_HTTP="http://localhost:8013" +PROMETHEUS_URL="http://localhost:9090" +TEMPO_URL="http://localhost:3200" +GRAFANA_URL="http://localhost:3000" + +# ---- 1. App reachable ------------------------------------------------------ +print_test_section "Checking lab reachability" +if curl -fsS --max-time 5 "$APP_URL/" >/dev/null 2>&1; then + print_info_indent "โœ“ Spring Boot lab reachable at $APP_URL" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "Spring Boot lab is not reachable at $APP_URL" + print_hint "Start the app with: ./mvnw spring-boot:run" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("app_reachable") +fi +print_new_line + +# ---- 2. flagd reachable --------------------------------------------------- +print_test_section "Checking flagd reachability" +if curl -fsS --max-time 5 -X POST "$FLAGD_HTTP/flagd.evaluation.v1.Service/ResolveBoolean" \ + -H 'Content-Type: application/json' \ + -d '{"flagKey":"loadgen_active","context":{}}' >/dev/null 2>&1; then + print_info_indent "โœ“ flagd HTTP eval API reachable at $FLAGD_HTTP" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "flagd HTTP API is not reachable at $FLAGD_HTTP" + print_hint "flagd is a sibling devcontainer service. Reopen the Codespace if it is not running." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("flagd_reachable") +fi +print_new_line + +# ---- 3. LGTM stack reachable --------------------------------------------- +print_test_section "Checking Grafana LGTM stack reachability" +if curl -fsS --max-time 5 "$GRAFANA_URL/api/health" >/dev/null 2>&1; then + print_info_indent "โœ“ Grafana reachable at $GRAFANA_URL" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "Grafana is not reachable at $GRAFANA_URL" + print_hint "The LGTM stack is a sibling devcontainer service (lgtm). Reopen the Codespace if it is not running." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("lgtm_reachable") +fi +print_new_line + +# ---- 4. vision_amplifier_v2 rolled back ----------------------------------- +print_test_section "Checking vision_amplifier_v2 rollback" +ROLLOUT_RESPONSE=$(curl -fsS --max-time 5 -X POST \ + "$FLAGD_HTTP/flagd.evaluation.v1.Service/ResolveBoolean" \ + -H 'Content-Type: application/json' \ + -d '{"flagKey":"vision_amplifier_v2","context":{"targetingKey":"verify-probe-user"}}' 2>/dev/null || echo "") + +if [[ -z "$ROLLOUT_RESPONSE" ]]; then + print_error_indent "Could not query vision_amplifier_v2 from flagd" + print_hint "Make sure the flagd container is running and flags.json has vision_amplifier_v2 defined." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("vision_amplifier_v2_rollback") +else + ROLLOUT_VALUE=$(echo "$ROLLOUT_RESPONSE" | jq -r '.value // empty') + if [[ "$ROLLOUT_VALUE" == "false" ]]; then + print_info_indent "โœ“ vision_amplifier_v2 evaluates to false (rollout has been rolled back)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "vision_amplifier_v2 still resolves to '$ROLLOUT_VALUE' for the probe user" + print_hint "Edit flags.json: flip the fractional bucket so 'off' is 100 and 'on' is 0, save, and flagd will pick it up." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("vision_amplifier_v2_rollback") + fi +fi +print_new_line + +# ---- 5. Prometheus has feature_flag_evaluation_requests_total ---------- +print_test_section "Checking feature_flag metrics in Prometheus" +PROM_QUERY='feature_flag_evaluation_requests_total' +PROM_RESPONSE=$(curl -fsS --max-time 5 -G "$PROMETHEUS_URL/api/v1/query" \ + --data-urlencode "query=$PROM_QUERY" 2>/dev/null || echo "") + +if [[ -z "$PROM_RESPONSE" ]]; then + print_error_indent "Could not query Prometheus at $PROMETHEUS_URL" + print_hint "The grafana/otel-lgtm container exposes Prometheus on port 9090. If port 9090 is not forwarded, the lgtm sibling container has not started โ€” reopen the Codespace." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("prometheus_metrics") +else + RESULT_COUNT=$(echo "$PROM_RESPONSE" | jq '.data.result | length // 0') + TOTAL=$(echo "$PROM_RESPONSE" | jq -r '[.data.result[]?.value[1] | tonumber] | add // 0') + # `add // 0` is a tiny safeguard if the array is empty. + if [[ "$RESULT_COUNT" -gt 0 ]] && awk -v v="$TOTAL" 'BEGIN { exit !(v+0 > 0) }'; then + print_info_indent "โœ“ feature_flag_evaluation_requests_total is non-zero (sum=$TOTAL)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "feature_flag_evaluation_requests_total is missing or zero" + print_hint "Wire the OpenTelemetry meter provider AND register MetricsHook in OpenFeatureConfig.initProvider(). Then drive traffic by flipping loadgen_active to 'on'." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("prometheus_metrics") + fi +fi +print_new_line + +# ---- 6. Tempo has at least one trace for the service ------------------- +print_test_section "Checking traces in Tempo" +TEMPO_RESPONSE=$(curl -fsS --max-time 5 -G "$TEMPO_URL/api/search" \ + --data-urlencode 'tags=service.name=fun-with-flags-java-spring' \ + --data-urlencode 'limit=20' 2>/dev/null || echo "") + +if [[ -z "$TEMPO_RESPONSE" ]]; then + print_error_indent "Could not query Tempo at $TEMPO_URL" + print_hint "The grafana/otel-lgtm container exposes Tempo on port 3200. If port 9090 is not forwarded, the lgtm sibling container has not started โ€” reopen the Codespace." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("tempo_traces") +else + TRACE_COUNT=$(echo "$TEMPO_RESPONSE" | jq '.traces | length // 0') + if [[ "$TRACE_COUNT" -gt 0 ]]; then + print_info_indent "โœ“ Tempo has $TRACE_COUNT trace(s) for service 'fun-with-flags-java-spring'" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "Tempo has no traces for service 'fun-with-flags-java-spring'" + print_hint "Send some traffic: curl http://localhost:8080/?userId=demo and wait a few seconds for the exporter to flush." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("tempo_traces") + fi +fi +print_new_line + +# ---- 6b. Tempo spans carry the dose context attribute ------------------ +# Generate a deterministic underdose request, give the exporter a moment to +# flush, then query Tempo for spans with feature_flag.context.dose. If the +# attribute is missing the participant has not registered the +# ContextSpanHook (or it is not reading the merged eval context). +print_test_section "Checking flag-context attributes on Tempo spans" +curl -s --max-time 5 'http://localhost:8080/?dose=underdose' >/dev/null 2>&1 || true +sleep 6 # OTel batch span processor flush window +DOSE_TEMPO=$(curl -fsS --max-time 5 -G "$TEMPO_URL/api/search" \ + --data-urlencode 'tags=feature_flag.context.dose=underdose' \ + --data-urlencode 'limit=5' 2>/dev/null || echo "") + +if [[ -z "$DOSE_TEMPO" ]]; then + print_error_indent "Could not query Tempo for context attributes" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("tempo_context") +else + DOSE_COUNT=$(echo "$DOSE_TEMPO" | jq '.traces | length // 0') + if [[ "$DOSE_COUNT" -gt 0 ]]; then + print_info_indent "โœ“ Tempo has $DOSE_COUNT span(s) tagged feature_flag.context.dose=underdose" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + print_error_indent "No spans with feature_flag.context.dose=underdose found in Tempo" + print_hint "Did you register the ContextSpanHook that copies merged-eval-context attrs onto Span.current()?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("tempo_context") + fi +fi +print_new_line + +# ---- 7. HTTP 5xx rate under threshold ---------------------------------- +print_test_section "Checking HTTP 5xx error rate (last 1m)" +ERROR_QUERY='sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[1m])) / clamp_min(sum(rate(http_server_request_duration_seconds_count[1m])), 1e-9)' +ERROR_RESPONSE=$(curl -fsS --max-time 5 -G "$PROMETHEUS_URL/api/v1/query" \ + --data-urlencode "query=$ERROR_QUERY" 2>/dev/null || echo "") + +if [[ -z "$ERROR_RESPONSE" ]]; then + # Fallback: try the older Spring metric name + ERROR_QUERY_ALT='sum(rate(http_server_requests_seconds_count{status=~"5.."}[1m])) / clamp_min(sum(rate(http_server_requests_seconds_count[1m])), 1e-9)' + ERROR_RESPONSE=$(curl -fsS --max-time 5 -G "$PROMETHEUS_URL/api/v1/query" \ + --data-urlencode "query=$ERROR_QUERY_ALT" 2>/dev/null || echo "") +fi + +if [[ -z "$ERROR_RESPONSE" ]]; then + print_error_indent "Could not query Prometheus for HTTP error rate" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("error_rate") +else + ERROR_RATE=$(echo "$ERROR_RESPONSE" | jq -r '.data.result[0].value[1] // "0"') + # Treat NaN (no requests at all) as a pass โ€” there's no traffic to fail on. + if [[ "$ERROR_RATE" == "NaN" ]]; then + print_info_indent "โœ“ No traffic in the last minute โ€” error rate not meaningful (treated as pass)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + elif awk -v v="$ERROR_RATE" 'BEGIN { exit !(v+0 < 0.01) }'; then + PERCENT=$(awk -v v="$ERROR_RATE" 'BEGIN { printf "%.2f", v*100 }') + print_info_indent "โœ“ HTTP 5xx rate is ${PERCENT}% (< 1%)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + PERCENT=$(awk -v v="$ERROR_RATE" 'BEGIN { printf "%.2f", v*100 }') + print_error_indent "HTTP 5xx rate is ${PERCENT}% (>= 1%)" + print_hint "The 'on' bucket of vision_amplifier_v2 throws 5xx 10% of the time. Roll the rollout back to 100% off." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("error_rate") + fi +fi +print_new_line + +print_verification_summary "Phase 3 โ€” read the chart" "$DOCS_URL" "$OBJECTIVE" + +if [[ $TESTS_FAILED -ne 0 ]]; then + exit 1 +fi diff --git a/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties b/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..13b218bf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json b/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json new file mode 100644 index 00000000..f5ff5568 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "๐Ÿ‡ฉ๐Ÿ‡ช Run the Lab โ€” Germany (COUNTRY=de)", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "de" } + }, + { + "type": "java", + "name": "๐Ÿ‡ฆ๐Ÿ‡น Run the Lab โ€” Austria (COUNTRY=at)", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "at" } + }, + { + "type": "java", + "name": "๐ŸŒ Run the Lab โ€” No country", + "request": "launch", + "mainClass": "dev.openfeature.demo.java.demo.Laboratory", + "projectName": "demo", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "env": { "COUNTRY": "" } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json b/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json new file mode 100644 index 00000000..9de294cd --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/.vscode/tasks.json @@ -0,0 +1,32 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "๐Ÿงช Verify Solution", + "type": "shell", + "command": "./verify.sh", + "options": { "cwd": "${workspaceFolder}" }, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" }, + "group": { "kind": "test", "isDefault": true } + }, + { + "label": "๐Ÿ‡ฉ๐Ÿ‡ช Run the Lab โ€” Germany", + "type": "shell", + "command": "./run-germany.sh", + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + }, + { + "label": "๐Ÿ‡ฆ๐Ÿ‡น Run the Lab โ€” Austria", + "type": "shell", + "command": "./run-austria.sh", + "options": { "cwd": "${workspaceFolder}" }, + "isBackground": true, + "problemMatcher": [], + "presentation": { "reveal": "always", "panel": "dedicated" } + } + ] +} diff --git a/adventures/planned/00-blind-by-design/intermediate/flags.json b/adventures/planned/00-blind-by-design/intermediate/flags.json new file mode 100644 index 00000000..5af2d2b0 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/flags.json @@ -0,0 +1,24 @@ +{ + "flags": { + "vision_state": { + "state": "ENABLED", + "variants": { + "enhanced": "enhanced", + "sharp": "sharp", + "blurry": "blurry", + "clouded": "clouded" + }, + "defaultVariant": "blurry", + "targeting": { + "if": [ + { "===": [{ "var": "species" }, "zyklop"] }, + "enhanced", + { "in": [{ "var": "dose" }, ["underdose", "overdose"]] }, + "clouded", + { "===": [{ "var": "country" }, "de"] }, + "sharp" + ] + } + } + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/mvnw b/adventures/planned/00-blind-by-design/intermediate/mvnw new file mode 100755 index 00000000..19529ddf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd b/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd new file mode 100644 index 00000000..249bdf38 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/adventures/planned/00-blind-by-design/intermediate/pom.xml b/adventures/planned/00-blind-by-design/intermediate/pom.xml new file mode 100644 index 00000000..4445bbcf --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + dev.openfeature.demo.java + demo + 0.0.1-SNAPSHOT + demo + Demo project for OpenFeature with Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + + + dev.openfeature + sdk + 1.14.2 + + + dev.openfeature.contrib.providers + flagd + 0.11.8 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/adventures/planned/00-blind-by-design/intermediate/run-austria.sh b/adventures/planned/00-blind-by-design/intermediate/run-austria.sh new file mode 100755 index 00000000..6b4411fd --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/run-austria.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Start the lab as a Phase trial registered in Austria. Same shape as +# run-germany.sh; only the country code differs. The country=at branch is +# not in flags.json by default โ€” every subject without a species override falls +# through to the "blurry" default. Useful for proving the country-targeting +# branch only fires when the country matches. +set -e +cd "$(dirname "${BASH_SOURCE[0]}")" +COUNTRY=at exec ./mvnw spring-boot:run "$@" 2>&1 | tee app.log diff --git a/adventures/planned/00-blind-by-design/intermediate/run-germany.sh b/adventures/planned/00-blind-by-design/intermediate/run-germany.sh new file mode 100755 index 00000000..e6d33b33 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/run-germany.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Start the lab as a Phase trial registered in Germany. Pipes through tee so +# verify.sh can grep the audit-hook lines from app.log. +set -e +cd "$(dirname "${BASH_SOURCE[0]}")" +COUNTRY=de exec ./mvnw spring-boot:run "$@" 2>&1 | tee app.log diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java new file mode 100644 index 00000000..53fb812b --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Laboratory.java @@ -0,0 +1,13 @@ +package dev.openfeature.demo.java.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Laboratory { + + public static void main(String[] args) { + SpringApplication.run(Laboratory.class, args); + } + +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java new file mode 100644 index 00000000..b82bc1e9 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/OpenFeatureConfig.java @@ -0,0 +1,22 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.contrib.providers.flagd.Config; +import dev.openfeature.contrib.providers.flagd.FlagdOptions; +import dev.openfeature.contrib.providers.flagd.FlagdProvider; +import dev.openfeature.sdk.OpenFeatureAPI; +import jakarta.annotation.PostConstruct; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenFeatureConfig { + + @PostConstruct + public void initProvider() { + OpenFeatureAPI api = OpenFeatureAPI.getInstance(); + FlagdOptions flagdOptions = FlagdOptions.builder() + .resolverType(Config.Resolver.RPC) + .build(); + + api.setProviderAndWait(new FlagdProvider(flagdOptions)); + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java new file mode 100644 index 00000000..744764fe --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/java/dev/openfeature/demo/java/demo/Trial.java @@ -0,0 +1,17 @@ +package dev.openfeature.demo.java.demo; + +import dev.openfeature.sdk.Client; +import dev.openfeature.sdk.FlagEvaluationDetails; +import dev.openfeature.sdk.OpenFeatureAPI; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Trial { + + @GetMapping("/") + public FlagEvaluationDetails observeSubject() { + Client client = OpenFeatureAPI.getInstance().getClient(); + return client.getStringDetails("vision_state", "untreated"); + } +} diff --git a/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties b/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties new file mode 100644 index 00000000..2109a440 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=demo diff --git a/adventures/planned/00-blind-by-design/intermediate/verify.sh b/adventures/planned/00-blind-by-design/intermediate/verify.sh new file mode 100755 index 00000000..7540a183 --- /dev/null +++ b/adventures/planned/00-blind-by-design/intermediate/verify.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load shared libraries +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../../../../lib/scripts/loader.sh" + +OBJECTIVE="By the end of this level, you should have: + +- A SpeciesInterceptor that captures ?species= into the OpenFeature transaction context +- A global evaluation context carrying country (from the COUNTRY env var) +- An AuditHook that logs every flag evaluation +- Trial passes a 'dose' attribute as invocation context at the call site +- curl /?species=zyklop returns 'enhanced' +- curl /?dose=standard returns 'sharp' (with COUNTRY=de) and never the fallback 'untreated' +- curl /?dose=underdose returns 'clouded' (improper dosing for non-zyklops) +- curl /?species=zyklop&dose=underdose returns 'enhanced' (species priority survives bad dose) +- The application log contains audit lines emitted by AuditHook" + +DOCS_URL="https://dynatrace-oss.github.io/open-ecosystem-challenges/00-blind-by-design/intermediate" + +print_header \ + 'Challenge 00: Blind by Design' \ + '๐ŸŸก Intermediate: Outcome by cohort' \ + 'Verification' + +# Init test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_CHECKS=() + +check_prerequisites curl jq + +# ----------------------------------------------------------------------------- +# Locate the application log. The participant is instructed (in intermediate.md) +# to start the app with `./mvnw spring-boot:run | tee app.log` so the log lives +# next to this script. Fall back to a couple of other reasonable spots. +# ----------------------------------------------------------------------------- +APP_LOG="" +for candidate in \ + "$SCRIPT_DIR/app.log" \ + "$SCRIPT_DIR/../app.log" \ + "$PWD/app.log"; do + if [[ -f "$candidate" ]]; then + APP_LOG="$candidate" + break + fi +done + +print_sub_header "Running verification checks..." + +# ----------------------------------------------------------------------------- +# 1. App reachable on :8080 +# ----------------------------------------------------------------------------- +print_test_section "Checking the lab is reachable on :8080..." +if curl -s --max-time 5 "http://localhost:8080/" >/dev/null 2>&1; then + print_success_indent "App is reachable at http://localhost:8080/" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "App not reachable at http://localhost:8080/" + print_hint "Start the lab with: ./run-germany.sh (or COUNTRY=de ./mvnw spring-boot:run | tee app.log)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("app_reachable") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 2. Per-subject targeting: ?species=zyklop must return "enhanced" +# ----------------------------------------------------------------------------- +print_test_section "Checking the zyklop subject gets 'enhanced'..." +ZYKLOP_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?species=zyklop' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$ZYKLOP_VALUE" == "enhanced" ]]; then + print_success_indent "GET /?species=zyklop returned 'enhanced'" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "GET /?species=zyklop returned: '$ZYKLOP_VALUE' (expected 'enhanced')" + print_hint "Did you wire SpeciesInterceptor and register a ThreadLocalTransactionContextPropagator?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("species_targeting") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 3. Trial-country targeting: GET /?dose=standard with COUNTRY=de in the env +# should resolve to "sharp". We pin dose=standard explicitly so the random +# dose pick (which the controller does on the call site) cannot trip the +# "improper dose -> clouded" branch. If the global eval context is not +# wired, the targeting falls through to the default variant and "blurry" +# comes back instead. The only response we truly reject is the literal +# fallback "untreated", which means no provider is resolving at all. +# ----------------------------------------------------------------------------- +print_test_section "Checking the trial-country branch fires for COUNTRY=de..." +COUNTRY_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?dose=standard' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$COUNTRY_VALUE" == "sharp" ]]; then + print_success_indent "GET /?dose=standard returned 'sharp' โ€” country targeting is firing" + TESTS_PASSED=$((TESTS_PASSED + 1)) +elif [[ "$COUNTRY_VALUE" == "untreated" || -z "$COUNTRY_VALUE" ]]; then + print_error_indent "GET /?dose=standard returned: '$COUNTRY_VALUE' โ€” provider isn't resolving" + print_hint "Check OpenFeatureConfig โ€” the FlagdProvider should be registered before the first request." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("default_resolves") +else + print_error_indent "GET /?dose=standard returned: '$COUNTRY_VALUE' (expected 'sharp' with COUNTRY=de)" + print_hint "Did you populate the global evaluation context with country=System.getenv(\"COUNTRY\")? Did you start the lab via ./run-germany.sh or with COUNTRY=de set?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("country_targeting") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 4. Invocation context: GET /?dose=underdose must return "clouded" โ€” the +# controller passes the participant-supplied dose at the call site, the +# targeting catches improper doses for non-zyklop subjects. +# ----------------------------------------------------------------------------- +print_test_section "Checking improper-dose targeting fires for ?dose=underdose..." +UNDERDOSE_VALUE="$(curl -s --max-time 5 'http://localhost:8080/?dose=underdose' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$UNDERDOSE_VALUE" == "clouded" ]]; then + print_success_indent "GET /?dose=underdose returned 'clouded' โ€” invocation context is firing" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "GET /?dose=underdose returned: '$UNDERDOSE_VALUE' (expected 'clouded')" + print_hint "Does Trial.observeSubject pass an ImmutableContext with 'dose' to client.getStringDetails(...) at the call site?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("invocation_context") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 5. Zyklop biology overrides bad dosing: even with ?dose=underdose, a zyklop +# subject should still resolve to "enhanced" because the targeting puts +# species-zyklop ahead of the improper-dose branch. +# ----------------------------------------------------------------------------- +print_test_section "Checking zyklop biology survives an improper dose..." +ZYKLOP_BAD_DOSE="$(curl -s --max-time 5 'http://localhost:8080/?species=zyklop&dose=underdose' 2>/dev/null \ + | jq -r '.value // empty' 2>/dev/null || echo "")" + +if [[ "$ZYKLOP_BAD_DOSE" == "enhanced" ]]; then + print_success_indent "Zyklop + underdose returned 'enhanced' โ€” species priority is correct" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "Zyklop + underdose returned: '$ZYKLOP_BAD_DOSE' (expected 'enhanced')" + print_hint "Targeting order in flags.json should evaluate species=zyklop before the improper-dose branch." + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("priority_species_over_dose") +fi +print_new_line + +# ----------------------------------------------------------------------------- +# 4. AuditHook audit lines must appear in the application log. +# ----------------------------------------------------------------------------- +print_test_section "Checking AuditHook audit lines in application log..." +if [[ -z "$APP_LOG" ]]; then + print_error_indent "Couldn't find app.log next to verify.sh" + print_hint "Start the lab with: ./run-germany.sh (or COUNTRY=de ./mvnw spring-boot:run | tee app.log)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("app_log_missing") +elif grep -Eq "AUDIT|Before hook|After hook" "$APP_LOG"; then + print_success_indent "Found AuditHook audit lines in $APP_LOG" + TESTS_PASSED=$((TESTS_PASSED + 1)) +else + print_error_indent "No 'Before hook'/'After hook' lines found in $APP_LOG" + print_hint "Did you implement AuditHook and register it via api.addHooks(...)?" + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_CHECKS+=("custom_hook_log") +fi +print_new_line + +# ============================================================================= +# Summary +# ============================================================================= +failed_checks_json="[]" +if [[ -n "${FAILED_CHECKS[*]:-}" ]]; then + failed_checks_json=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) +fi + +if [[ $TESTS_FAILED -gt 0 ]]; then + track_verification_completed "failed" "$failed_checks_json" + print_verification_summary "blind by design" "$DOCS_URL" "$OBJECTIVE" + exit 1 +fi + +track_verification_completed "success" "$failed_checks_json" + +print_header "Test Results Summary" +print_success "โœ… PASSED: All $TESTS_PASSED verification checks passed!" +print_new_line + +# Run submission readiness checks (best-effort: the function exists in lib) +if command -v check_submission_readiness >/dev/null 2>&1; then + check_submission_readiness "00-blind-by-design" "intermediate" +fi diff --git a/adventures/planned/00-blind-by-design/mkdocs.yaml b/adventures/planned/00-blind-by-design/mkdocs.yaml new file mode 100644 index 00000000..f1cb90d3 --- /dev/null +++ b/adventures/planned/00-blind-by-design/mkdocs.yaml @@ -0,0 +1,11 @@ +site_name: '๐Ÿงช 00: Blind by Design' + +nav: + - Introduction: index.md + - '๐ŸŸข Beginner': beginner.md + - '๐ŸŸก Intermediate': intermediate.md + - '๐Ÿ”ด Expert': expert.md + - 'Solutions': + - '๐ŸŸข Beginner': solutions/beginner.md + - '๐ŸŸก Intermediate': solutions/intermediate.md + - '๐Ÿ”ด Expert': solutions/expert.md diff --git a/ideas/blind-by-design.md b/ideas/blind-by-design.md new file mode 100644 index 00000000..e5941953 --- /dev/null +++ b/ideas/blind-by-design.md @@ -0,0 +1,130 @@ +# Adventure Idea: ๐Ÿงช Blind by Design + +## Overview + +**Theme:** A research lab is testing a vision-enhancement serum on volunteers. The serum is supposed to take ordinary eyes and produce sharper, even enhanced sight โ€” useful for observation work. The lab is a Spring Boot service; OpenFeature is the chart system; `flags.json` decides what reading the lab records for each subject. The protocol is the same for every subject โ€” what differs is the **observed `vision_state`**, because subjects come in with different biology, dose adherence, and trial-jurisdiction baseline. The flagship Phase 3 trial โ€” a new amplifier algorithm โ€” has started showing trouble: subjects stabilise slower, and roughly one in ten emerge blind. The dashboard that should be tracking all of this is dark, because the lab forgot to wire the metric exporter. Your mission across three levels: stand up the lab, read the chart by cohort, then turn on the lights and roll back the trial before more subjects lose their sight. + +**Skills:** + +- Wire OpenFeature into a real application and resolve flags from a flagd sidecar +- Layer per-request, per-process, and per-evaluation context so the same trial yields the right reading for every cohort, and audit every reading in the logs +- Roll out a risky algorithm in measured phases and roll it back from observability data when it misbehaves + +**Technologies:** OpenFeature Java SDK, flagd, Spring Boot, Grafana LGTM (Tempo + Prometheus + Loki), Testcontainers + +--- + +## Levels + +### ๐ŸŸข Beginner: Stand up the lab + +#### Description + +Wire OpenFeature into a Spring Boot service so the lab's `vision_state` reading is resolved by a flagd sidecar against a `flags.json` instead of a hard-coded literal. + +#### Story + +The lab is on its first shift. Every subject who walks in gets the same hard-coded reading on their chart โ€” no matter what the lab director just signed off on, no matter what the protocol says. A flagd sidecar is already running next to the lab in the Codespace, with an empty `flags.json` mounted into it; the OpenFeature SDK is not in the project at all. The lab director has approved the switch: add the SDK + flagd contrib provider to the project, register the provider against the sidecar, author the first flag definition in `flags.json`, and let the chart drive what gets recorded for each subject. While you are at it, prove the lab can change the reading without restarting anything โ€” flagd's file watcher does the work. + +#### The Problem + +The Spring Boot starter app has a `Trial` controller whose `GET /` returns a string literal. There is no OpenFeature dependency in the `pom.xml`, no provider configured, and `flags.json` ships as `{"flags": {}}` so the flagd sidecar can boot. The participant must add the OpenFeature SDK and the flagd contrib provider, configure a `FlagdProvider` in `Resolver.RPC` mode (the devcontainer pre-sets `FLAGD_HOST=flagd` and `FLAGD_PORT=8013` so no host or port needs to be hard-coded), add the `vision_state` flag to `flags.json`, and switch the controller to call `client.getStringDetails` against it. + +#### Objective + +By the end of this level, the learner should: + +- Have `curl http://localhost:8080/` return a `vision_state` reading **resolved by the flagd sidecar** (not the hard-coded `untreated` fallback) +- Confirm the response payload includes the OpenFeature evaluation details (variant, reason, value) +- Edit `flags.json` to change the `defaultVariant`, save, and have the **next** request return the new variant **without restarting the app or flagd** + +#### What You'll Learn + +- How an OpenFeature client and provider work together โ€” the SDK is provider-agnostic and the flagd provider plugs in via dependency only +- What "remote provider" means in practice โ€” the SDK calls a separate flag service (flagd) over gRPC; the SDK does not parse `flags.json` itself +- What `flags.json` looks like for flagd (state, variants, defaultVariant) +- Why hot-reload of the flag file matters operationally โ€” configuration without redeploy + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `jq` (optional for prettier output) +- **Infrastructure:** Java 21 toolchain, a `flagd` sidecar service running in the devcontainer's compose stack on `:8013` (gRPC eval), `:8014` (management/metrics), `:8015` (sync), `:8016` (OFREP) + +--- + +### ๐ŸŸก Intermediate: Outcome by cohort + +#### Description + +Add request-scoped context, a global runtime context, an invocation context at the call site, and an audit hook so the lab records the right reading per subject cohort and every reading shows up in the audit log. + +#### 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 into the lab holding a stack of complaint forms. The protocol is the same for every subject; what differs is the *observed outcome* because subjects come in with different biology, different dose adherence, and the trial is registered in different jurisdictions. The OpenFeature client never sees what **species** is on the table (each subject brings their own โ€” humans, zyklops, you name it), never sees which **country** this trial is registered in (set once when the lab boots), never sees what **dose** the subject actually absorbed (varies per subject โ€” missed appointments, fast metabolisers, the usual reasons). And there is no audit hook recording who ended up with which reading. + +#### The Problem + +The lab from the Beginner level reads the flag, but the same variant comes back for every request. The flag definition in `flags.json` already has all three targeting branches loaded โ€” `species == zyklop`, improper-`dose` for non-zyklops, and `country == de` โ€” but none of those attributes are in the evaluation context yet, so the targeting has nothing to fire on. The participant must wire a `SpeciesInterceptor` that lifts `?species=` into the OpenFeature **transaction context**, populate the **global** evaluation context with `country` from the `COUNTRY` env var at startup, pass a `dose` attribute as **invocation context** at the call site of `client.getStringDetails(...)`, and register an `AuditHook` that records every evaluation with the cohort attributes that drove it. + +#### Objective + +By the end of this level, the learner should: + +- Have a Spring `HandlerInterceptor` (`SpeciesInterceptor`) that reads `?species=` from the request and sets it on the OpenFeature transaction context, then clears it on `afterCompletion` +- Have a global evaluation context that carries `country` from `System.getenv("COUNTRY")`, set once in `OpenFeatureConfig.@PostConstruct` +- Have the `Trial` controller pass a `dose` attribute as invocation context at the call site +- Have an `AuditHook` registered that emits one `[AUDIT] ...` log line per evaluation, at `WARN` for `clouded` outcomes +- Confirm `curl /?species=zyklop` returns `enhanced` (transaction wins), `curl /?dose=standard` with `COUNTRY=de` returns `sharp` (global), `curl /?dose=underdose` returns `clouded` (invocation), and `curl /?species=zyklop&dose=underdose` returns `enhanced` (precedence: species-zyklop is evaluated before improper-dose in `flags.json`) + +#### What You'll Learn + +- How OpenFeature's three evaluation-context layers compose โ€” global (per-process), transaction (per-request, propagated thread-locally), invocation (per-evaluation, passed at the call site) โ€” and how precedence works on conflict +- How transaction-context propagation works in a thread-per-request server with a `ThreadLocalTransactionContextPropagator` +- How hooks let you attach cross-cutting behaviour (audit today, observability tomorrow) without modifying every call site +- Why an audit log needs a fixed allowlist of context attributes โ€” `targetingKey` and other PII routinely sit in the merged context in real apps + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `tail -f` against `app.log`, two convenience runners (`./run-germany.sh` and `./run-austria.sh`) that pre-set `COUNTRY` and tee to the log +- **Infrastructure:** Same Java 21 toolchain, the same `flagd` sidecar from Beginner (compose-managed alongside `workspace`) + +--- + +### ๐Ÿ”ด Expert: Phase 3 โ€” read the chart + +#### Description + +Finish wiring OpenTelemetry traces and metrics through to the Grafana LGTM stack, write a `ContextSpanHook` of your own that mirrors the merged evaluation context onto the active span, find the misbehaving Phase 3 amplifier in the dashboard, and roll it back without redeploying. + +#### Story + +The trial just went wide. The same flagd sidecar from earlier levels is now serving two flags that matter โ€” the cohort-targeted `vision_state` from Intermediate, and a new fractional-rollout flag `vision_amplifier_v2` driving the Phase 3 trial. OpenTelemetry is half-wired: a traces exporter is shipping spans to Tempo, but the meter provider is unconfigured, so the rollout dashboard is dark. And the fractional bucket on `vision_amplifier_v2` is inverted โ€” every subject is rolling into the new amplifier. Each evaluation under the new amplifier is 200 milliseconds slower to stabilise, and roughly one in ten subjects emerges blind (HTTP 500). The lab is the lab โ€” it cannot fix what it cannot see. The dashboard is dark. + +The director wants four things, in order: the dashboard lit up, the eval-context attributes that drove each outcome searchable on the spans (so on-call can answer "which dose got which variant?"), the bad fractional bucket identified, and the rollout rolled back to a safe number โ€” all without redeploying the lab. + +#### The Problem + +The level ships a working lab pointed at the same `flagd` sidecar in `Resolver.RPC` mode, plus a Grafana LGTM container with OTLP receivers on the standard ports and a k6 loadgen that drives traffic when the `loadgen_active` flag is on. The OpenTelemetry SDK in the app is wired for traces (the OTel `TracesHook` is registered, the exporter writes to Tempo) but the meter provider's exporter is set to `none`, so the OpenFeature `MetricsHook` has nowhere to record. The `AuditHook` from Intermediate is carried over and continues to write a durable archive view; what the lab is missing is the **real-time correlation** between context attributes and span events. The participant must (1) flip `otel.metrics.exporter` from `none` to `otlp`, (2) register `MetricsHook` on the OpenFeatureAPI, (3) write a small `ContextSpanHook` that copies a fixed allowlist (`species`, `country`, `dose`) from the merged eval context onto the active span as `feature_flag.context.`, (4) flip `loadgen_active` to `on` and observe the latency and 5xx panels, and (5) edit `flags.json` to flip the `vision_amplifier_v2` fractional bucket back to `100% off / 0% on` while the app keeps running. + +#### Objective + +By the end of this level, the learner should: + +- Have `MetricsHook` registered and the OTel meter provider configured to export to the LGTM stack on `localhost:4317` +- Have a `ContextSpanHook` of their own that copies the merged evaluation context (`species`, `country`, `dose`) onto the active span as `feature_flag.context.` โ€” registered alongside `TracesHook` / `MetricsHook` +- Have **at least one trace** for `fun-with-flags-java-spring` visible in the Grafana **Explore โ†’ Tempo** view +- Have spans tagged with `feature_flag.context.dose=underdose` searchable in Tempo and lining up with `feature_flag.variant=clouded` on the same span +- Have the **Fun With Flags โ€” Feature Flag Metrics** dashboard showing live evaluation rate, variant distribution, and latency by variant +- Have `vision_amplifier_v2` rolled back to `0% on`, confirmed by reading the flag from flagd's gRPC-Gateway HTTP route on `:8013`, and the HTTP 5xx rate dropping below threshold afterwards + +#### What You'll Learn + +- How the OpenFeature OTel hooks (`TracesHook` and `MetricsHook`) join flag evaluations to the rest of an app's telemetry without a separate ingestion path +- How to author your own `Hook` โ€” a tiny class that reads `HookContext.getCtx()` (the merged eval context) and emits something useful (here: span attributes) โ€” and why the **PII allowlist** matters when those attributes flow into observability backends with multi-day retention +- How fractional rollout in flagd buckets subjects by `targetingKey` and how to read the bucketing from a dashboard +- How a flag flip is a faster operational lever than a redeploy when a rollout is misbehaving + +#### Tools & Infrastructure + +- **Tools:** `curl`, `./mvnw`, `docker compose`, a browser pointed at Grafana on `:3000` +- **Infrastructure:** Java 21 toolchain, `flagd` sidecar (`:8013` gRPC eval, `:8014` management/metrics, `:8015` sync, `:8016` OFREP), `grafana/otel-lgtm` container on `:3000`/`:4317`/`:4318`/`:9090`/`:3200`, k6 loadgen container driving traffic when `loadgen_active` is on