From 1e6ce17797e1d856d61cd7ba6d2394b554daecd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 May 2026 10:33:30 +0000 Subject: [PATCH 1/3] feat: add expert level to blind-by-design --- public/sitemap.xml | 1 + react-router.config.ts | 1 + src/data/adventures/blind-by-design/expert-posts.json | 3 +++ 3 files changed, 5 insertions(+) create mode 100644 src/data/adventures/blind-by-design/expert-posts.json diff --git a/public/sitemap.xml b/public/sitemap.xml index 07822d02..f77d214d 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -41,4 +41,5 @@ https://offon.dev/challenges/terraform/monthly0.7 https://offon.dev/challenges/trivy/monthly0.7 https://offon.dev/challenges/flagd/monthly0.7 + https://offon.dev/adventures/blind-by-design/levels/expert/monthly0.8 diff --git a/react-router.config.ts b/react-router.config.ts index e518663b..3ccded1a 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -48,5 +48,6 @@ export default { "/challenges/terraform", "/challenges/trivy", "/challenges/flagd", + "/adventures/blind-by-design/levels/expert", ], } satisfies Config; diff --git a/src/data/adventures/blind-by-design/expert-posts.json b/src/data/adventures/blind-by-design/expert-posts.json new file mode 100644 index 00000000..b8c41dd3 --- /dev/null +++ b/src/data/adventures/blind-by-design/expert-posts.json @@ -0,0 +1,3 @@ +{ + "discussionUrl": "TODO: Add Discourse topic URL" +} From 657ee4e66cd242d68d69a2d7fdfc020a9312b0ba Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli Date: Thu, 28 May 2026 15:43:49 +0200 Subject: [PATCH 2/3] fix: add metaDescription field and complete blind-by-design expert level - Add optional metaDescription field to level schema, AdventureLevel type, generate script, and ChallengeDetail meta function - When present, metaDescription is used directly as the SEO meta description instead of the auto-generated formula from learnings, which was truncating mid-sentence with apostrophes encoding as ' - Add metaDescription to all three blind-by-design levels with purpose-built descriptions under 160 chars and no apostrophes - Add expert level (Read the Chart) to seo.test.ts, prerender.test.ts, and e2e/smoke.spec.ts - Delete stale test-b.generated.ts artifact - Improve new-adventure.mjs and new-level.mjs templates with clearer comments and examples - new-level.mjs now patches has_ flag in refresh-leaderboard.mjs automatically - WalkthroughSection renders all intro paragraphs, not just the first Signed-off-by: Sinduri Guntupalli --- .github/workflows/new-adventure.yml | 44 +++- .github/workflows/new-level.yml | 45 +++- e2e/smoke.spec.ts | 1 + schemas/adventure.schema.json | 5 + scripts/generate-adventures.mjs | 1 + scripts/new-adventure.mjs | 149 ++++++++---- scripts/new-level.mjs | 116 +++++++-- scripts/refresh-leaderboard.mjs | 2 +- .../diagrams/blind-by-design-expert.svg | 1 + src/components/WalkthroughSection.tsx | 86 ++++--- .../adventures/blind-by-design.generated.ts | 156 ++++++++++-- .../adventures/blind-by-design/adventure.yaml | 224 +++++++++++++++--- .../blind-by-design/expert-posts.json | 20 +- .../blind-by-design/leaderboard.json | 10 +- .../building-cloudhaven.generated.ts | 4 +- .../building-cloudhaven/adventure.yaml | 6 +- .../echoes-lost-in-orbit.generated.ts | 47 ++-- .../echoes-lost-in-orbit/adventure.yaml | 61 +++-- src/data/adventures/summaries.ts | 19 +- .../the-ai-observatory.generated.ts | 4 +- .../the-ai-observatory/adventure.yaml | 7 +- src/data/adventures/types.ts | 3 + src/pages/ChallengeDetail.tsx | 15 +- src/test/prerender.test.ts | 4 + src/test/seo.test.ts | 1 + 25 files changed, 782 insertions(+), 249 deletions(-) create mode 100644 src/assets/diagrams/blind-by-design-expert.svg diff --git a/.github/workflows/new-adventure.yml b/.github/workflows/new-adventure.yml index 0fe662c9..f2307fc7 100644 --- a/.github/workflows/new-adventure.yml +++ b/.github/workflows/new-adventure.yml @@ -87,17 +87,41 @@ jobs: printf '| Title | %s |\n' "$ADVENTURE_TITLE" printf '| Month | %s |\n' "$ADVENTURE_MONTH" printf '| Levels | %s |\n\n' "$ADVENTURE_LEVELS" - printf '### Generated files\n' - printf -- '- `src/data/adventures/%s/adventure.yaml` (fill in TODOs)\n' "$ADVENTURE_ID" - printf -- '- Discussion JSON stubs per level\n' + printf '### Generated files\n\n' + printf -- '- `src/data/adventures/%s/adventure.yaml` — fill in the fields below\n' "$ADVENTURE_ID" + printf -- '- Discussion JSON stubs per level (update `discussionUrl` in each)\n' printf -- '- Routes added to `react-router.config.ts`\n' - printf -- '- Sitemap entries added\n\n' - printf '### Next steps\n' - printf '1. Fill in the TODOs in `src/data/adventures/%s/adventure.yaml`\n' "$ADVENTURE_ID" - printf '2. Update `discussionUrl` in the YAML and each level `-posts.json` file\n' - printf '3. Run `npm run generate` to produce the TypeScript from YAML\n' - printf '4. Run `node scripts/refresh-discussions.mjs`\n' - printf '5. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' + printf -- '- Sitemap entries added to `public/sitemap.xml`\n\n' + printf '### Required fields — fill in `adventure.yaml`\n\n' + printf -- '- [ ] `story` — 2-3 sentences: what tech is used and what each level does\n' + printf -- '- [ ] `tags` — technology names matching the topics across all levels\n\n' + printf 'For each level:\n\n' + printf -- '- [ ] `name` — display name (e.g. "Stand Up the Lab")\n' + printf -- '- [ ] `topics` — technology pill tags shown on the level card\n' + printf -- '- [ ] `learnings` — full sentences describing concrete skills gained\n' + printf -- '- [ ] `devcontainerPath` — path to `devcontainer.json` in the challenges repo\n' + printf -- '- [ ] `discussionUrl` — Discourse topic URL or relative path (e.g. `/t/slug/123`)\n' + printf -- '- [ ] `intro` — 1-2 sentences: what to wire up and what to prove works\n' + printf -- '- [ ] `objective` — verifiable outcomes a player can check with a command\n' + printf -- '- [ ] `toolbox` — CLI tools and services available in the Codespace\n' + printf -- '- [ ] `howToPlay` — step-by-step walkthrough; first step confirms broken state\n' + printf -- '- [ ] `verification` — keep defaults unless the script name or message differs\n\n' + printf '### Optional fields (uncomment in `adventure.yaml`)\n\n' + printf -- '- `contributor` — external contributor name, URL, and bio\n' + printf -- '- `backstory` (adventure) — narrative context for the whole adventure (1-3 paragraphs)\n' + printf -- '- `context` — "What you'\''ll be using" explainer section\n' + printf -- '- `rewards` — prize tiers, deadline, and ranking rules\n' + printf -- '- Per level: `audience`, `backstory`, `architecture` + `architectureDiagram` + `diagramAlt`, `helpfulLinks`, `deadline`\n\n' + printf '### Next steps\n\n' + printf '1. Fill in `src/data/adventures/%s/adventure.yaml` (required fields above)\n' "$ADVENTURE_ID" + printf '2. Add the adventure and each level route to `src/routes.ts`\n' + printf '3. Update `discussionUrl` in the YAML and each level `*-posts.json` file\n' + printf '4. Run `npm run generate`\n' + printf '5. Run `node scripts/refresh-discussions.mjs`\n' + printf '6. Add the adventure to `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs` (set `categoryId` and per-level booleans), then run `node scripts/refresh-leaderboard.mjs`\n' + printf '7. Add each level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `` value\n' + printf '8. Update the routes table in `README.md`\n' + printf '9. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' } > /tmp/pr-body.md gh pr create \ --title "feat: scaffold adventure — ${ADVENTURE_TITLE}" \ diff --git a/.github/workflows/new-level.yml b/.github/workflows/new-level.yml index adc2a407..a6772e7d 100644 --- a/.github/workflows/new-level.yml +++ b/.github/workflows/new-level.yml @@ -78,18 +78,41 @@ jobs: printf '| Field | Value |\n|---|---|\n' printf '| Adventure | `%s` |\n' "$ADVENTURE" printf '| Level | `%s` |\n\n' "$LEVEL" - printf '### Generated\n' - printf -- '- `src/data/adventures/%s/%s-posts.json` (discussion stub)\n' "$ADVENTURE" "$LEVEL" - printf -- '- Prerender entry in `react-router.config.ts`\n' - printf -- '- Sitemap entry in `public/sitemap.xml`\n\n' - printf '### Manual step required\n' - printf 'Paste this into the `levels` array in `src/data/adventures/%s/adventure.yaml`:\n\n' "$ADVENTURE" + printf '### Generated files\n\n' + printf -- '- `src/data/adventures/%s/%s-posts.json` — update `discussionUrl`\n' "$ADVENTURE" "$LEVEL" + printf -- '- Prerender entry added to `react-router.config.ts`\n' + printf -- '- Sitemap entry added to `public/sitemap.xml`\n' + printf -- '- `has_%s` set to `true` in `ADVENTURE_CATEGORIES` in `scripts/refresh-leaderboard.mjs`\n\n' "$LEVEL" + printf '### Paste this into the `levels` array in `adventure.yaml`\n\n' printf '```yaml\n%s\n```\n\n' "$SNIPPET" - printf '### Next steps\n' - printf '1. Paste the snippet above and fill in the TODOs\n' - printf '2. Update `discussionUrl` in the YAML and the `-posts.json` file\n' - printf '3. Run `node scripts/refresh-discussions.mjs`\n' - printf '4. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' + printf '### Required fields — fill in after pasting\n\n' + printf -- '- [ ] `name` — display name (e.g. "Stand Up the Lab")\n' + printf -- '- [ ] `topics` — technology pill tags shown on the level card\n' + printf -- '- [ ] `learnings` — full sentences describing concrete skills gained\n' + printf -- '- [ ] `devcontainerPath` — path to `devcontainer.json` in the challenges repo\n' + printf -- '- [ ] `discussionUrl` — Discourse topic URL or relative path (e.g. `/t/slug/123`)\n' + printf -- '- [ ] `intro` — 1-2 sentences: what to wire up and what to prove works\n' + printf -- '- [ ] `objective` — verifiable outcomes a player can check with a command\n' + printf -- '- [ ] `toolbox` — CLI tools and services available in the Codespace\n' + printf -- '- [ ] `howToPlay` — step-by-step walkthrough; first step confirms broken state\n' + printf -- '- [ ] `verification` — keep defaults unless the script name or message differs\n\n' + printf '### Optional fields (uncomment after pasting)\n\n' + printf -- '- `audience` — who this level is aimed at and what prior knowledge helps\n' + printf -- '- `backstory` — narrative context that sets the scene for this specific level\n' + printf -- '- `architecture` — technical setup (services, ports, how they connect)\n' + printf -- '- `architectureDiagram` + `diagramAlt` — SVG diagram filename and alt text\n' + printf -- '- `helpfulLinks` — reference docs the player will need\n' + printf -- '- `deadline` — submission deadline if rewards are active\n\n' + printf '### Next steps\n\n' + printf '1. Paste the snippet above into `src/data/adventures/%s/adventure.yaml` and fill in required fields\n' "$ADVENTURE" + printf '2. Add the level route to `src/routes.ts`\n' + printf '3. Update `discussionUrl` in the YAML and `src/data/adventures/%s/%s-posts.json`\n' "$ADVENTURE" "$LEVEL" + printf '4. Run `npm run generate`\n' + printf '5. Run `node scripts/refresh-discussions.mjs`\n' + printf '6. Run `node scripts/refresh-leaderboard.mjs` (requires `DISCOURSE_API_KEY`)\n' + printf '7. Add the level URL to the `ROUTES` array in `e2e/smoke.spec.ts` and `src/test/seo.test.ts`, and to the `pages` array in `src/test/prerender.test.ts` with the expected `<title>` value\n' + printf '8. Update the routes table in `README.md`\n' + printf '9. Run all checks: `npm run lint && npm test && npm run build && npm run test:e2e`\n' } > /tmp/pr-body.md gh pr create \ --title "feat: add ${LEVEL} level — ${ADVENTURE}" \ diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 63fd8982..eaf0294a 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -29,6 +29,7 @@ const ROUTES: RouteSpec[] = [ { path: "/adventures/blind-by-design", title: /Blind by Design/ }, { path: "/adventures/blind-by-design/levels/beginner", title: /Stand up the Lab/ }, { path: "/adventures/blind-by-design/levels/intermediate", title: /Outcome by Cohort/ }, + { path: "/adventures/blind-by-design/levels/expert", title: /Read the Chart/ }, { path: "/challenges", title: /Open Source Challenges/ }, { path: "/challenges/argo-cd", title: /Argo CD Challenges/ }, { path: "/challenges/argo-rollouts", title: /Argo Rollouts Challenges/ }, diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json index 22553a71..7aab0284 100644 --- a/schemas/adventure.schema.json +++ b/schemas/adventure.schema.json @@ -216,6 +216,11 @@ }, "description": "Reference documentation links shown at the end of the challenge walkthrough." }, + "metaDescription": { + "type": "string", + "maxLength": 160, + "description": "Optional SEO meta description (max 160 chars). Use when the auto-generated description from learnings is insufficient. If omitted, the generator builds one from the level name, learnings, difficulty, and adventure title." + }, "solvedCount": { "type": "integer" }, "topPlayers": { "type": "array", diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs index 833ec2af..500c22f0 100644 --- a/scripts/generate-adventures.mjs +++ b/scripts/generate-adventures.mjs @@ -234,6 +234,7 @@ function generateLevelCode(level, adventureId, indent) { lines.push(`${i2}},`); } + if (level.metaDescription) lines.push(`${i2}metaDescription: ${formatString(level.metaDescription)},`); if (level.solvedCount !== undefined) lines.push(`${i2}solvedCount: ${level.solvedCount},`); if (level.topPlayers && level.topPlayers.length > 0) { lines.push(`${i2}topPlayers: [`); diff --git a/scripts/new-adventure.mjs b/scripts/new-adventure.mjs index b3583770..110433dc 100644 --- a/scripts/new-adventure.mjs +++ b/scripts/new-adventure.mjs @@ -99,53 +99,84 @@ const levelEntries = levels .map((level) => { const difficulty = difficultyMap[level]; return ` - id: ${level} - # required | e.g. "Stand up the Lab" / "Outcome by Cohort" / "Lights On" - name: "TODO: Level name" + # required | e.g. "Stand Up the Lab" / "Outcome by Cohort" / "Lights On" + name: "TODO: Replace with level display name" difficulty: ${difficulty} topics: # required | technology and tool names shown as pill tags on the level card. - # e.g. OpenFeature, flagd, Spring Boot - - "TODO: Add topic tags" + - "TODO: Replace with technology name" + - "TODO: Replace with another technology name" learnings: - # required | full sentences describing concrete skills or insights gained. - # e.g. "How an OpenFeature client and provider work together: the SDK is provider-agnostic and plugs in via dependency only" - - "TODO: Add learning 1" - - "TODO: Add learning 2" + # required | full sentences describing concrete skills or insights gained. Use >- for prose with colons. + - >- + TODO: How the client and provider work together: the SDK is provider-agnostic and plugs in via dependency only + - >- + TODO: Why hot-reload matters operationally: editing the flag file flips behaviour on the next request with no redeploy devcontainerPath: ".devcontainer/TODO/devcontainer.json" # required discussionUrl: "/t/TODO" # required intro: - # required | one or two sentences: what the player will wire up and what they will prove works. - # e.g. "Wire the OpenFeature Java SDK into a Spring Boot service so flag evaluations are resolved by a flagd sidecar. Prove that editing flags.json flips the response without restarting the app." - - "TODO: Add intro paragraph" - backstory: - # optional | narrative context that sets the scene for this specific level. - # e.g. "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, no matter what the director signed off on." - - "TODO: Add backstory" + # required | 1-2 sentences: what the player will wire up and what they will prove works. + - >- + TODO: Wire the SDK into the service so flag evaluations are resolved by a sidecar running alongside your Codespace. + Prove that editing the flag file flips the response on the next request without restarting anything. objective: # required | verifiable outcomes a player can check with a command. - # e.g. "curl http://localhost:8080/ returns a vision_state resolved from flags.json (not the hard-coded fallback)" - - "TODO: Add objective 1" + - >- + TODO: curl http://localhost:8080/ returns a value resolved from the flag file, not the hard-coded fallback + - >- + TODO: Editing the flag file and re-running curl returns the new value without restarting the service # optional | uncomment to describe who this level is aimed at and what prior knowledge helps. - # audience: "Best suited for: Platform engineers, SREs, and developers curious about Kubernetes security. No prior Kyverno experience needed, but familiarity with basic kubectl and YAML will help." - # optional | uncomment to describe the technical setup (services, ports, how they connect). + # audience: >- + # Best suited for platform engineers and developers new to feature flagging. Familiarity with basic + # Java and Spring Boot helps but no prior SDK experience is needed. + # optional | narrative context that sets the scene for this specific level. + # backstory: + # - >- + # The service is returning a hard-coded response. The SDK was integrated last quarter but the provider + # was never registered, so every request falls back to the default. Complete the wiring so the service + # reads from the flag file instead. + # optional | describe the technical setup (services, ports, how they connect). # architecture: - # - "TODO: e.g. Two containers run side-by-side: the app on http://localhost:8080 and a sidecar on :8013." - # - "TODO: e.g. Edit config.json through the IDE; the file watcher picks up changes within a second." + # - >- + # Two containers run side-by-side: the app on http://localhost:8080 and a sidecar on port 8013. + # - >- + # Edit the flag file through the IDE; the file watcher picks up changes within about a second. toolbox: # required | list the CLI tools and services available in the Codespace. Add a url field for external docs. - # e.g. name: "./mvnw" / description: "Maven wrapper; builds and runs the Spring Boot service" - - name: "TODO" - description: "TODO: Add tool description" + - name: "TODO: ./run.sh" + description: "TODO: Starts the service; also available via F5 in VS Code" + - name: "curl" + description: "TODO: sends requests to http://localhost:8080/ to confirm flags are being evaluated" + url: "https://curl.se/" howToPlay: # required | walk the player from the broken state to a working solution, one titled step at a time. # Use fenced code blocks for commands. First step: confirm the broken state. Last step: run the verifier. - - title: "TODO: Step 1" - body: "TODO: Add instructions" + - title: "Confirm the Broken State" + body: | + TODO: Start the service and confirm the hard-coded fallback is returned: + + \`\`\`sh + ./run.sh + curl http://localhost:8080/ + # returns the fallback — this is the broken state + \`\`\` + - title: "TODO: Replace with main fix step title" + body: >- + TODO: Replace with instructions for the main fix. What file to open, what to add or change. + - title: "Verify the Fix" + body: | + TODO: Edit the flag file and re-run curl without restarting anything: + + \`\`\`sh + curl http://localhost:8080/ + # now returns the flag-resolved value + \`\`\` helpfulLinks: # optional | reference docs the player will need. label is the link text; url must be a full https:// URL. - # e.g. label: "OpenFeature Java SDK" / url: https://openfeature.dev/docs/reference/technologies/server/java/ - - label: "TODO: Doc title" - url: "https://TODO" + - label: "TODO: SDK documentation" + url: https://example.com/docs + - label: "TODO: Flag definition reference" + url: https://example.com/flags verification: # required | keep the defaults unless the verification script name or message differs. command: "./verify.sh" @@ -156,40 +187,58 @@ const levelEntries = levels const yamlContent = `id: ${id} title: "${title}" month: "${month}" -# required | 2-3 sentences covering what technology is used and what each level does. -# e.g. "Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot service. -# Wire the SDK (Beginner), add cohort targeting (Intermediate), then instrument with OpenTelemetry (Expert)." -story: "TODO: Add adventure story summary" +# required | 2-3 sentences: what technology is used and what each level does. +story: >- + TODO: Replace with a 2-3 sentence summary. e.g. Three levels of OpenFeature with flagd as the provider, + in a Java + Spring Boot service. Wire the SDK (Beginner), add cohort targeting (Intermediate), + then instrument with OpenTelemetry (Expert). tags: # required | technology and tool names. Mirror the topics used across all levels. - # e.g. OpenFeature, flagd, Spring Boot, Java, OpenTelemetry - - "TODO: Add tags" + - "TODO: Replace with technology name" + - "TODO: Replace with another technology name" # optional | uncomment and fill in if the adventure has an external contributor: # contributor: -# name: "TODO: Contributor name" -# url: "https://TODO" -# about: "TODO: Short bio" - -backstory: - # optional | sets the scene for the whole adventure. Can be 1-3 paragraphs. - # e.g. "The Aletheia Institute is running a multi-phase vision-enhancement trial. The lab is a Spring Boot service whose one job is to record the vision_state of every subject..." - - "TODO: Add adventure backstory paragraph 1" - -# optional | uncomment and fill in for a 'What you will be using' section: +# name: "Contributor Name" +# url: "https://contributor-website.example.com" +# about: >- +# Short bio. e.g. CNCF Ambassador and maintainer of OpenFeature. Helps teams release faster +# through open standards and feature flagging. Familiar face at KubeCon and Devoxx. + +# optional | narrative context that sets the scene for the whole adventure. Can be 1-3 paragraphs. +# backstory: +# - >- +# Replace with paragraph 1. Describe the fictional world and situation. e.g. The Aletheia +# Institute is running a multi-phase trial. The lab is a Spring Boot service whose one job is +# to record the state of every subject who walks through the protocol. +# - >- +# Replace with paragraph 2. Build the stakes. e.g. It hasn't been working for eight months. +# Every subject through the door has been recorded as "untreated". + +# optional | "What you'll be using" explainer section shown on the adventure overview page. # context: # title: "What you'll be using" # body: -# - "TODO: Explain the main technology" +# - >- +# Replace with a paragraph explaining the main technology. e.g. OpenFeature is a vendor-neutral +# standard for feature flags. The reference cloud-native implementation is flagd, which serves +# flag definitions from a JSON file. +# - >- +# Replace with a second paragraph explaining how the adventure uses the technology. # optional | uncomment and fill in for reward info: # rewards: -# deadline: "TODO: Day, DD Month YYYY at HH:MM CET" -# eligibility: "TODO: Eligibility criteria" +# deadline: "DD Month YYYY at HH:MM CET" +# eligibility: "Complete all levels and post your solution in the community before the deadline to be eligible." # tiers: # - label: "1st place" -# description: "TODO: Prize" -# rankingNote: "TODO: How ranking works" +# description: "Replace with prize description" +# - label: "Top 3" +# description: "Credly badge to showcase the achievement" +# rankingNote: >- +# Ranking is determined by total points across all levels. Points per level are awarded by +# submission order within the active week (100 for the first valid solution, 95 for the second; +# late submissions still earn 60). # rankingRulesUrl: "/t/about-the-challenges-category/16" levels: diff --git a/scripts/new-level.mjs b/scripts/new-level.mjs index be9719e1..c130b3d6 100644 --- a/scripts/new-level.mjs +++ b/scripts/new-level.mjs @@ -10,6 +10,7 @@ * - Creates src/data/adventures/<adventure>/<level>-posts.json (discussion stub) * - Adds prerender entry to react-router.config.ts * - Adds sitemap entry to public/sitemap.xml + * - Sets has_<level>: true in ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs * - Prints a YAML snippet to paste into the adventure's adventure.yaml levels array */ @@ -124,37 +125,116 @@ if (sitemapContent.includes(`/adventures/${adventureId}/levels/${levelId}/`)) { console.log(` Patched: public/sitemap.xml`); } -// 4. Print the YAML snippet to paste +// 4. Patch ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs +const leaderboardScriptPath = resolve(ROOT, "scripts/refresh-leaderboard.mjs"); +const levelFlag = `has_${levelId}`; +const leaderboardContent = readFileSync(leaderboardScriptPath, "utf-8"); +const escapedId = adventureId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const flagFalseRegex = new RegExp(`("${escapedId}"[^\\n]*\\b${levelFlag}:\\s*)false`); + +if (flagFalseRegex.test(leaderboardContent)) { + writeFileSync(leaderboardScriptPath, leaderboardContent.replace(flagFalseRegex, "$1true")); + console.log(` Patched: scripts/refresh-leaderboard.mjs (${levelFlag}: true)`); +} else if (new RegExp(`"${escapedId}"`).test(leaderboardContent)) { + console.log(` Skipped: scripts/refresh-leaderboard.mjs (${levelFlag} already true)`); +} else { + console.warn(` Warning: "${adventureId}" not found in ADVENTURE_CATEGORIES in scripts/refresh-leaderboard.mjs. Add it manually.`); +} + +// 5. Print the YAML snippet to paste console.log(`\n\x1b[32mDone!\x1b[0m Level "${levelId}" config added for "${adventureId}".\n`); console.log("Paste this into the levels array in src/data/adventures/" + adventureId + "/adventure.yaml:\n"); console.log(` - id: ${levelId} - name: "TODO: Level name" + # required | e.g. "Stand Up the Lab" / "Outcome by Cohort" / "Lights On" + name: "TODO: Replace with level display name" difficulty: ${difficulty} topics: - - "TODO: Add topic tags" + # required | technology and tool names shown as pill tags on the level card. + - "TODO: Replace with technology name" + - "TODO: Replace with another technology name" learnings: - - "TODO: Add learning 1" - devcontainerPath: ".devcontainer/TODO/devcontainer.json" - discussionUrl: "/t/TODO" + # required | full sentences describing concrete skills or insights gained. Use >- for prose with colons. + - >- + TODO: How the client and provider work together: the SDK is provider-agnostic and plugs in via dependency only + - >- + TODO: Why hot-reload matters operationally: editing the flag file flips behaviour on the next request with no redeploy + devcontainerPath: ".devcontainer/TODO/devcontainer.json" # required + discussionUrl: "/t/TODO" # required intro: - - "TODO: Add intro paragraph" - backstory: - - "TODO: Add backstory" + # required | 1-2 sentences: what the player will wire up and what they will prove works. + - >- + TODO: Wire the SDK into the service so flag evaluations are resolved by a sidecar running alongside your Codespace. + Prove that editing the flag file flips the response on the next request without restarting anything. objective: - - "TODO: Add objective 1" + # required | verifiable outcomes a player can check with a command. + - >- + TODO: curl http://localhost:8080/ returns a value resolved from the flag file, not the hard-coded fallback + - >- + TODO: Editing the flag file and re-running curl returns the new value without restarting the service + # optional | uncomment to describe who this level is aimed at and what prior knowledge helps. + # audience: >- + # Best suited for platform engineers and developers new to feature flagging. Familiarity with basic + # Java and Spring Boot helps but no prior SDK experience is needed. + # optional | narrative context that sets the scene for this specific level. + # backstory: + # - >- + # The service is returning a hard-coded response. The SDK was integrated last quarter but the provider + # was never registered, so every request falls back to the default. Complete the wiring so the service + # reads from the flag file instead. + # optional | describe the technical setup (services, ports, how they connect). + # architecture: + # - >- + # Two containers run side-by-side: the app on http://localhost:8080 and a sidecar on port 8013. + # - >- + # Edit the flag file through the IDE; the file watcher picks up changes within about a second. toolbox: - - name: "TODO" - description: "TODO: Add tool description" + # required | list the CLI tools and services available in the Codespace. Add a url field for external docs. + - name: "TODO: ./run.sh" + description: "TODO: Starts the service; also available via F5 in VS Code" + - name: "curl" + description: "TODO: sends requests to http://localhost:8080/ to confirm flags are being evaluated" + url: "https://curl.se/" howToPlay: - - title: "TODO: Step 1" - body: "TODO: Add instructions" + # required | walk the player from the broken state to a working solution, one titled step at a time. + # Use fenced code blocks for commands. First step: confirm the broken state. Last step: run the verifier. + - title: "Confirm the Broken State" + body: | + TODO: Start the service and confirm the hard-coded fallback is returned: + + \`\`\`sh + ./run.sh + curl http://localhost:8080/ + # returns the fallback — this is the broken state + \`\`\` + - title: "TODO: Replace with main fix step title" + body: >- + TODO: Replace with instructions for the main fix. What file to open, what to add or change. + - title: "Verify the Fix" + body: | + TODO: Edit the flag file and re-run curl without restarting anything: + + \`\`\`sh + curl http://localhost:8080/ + # now returns the flag-resolved value + \`\`\` + helpfulLinks: + # optional | reference docs the player will need. label is the link text; url must be a full https:// URL. + - label: "TODO: SDK documentation" + url: https://example.com/docs + - label: "TODO: Flag definition reference" + url: https://example.com/flags verification: + # required | keep the defaults unless the verification script name or message differs. command: "./verify.sh" description: "Once you think you've solved the challenge, run the verification script."`); console.log(`\nNext steps:`); console.log(` 1. Paste the snippet above into src/data/adventures/${adventureId}/adventure.yaml`); console.log(` 2. Fill in the TODOs`); -console.log(` 3. Update the discussionUrl in the YAML and src/data/adventures/${adventureId}/${levelId}-posts.json`); -console.log(` 4. Run: npm run generate`); -console.log(` 5. Run: node scripts/refresh-discussions.mjs`); -console.log(` 6. Run: npm run lint && npm test && npm run build && npm run test:e2e`); +console.log(` 3. Add the level route to src/routes.ts`); +console.log(` 4. Update the discussionUrl in the YAML and src/data/adventures/${adventureId}/${levelId}-posts.json`); +console.log(` 5. Run: npm run generate`); +console.log(` 6. Run: node scripts/refresh-discussions.mjs`); +console.log(` 7. Run: node scripts/refresh-leaderboard.mjs (requires DISCOURSE_API_KEY)`); +console.log(` 8. Add the level URL to e2e/smoke.spec.ts, src/test/seo.test.ts, and src/test/prerender.test.ts`); +console.log(` 9. Update the routes table in README.md`); +console.log(` 10. Run: npm run lint && npm test && npm run build && npm run test:e2e`); diff --git a/scripts/refresh-leaderboard.mjs b/scripts/refresh-leaderboard.mjs index 6449efdf..d6af1045 100644 --- a/scripts/refresh-leaderboard.mjs +++ b/scripts/refresh-leaderboard.mjs @@ -38,7 +38,7 @@ const ADVENTURE_CATEGORIES = { "echoes-lost-in-orbit": { categoryId: 35, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, "building-cloudhaven": { categoryId: 36, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, "the-ai-observatory": { categoryId: 37, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, - "blind-by-design": { categoryId: 41, has_beginner: true, has_intermediate: true, has_expert: false, has_single: false }, + "blind-by-design": { categoryId: 41, has_beginner: true, has_intermediate: true, has_expert: true, has_single: false }, }; // Load .env file for local development. Never used in CI (secrets are env vars). diff --git a/src/assets/diagrams/blind-by-design-expert.svg b/src/assets/diagrams/blind-by-design-expert.svg new file mode 100644 index 00000000..fb797e5c --- /dev/null +++ b/src/assets/diagrams/blind-by-design-expert.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 520"><desc style="fill:#000;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">Four service boxes connected by arrows: Spring Boot app sends OTLP/gRPC to grafana/otel-lgtm, connects via OpenFeature SDK to flagd, and k6 loadgen polls flagd and scrapes metrics from otel-lgtm.</desc><defs><marker id="a" markerHeight="6" markerWidth="6" orient="auto-start-reverse" refX="8" refY="5" viewBox="0 0 10 10"><path fill="none" stroke="context-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2 1 6 4-6 4"/></marker></defs><rect width="210" height="120" x="30" y="30" fill="#85b7eb" stroke="#185fa5" stroke-width=".5" rx="8" style="fill:#85b7eb;stroke:#185fa5;color:#fff;stroke-width:.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="135" y="60" fill="#042c53" dominant-baseline="central" font-family="sans-serif" font-size="14" font-weight="500" style="fill:#042c53;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:14px;font-weight:500;text-anchor:middle;dominant-baseline:central" text-anchor="middle">Spring Boot</text><text x="135" y="82" fill="#0c447c" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#0c447c;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">fun-with-flags-java-spring</text><text x="135" y="100" fill="#0c447c" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#0c447c;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">flag eval + HTTP</text><text x="135" y="118" fill="#0c447c" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#0c447c;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8080</text><rect width="210" height="120" x="440" y="30" fill="#5dcaa5" stroke="#0f6e56" stroke-width=".5" rx="8" style="fill:#5dcaa5;stroke:#0f6e56;color:#fff;stroke-width:.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="545" y="55" fill="#04342c" dominant-baseline="central" font-family="sans-serif" font-size="14" font-weight="500" style="fill:#04342c;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:14px;font-weight:500;text-anchor:middle;dominant-baseline:central" text-anchor="middle">grafana/otel-lgtm</text><text x="545" y="76" fill="#085041" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#085041;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">Grafana :3000</text><text x="545" y="93" fill="#085041" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#085041;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">Prometheus :9090</text><text x="545" y="110" fill="#085041" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#085041;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">Tempo :3200</text><path fill="none" stroke="#4a6e8f" stroke-width="1.5" marker-end="url(#a)" d="M240 75h198" style="fill:none;stroke:#4a6e8f;color:#fff;stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="339" y="63" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">OTLP/gRPC :4317</text><rect width="210" height="180" x="30" y="310" fill="#afa9ec" stroke="#534ab7" stroke-width=".5" rx="8" style="fill:#afa9ec;stroke:#534ab7;color:#fff;stroke-width:.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="135" y="335" fill="#26215c" dominant-baseline="central" font-family="sans-serif" font-size="14" font-weight="500" style="fill:#26215c;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:14px;font-weight:500;text-anchor:middle;dominant-baseline:central" text-anchor="middle">flagd</text><text x="135" y="356" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8013 gRPC + HTTP eval</text><text x="135" y="374" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8014 management/metrics</text><text x="135" y="392" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8015 sync stream</text><text x="135" y="410" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8016 OFREP</text><text x="135" y="428" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">flags.json mounted</text><text x="135" y="446" fill="#3c3489" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#3c3489;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">:8013 (RPC mode)</text><rect width="210" height="180" x="440" y="310" fill="#ef9f27" stroke="#854f0b" stroke-width=".5" rx="8" style="fill:#ef9f27;stroke:#854f0b;color:#fff;stroke-width:.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="545" y="340" fill="#412402" dominant-baseline="central" font-family="sans-serif" font-size="14" font-weight="500" style="fill:#412402;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:14px;font-weight:500;text-anchor:middle;dominant-baseline:central" text-anchor="middle">k6 loadgen</text><text x="545" y="362" fill="#633806" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#633806;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">HTTP GET /?userId=…</text><text x="545" y="382" fill="#633806" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#633806;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">lab interceptor sets</text><text x="545" y="400" fill="#633806" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#633806;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">userId as targetingKey</text><text x="545" y="420" fill="#633806" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#633806;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">fractional rollouts</text><text x="545" y="440" fill="#633806" dominant-baseline="central" font-family="sans-serif" font-size="12" style="fill:#633806;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:central" text-anchor="middle">bucket on this key</text><path fill="none" stroke="#4a6e8f" stroke-width="1.5" marker-end="url(#a)" d="M135 150v158" style="fill:none;stroke:#4a6e8f;color:#fff;stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="93" y="230" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">OpenFeature</text><text x="93" y="246" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">SDK :8013</text><text x="93" y="262" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">(RPC mode)</text><path fill="none" stroke="#4a6e8f" stroke-width="1.5" marker-end="url(#a)" d="M545 308V152" style="fill:none;stroke:#4a6e8f;color:#fff;stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="590" y="236" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">scrape /</text><text x="590" y="252" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">pull</text><path fill="none" stroke="#4a6e8f" stroke-width="1.5" marker-end="url(#a)" d="M438 400H242" style="fill:none;stroke:#4a6e8f;color:#fff;stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:"Anthropic Sans",-apple-system,"system-ui","Segoe UI",sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/><text x="340" y="388" fill="#4a6e8f" font-family="sans-serif" font-size="12" style="fill:#4a6e8f;stroke:none;color:#fff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto" text-anchor="middle">poll loadgen flag</text></svg> \ No newline at end of file diff --git a/src/components/WalkthroughSection.tsx b/src/components/WalkthroughSection.tsx index 4ab2f196..62897fcb 100644 --- a/src/components/WalkthroughSection.tsx +++ b/src/components/WalkthroughSection.tsx @@ -1,4 +1,6 @@ +import { useState } from "react"; import type { JSX } from "react"; +import { ChevronDown } from "lucide-react"; import { CollapsibleSection } from "@/components/CollapsibleSection"; import { MarkdownContent } from "@/components/MarkdownContent"; import type { WalkthroughStep } from "@/data/adventures"; @@ -7,34 +9,60 @@ type WalkthroughSectionProps = { steps: WalkthroughStep[]; }; -export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Element => ( - <CollapsibleSection id="walkthrough" title="Walkthrough"> - <ol className="space-y-5"> - {steps.map((step, i) => ( - <li - key={i} - className="rounded-lg border border-[hsl(var(--surface-border))] bg-background/40 p-5" - > - <div className="flex items-start gap-4"> - <span - className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold" - aria-hidden="true" +export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Element => { + const [openSteps, setOpenSteps] = useState<boolean[]>(() => steps.map(() => true)); + + const toggle = (i: number): void => + setOpenSteps((prev) => prev.map((open, idx) => (idx === i ? !open : open))); + + return ( + <CollapsibleSection id="walkthrough" title="Walkthrough"> + <ol className="space-y-3"> + {steps.map((step, i) => { + const isOpen = openSteps[i] ?? true; + const contentId = `walkthrough-step-${i}`; + return ( + <li + key={i} + className="rounded-lg border border-[hsl(var(--surface-border))] bg-background/40" > - {i + 1} - </span> - <div className="min-w-0 flex-1"> - {step.title && ( - <h3 className="text-sm font-semibold text-foreground mb-2"> - {step.title} - </h3> + <button + type="button" + onClick={() => toggle(i)} + aria-expanded={isOpen} + aria-controls={contentId} + className="flex w-full items-start gap-4 p-5 text-left" + > + <span + className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold" + aria-hidden="true" + > + {i + 1} + </span> + <span className="min-w-0 flex-1 text-sm font-semibold text-foreground"> + {step.title ?? `Step ${i + 1}`} + </span> + <ChevronDown + size={16} + className={`mt-0.5 shrink-0 text-[hsl(var(--text-faint))] transition-transform duration-200 ${isOpen ? "rotate-0" : "-rotate-90"}`} + aria-hidden="true" + /> + </button> + {isOpen && ( + <div + id={contentId} + className="grid grid-cols-[1.25rem_1fr] gap-4 px-5 pb-5" + > + <div aria-hidden="true" /> + <div className="min-w-0 text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> + <MarkdownContent source={step.body} /> + </div> + </div> )} - <div className="text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> - <MarkdownContent source={step.body} /> - </div> - </div> - </div> - </li> - ))} - </ol> - </CollapsibleSection> -); + </li> + ); + })} + </ol> + </CollapsibleSection> + ); +}; diff --git a/src/data/adventures/blind-by-design.generated.ts b/src/data/adventures/blind-by-design.generated.ts index 9ce4539a..a67bcad4 100644 --- a/src/data/adventures/blind-by-design.generated.ts +++ b/src/data/adventures/blind-by-design.generated.ts @@ -1,5 +1,6 @@ import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants"; import blindByDesignIntermediate from "@/assets/diagrams/blind-by-design-intermediate.svg"; +import blindByDesignExpert from "@/assets/diagrams/blind-by-design-expert.svg"; import type { Adventure } from "./types"; export const BLIND_BY_DESIGN: Adventure = { @@ -101,6 +102,7 @@ curl -s http://localhost:8080/ | jq command: "./verify.sh", description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion.", }, + metaDescription: "Wire the OpenFeature Java SDK and flagd provider into a Spring Boot service. Author a flag in flags.json and prove hot-reload works without restarting the app.", }, { id: "intermediate", @@ -108,18 +110,15 @@ curl -s http://localhost:8080/ | jq difficulty: "Intermediate", topics: ["OpenFeature", "flagd", "Spring Boot", "Java"], learnings: [ - "How evaluation context works in OpenFeature: passing runtime attributes (user ID, cohort, region) to influence flag resolution", - "How to configure flagd targeting rules to route specific cohorts to specific flag variants without code changes", - "Why cohort-based rollouts reduce blast radius: only the targeted segment sees the new behaviour", - "How to verify targeting is working correctly by inspecting flag evaluation results per context", + "How OpenFeature's transaction-context propagation works in a thread-per-request server, and why a ThreadLocalTransactionContextPropagator is the right primitive for Servlet-based apps", + "The difference between request-scoped context (the subject's species) and global evaluation context (the trial's country), and when each is the right tool", + "How hooks let you attach cross-cutting behaviour, audit logging today and OpenTelemetry tracing tomorrow, without modifying every flag evaluation call site", ], codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_02-intermediate%2Fdevcontainer.json&quickstart=1`, discussionUrl: `${COMMUNITY_URL}/t/outcome-by-cohort-adventure-04-intermediate/1485`, deadline: "26 May 2026 at 23:59 CET", intro: [ - "Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register a custom Hook.", - "Transaction context (request-scoped) is populated by a Spring HandlerInterceptor that reads ?species= and clears on afterCompletion so values don't leak across pooled threads. Global context (process-scoped) is set once at startup from the COUNTRY environment variable. Invocation context (call-site) is passed as the third argument to client.getStringDetails(), carrying the per-evaluation dose attribute. An Audit Hook fires after every evaluation and 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: make the targeting fire by wiring the three context layers and the audit hook.", + "Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register an AuditHook. Transaction context comes from a HandlerInterceptor, global context from the COUNTRY environment variable at startup, and invocation context at the call site. The targeting in flags.json already has three branches for species, dose, and country, but none fire yet because the context layers are missing.", ], backstory: [ "The trial is widening. Subjects from outside the lab's local population are getting the wrong reading on their chart, and the lab director has just walked in holding a stack of complaint forms. She wants the audit log to tell her, after the fact, exactly which vision_state the lab recorded for which subject, and she wants the lab to read the chart properly before it records any more bad readings.", @@ -133,10 +132,6 @@ curl -s http://localhost:8080/ | jq "Every evaluation produces an [AUDIT] log line naming the flag, the resolved variant, the reason, and the attributes that drove the outcome", "The response is never 'untreated' (that fallback only fires when the SDK cannot reach flagd)", ], - architecture: [ - "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 watches flags.json and serves evaluations from it.", - "Three context layers merge before flagd evaluates the targeting rules: global context (country, set at startup), transaction context (species, set per request by the interceptor), and invocation context (dose, passed at the call site). Precedence on conflict: invocation > transaction > global.", - ], architectureDiagram: blindByDesignIntermediate, diagramAlt: "HTTP flows through SpeciesInterceptor, Trial, and OpenFeature client left to right, then down through AuditHook and FlagdProvider, connecting via gRPC to a flagd sidecar.", toolbox: [ @@ -144,18 +139,21 @@ curl -s http://localhost:8080/ | jq { name: "./mvnw", description: "Spring Boot Maven Wrapper, no global Maven install required" }, { name: "curl", description: "sends requests to http://localhost:8080/ to test each targeting branch", url: "https://curl.se/" }, { name: "jq", description: "pretty-prints the JSON evaluation details", url: "https://jqlang.org/" }, + { name: "tail -f", description: "watches the application log live for [AUDIT] lines" }, ], howToPlay: [ - { title: "Wait for Setup", body: "Wait ~2-3 minutes for the Java toolchain to install." }, - { title: "Confirm the Broken State", body: `Start the lab and confirm the broken state, where no targeting fires yet. Stop the app and start fixing: + { title: "Wait for Setup", body: `Wait ~2-3 minutes for the Java toolchain to install. Use \`Cmd/Ctrl + Shift + P\` then \`View Creation Log\` to watch progress. When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in \`adventures/04-blind-by-design/intermediate/\`.` }, + { title: "Confirm the Broken State", body: `Start the lab and confirm the broken state, where no targeting fires yet: \`\`\`sh ./mvnw spring-boot:run curl 'http://localhost:8080/?species=zyklop' # returns 'blurry', wrong cohort, targeting can't fire \`\`\` + +That \`"blurry"\` is the starting point you want: even when the request shouts \`species=zyklop\`, the lab has nothing in its evaluation context, so flagd's targeting cannot fire and every subject drops to the default variant. Stop the app and start fixing. ` }, - { title: "Inspect the Targeting Rules", body: `Open \`flags.json\` and inspect the targeting. Three branches exist but none fire because nothing in the app populates the attributes yet: + { title: "Inspect the Starting Point", body: `The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the \`FlagdProvider\` is wired in \`Resolver.RPC\` mode against the flagd sidecar. Open \`flags.json\` and inspect the targeting. Three branches exist but none fire because nothing in the app populates \`species\`, \`country\`, or \`dose\` yet: \`\`\`json "targeting": { @@ -169,17 +167,19 @@ curl 'http://localhost:8080/?species=zyklop' Your job: populate \`species\`, \`country\`, and \`dose\` on the evaluation context so the targeting fires. ` }, - { title: "Build the SpeciesInterceptor", body: `Create a Spring \`HandlerInterceptor\` named \`SpeciesInterceptor\`: in \`preHandle\`, read \`?species=\` and put it on the transaction context; in \`afterCompletion\`, clear the context so values do not leak across pooled threads. Register a \`ThreadLocalTransactionContextPropagator\` once on the OpenFeature API in a static initializer.` }, - { title: "Wire OpenFeatureConfig", body: `Update \`OpenFeatureConfig\` to: register \`SpeciesInterceptor\` with Spring (\`WebMvcConfigurer.addInterceptors\`), read the \`COUNTRY\` environment variable and set it as the global evaluation context, and register \`AuditHook\` globally on the OpenFeature API.` }, - { title: "Update the Trial", body: `Update \`Trial\` so each evaluation passes \`dose\` on the invocation context (the third argument to \`client.getStringDetails\`). Default to 'standard', but make it overridable via \`?dose=\` so you can test each branch by hand.` }, - { title: "Implement AuditHook", body: `Implement \`AuditHook\`: in \`after()\`, write an \`[AUDIT]\` log line with flag name, variant, reason, and a fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is 'clouded', otherwise INFO. Implement \`error()\` so failed evaluations are not silent.` }, - { title: "Test All Targeting Branches", body: `Run the lab with country-specific scripts. These pipe output through \`tee app.log\`, which the verifier needs: + { title: "Build the SpeciesInterceptor", body: `Create a Spring \`HandlerInterceptor\` named \`SpeciesInterceptor\`: in \`preHandle\`, read \`?species=\` and put it on the transaction context; in \`afterCompletion\`, clear the context so values do not leak across pooled threads. Register a \`ThreadLocalTransactionContextPropagator\` once on the OpenFeature API in a static initializer. Without the propagator the SDK has no way to carry per-request context across the call into the controller, and the transaction context silently stays empty.` }, + { title: "Wire OpenFeatureConfig", body: `Update \`OpenFeatureConfig\` to: register \`SpeciesInterceptor\` with Spring (\`WebMvcConfigurer.addInterceptors\`), read the \`COUNTRY\` environment variable and set it as the global evaluation context, and register \`AuditHook\` globally on the OpenFeature API. The three context layers, global (\`country\`), transaction (\`species\` from the interceptor), and invocation (\`dose\` from \`Trial\`), merge before flagd evaluates the rules. Precedence on conflict is invocation over transaction over global.` }, + { title: "Update the Trial", body: `Update \`Trial\` so each evaluation passes \`dose\` on the invocation context (the third argument to \`client.getStringDetails\`). Default to \`'standard'\` most of the time but occasionally to \`'underdose'\` or \`'overdose'\`, that is the lab tech mis-measuring, and it is what makes the improper-dosing branch in \`flags.json\` fire at all. Make it overridable via \`?dose=\` so you can test each branch by hand. If the invocation context does not carry \`dose\`, the targeting rule sees \`null\` and the branch never fires: every non-zyklop request lands on either the country branch or the default.` }, + { title: "Implement AuditHook", body: `Implement \`AuditHook\`: in \`after()\`, write an \`[AUDIT]\` log line with flag name, variant, reason, and a fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is \`'clouded'\` so the safety officer can grep for it, otherwise INFO. Implement \`error()\` so failed evaluations are not silent. Use a fixed allowlist (\`List.of("species", "country", "dose")\`) rather than iterating the whole context: audit logs outlive app logs, and logging only what you decided to log pays off the moment something sensitive lands on the context.` }, + { title: "Test All Targeting Branches", body: `Run the lab with country-specific scripts. These pipe output through \`tee app.log\`, which the verifier greps for \`[AUDIT]\` lines. If you run \`./mvnw spring-boot:run\` directly, add \`| tee app.log\` or the verifier has nothing to grep: \`\`\`sh -./run-germany.sh # COUNTRY=de -./run-austria.sh # COUNTRY=at +./run-germany.sh # COUNTRY=de (or: make lab-germany) +./run-austria.sh # COUNTRY=at \`\`\` +Three named launch configs in \`.vscode/launch.json\` (Germany / Austria / No country) also let you switch cohorts from the Run and Debug view. + In another terminal, verify each branch: \`\`\`sh @@ -201,6 +201,8 @@ Check the audit trail: \`\`\`sh grep '\\[AUDIT\\]' app.log | head \`\`\` + +You should see one \`[AUDIT] flag=vision_state variant=... reason=... species=... country=... dose=...\` line per request. \`clouded\` outcomes log at WARN. ` }, ], helpfulLinks: [ @@ -213,6 +215,118 @@ grep '\\[AUDIT\\]' app.log | head command: "./verify.sh", description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion.", }, + metaDescription: "Add OpenFeature evaluation context and an AuditHook to a Spring Boot service. Target flag evaluations by species, country, and dose to record cohort outcomes.", + }, + { + id: "expert", + name: "Read the Chart", + difficulty: "Expert", + topics: ["OpenFeature", "OpenTelemetry", "Grafana", "Spring Boot"], + audience: "Platform engineers, SREs, and observability-focused developers who have completed the Beginner and Intermediate levels or are comfortable with OpenFeature evaluation context, and want to learn how flag evaluations join distributed traces and metrics, and how to use a flag flip as an operational lever for live rollbacks.", + learnings: [ + "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, closing the loop between why a flag resolved the way it did and what the operator sees in Tempo", + "How fractional 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", + ], + codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_03-expert%2Fdevcontainer.json&quickstart=1`, + discussionUrl: `${COMMUNITY_URL}/t/read-the-chart-adventure-04-expert/1530`, + deadline: "26 May 2026 at 23:59 CET", + intro: [ + "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.", + ], + backstory: [ + "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, with 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.", + ], + objective: [ + "Spans for fun-with-flags-java-spring are visible in Tempo with feature_flag.context.<key> attributes. Searching feature_flag.context.dose=underdose lights up requests where a subject was mis-dosed, with feature_flag.variant=clouded on the same span", + "feature_flag_evaluation_requests_total is non-zero in Prometheus: flag evaluations show up as counters, not just spans", + "The Feature Flag Metrics dashboard renders: variant distribution, error rate, and latency p99 are all populated from the metric counters", + "The vision_amplifier_v2 rollout is rolled back to 100% off without redeploying the lab", + "HTTP 5xx rate over the last minute drops below 1%: the bad arm is contained", + ], + architectureDiagram: blindByDesignExpert, + diagramAlt: "Architecture diagram showing four services: Spring Boot app sends traces via OTLP/gRPC to a Grafana LGTM stack, connects via OpenFeature SDK to flagd for feature flag evaluation, and a k6 load generator polls flagd and scrapes metrics from the LGTM stack.", + toolbox: [ + { name: "Java 21 (Temurin)", description: "pre-installed in the devcontainer", url: "https://adoptium.net/" }, + { name: "./mvnw", description: "Spring Boot Maven Wrapper, no global Maven install required" }, + { name: "curl", description: "sends requests to http://localhost:8080/ to test the lab, and to Prometheus on http://localhost:9090/ to query metrics directly", url: "https://curl.se/" }, + { name: "Grafana", description: "browser UI at http://localhost:3000 (admin/admin) for the Feature Flag Metrics dashboard and Tempo trace explorer" }, + { name: "jq", description: "pretty-prints the JSON evaluation details", url: "https://jqlang.org/" }, + ], + howToPlay: [ + { title: "Start Your Challenge", body: `The sibling containers (flagd, Grafana LGTM, k6 loadgen) start automatically as part of the devcontainer compose. Wait ~2-3 minutes for them to be ready before moving on. +` }, + { title: "Start the Lab", body: `The sibling containers are already up. Boot the Spring Boot lab by clicking **Run** on \`Laboratory\` in the Spring Boot Dashboard panel (or press **F5** with \`Laboratory.java\` open), or from the terminal: + +\`\`\`sh +./mvnw spring-boot:run +\`\`\` + +Spans start flowing into Tempo on the first request. The trace pipeline is already wired. The metrics pipeline is dead (task 4a), so the Grafana dashboard panels stay empty until you fix it. +` }, + { title: "Explore the UIs", body: `Open the **Ports** tab and navigate to each service: + +- **Port 8080:** Spring Boot lab. Add \`?userId=subject-42\` for a stable fractional-rollout bucketing key. +- **Port 3000:** Grafana (admin / admin). Open **Dashboards > Feature Flag Metrics** (empty for now). Try **Explore > Tempo** to see flag evaluations as span events nested inside HTTP request spans. +- **Port 9090:** Prometheus. Query metrics directly: + + \`\`\`sh + curl 'http://localhost:9090/api/v1/query?query=feature_flag_evaluation_requests_total' + \`\`\` +- **Port 3200:** Tempo's HTTP API, used by the verify script to assert traces are flowing. + +flagd runs on the docker-internal network only (\`flagd:8013\`). No port forwarding needed. +` }, + { title: "Turn On the Metrics Exporter", body: `OTel ships two parallel pipelines: traces (already flowing into Tempo) and metrics (dead). The OTel Java Agent attached to the lab JVM has both pipelines plumbed and pointed at the LGTM stack, but \`otel.properties\` (next to \`pom.xml\`) sets \`otel.metrics.exporter=none\`, so anything the meter records goes nowhere. + +Open \`otel.properties\` and flip the exporter on. While you're there, look at the export interval. The default makes the next steps harder than they need to be. + +Once the exporter is on, \`MetricsHook\` (next step) finds the working meter provider through \`GlobalOpenTelemetry\` without any further plumbing. You will need to restart the lab to pick up the change. +` }, + { title: "Register MetricsHook", body: `\`OpenFeatureConfig.java\` registers \`TracesHook\` but stops there. \`MetricsHook\` needs an \`OpenTelemetry\` handle to find the meter provider. The agent installs one globally at JVM start, so \`GlobalOpenTelemetry.get()\` is the way to reach it. + +Register \`MetricsHook\` alongside \`TracesHook\` in \`OpenFeatureConfig\`. The Feature Flag Metrics dashboard stays empty until traffic drives through. That is what the loadgen step does. +` }, + { title: "Write and Register ContextSpanHook", body: `The two contrib hooks tell you *what* happened: which flag, which variant, which reason. What is missing is the *why* visible in Tempo. Write a \`ContextSpanHook\` that copies the merged eval context attributes onto the active OTel span as \`feature_flag.context.<key>\`: + +\`\`\`text +before(hookCtx) { + span = active OTel span + for each allowlisted key in merged eval context: + span.setAttribute("feature_flag.context." + key, value) +} +\`\`\` + +\`HookContext.getCtx()\` returns the merged evaluation context (global + transaction + invocation). Use a fixed allowlist of \`List.of("species", "country", "dose")\`. Never iterate the whole context: \`targetingKey\` joins to PII in real apps, and span attributes are retained for days in Tempo at scale. + +Register \`ContextSpanHook\` alongside \`TracesHook\` and \`MetricsHook\` in \`OpenFeatureConfig\`. The verifier searches Tempo for \`feature_flag.context.dose=underdose\` once you are done. +` }, + { title: "Turn On the Loadgen", body: `\`flags.json\` has two flags: \`loadgen_active\` (off by default) and the misbehaving \`vision_amplifier_v2\`. flagd watches the file and picks up changes within about a second. + +Flip \`loadgen_active\` to on. The k6 loadgen polls it every two seconds and starts five virtual users hammering the lab. Within a minute, latency p99 should climb ~200ms and the 5xx rate ~10% on the dashboard, confirming that the bad arm of \`vision_amplifier_v2\` is active. +` }, + { title: "Roll Back the Rollout", body: `The dashboard's variant-distribution panel shows which variant is the culprit. Roll it back by editing \`flags.json\` to set \`vision_amplifier_v2\` to 100% off. + +**No deploy. No rebuild. No restart of the lab.** + +Watch the dashboard: the 5xx rate falls back to baseline, and the next batch of subjects walks out seeing. +` }, + ], + helpfulLinks: [ + { label: "OpenFeature OTel contrib hooks (Java)", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/hooks/open-telemetry" }, + { label: "OpenTelemetry Java Agent configuration", url: "https://opentelemetry.io/docs/zero-code/java/agent/configuration/" }, + { label: "OpenFeature Hooks concept", url: "https://openfeature.dev/docs/reference/concepts/hooks" }, + { label: "flagd fractional operation", url: "https://flagd.dev/reference/custom-operations/fractional-operation/" }, + { label: "OpenTelemetry security guidance", url: "https://opentelemetry.io/docs/security/" }, + ], + verification: { + command: "./verify.sh", + description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion.", + }, + metaDescription: "Wire OpenTelemetry metrics into an OpenFeature Java app, author a ContextSpanHook, then roll back a bad rollout by flipping a flag in flags.json. No redeploy.", }, ], }; diff --git a/src/data/adventures/blind-by-design/adventure.yaml b/src/data/adventures/blind-by-design/adventure.yaml index 0b2e0cad..0936119b 100644 --- a/src/data/adventures/blind-by-design/adventure.yaml +++ b/src/data/adventures/blind-by-design/adventure.yaml @@ -164,6 +164,7 @@ levels: url: https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd - label: flagd flag definitions url: https://flagd.dev/reference/flag-definitions/ + metaDescription: "Wire the OpenFeature Java SDK and flagd provider into a Spring Boot service. Author a flag in flags.json and prove hot-reload works without restarting the app." verification: command: "./verify.sh" description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion." @@ -178,26 +179,17 @@ levels: - Spring Boot - Java learnings: - - "How evaluation context works in OpenFeature: passing runtime attributes (user ID, cohort, region) to influence flag resolution" - - "How to configure flagd targeting rules to route specific cohorts to specific flag variants without code changes" - - "Why cohort-based rollouts reduce blast radius: only the targeted segment sees the new behaviour" - - "How to verify targeting is working correctly by inspecting flag evaluation results per context" + - "How OpenFeature's transaction-context propagation works in a thread-per-request server, and why a ThreadLocalTransactionContextPropagator is the right primitive for Servlet-based apps" + - "The difference between request-scoped context (the subject's species) and global evaluation context (the trial's country), and when each is the right tool" + - "How hooks let you attach cross-cutting behaviour, audit logging today and OpenTelemetry tracing tomorrow, without modifying every flag evaluation call site" devcontainerPath: ".devcontainer/04-blind-by-design_02-intermediate/devcontainer.json" discussionUrl: "/t/outcome-by-cohort-adventure-04-intermediate/1485" intro: - >- - Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register a custom Hook. - - >- - Transaction context (request-scoped) is populated by a Spring HandlerInterceptor that reads ?species= and - clears on afterCompletion so values don't leak across pooled threads. Global context (process-scoped) is set - once at startup from the COUNTRY environment variable. Invocation context (call-site) is passed as the third - argument to client.getStringDetails(), carrying the per-evaluation dose attribute. An Audit Hook fires after - every evaluation and 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: make the targeting fire by wiring the three context layers and the audit hook. + Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register an AuditHook. + Transaction context comes from a HandlerInterceptor, global context from the COUNTRY environment variable at + startup, and invocation context at the call site. The targeting in flags.json already has three branches for + species, dose, and country, but none fire yet because the context layers are missing. backstory: - >- The trial is widening. Subjects from outside the lab's local population are getting the wrong reading on @@ -216,14 +208,6 @@ levels: with its variant and reason. architectureDiagram: "blind-by-design-intermediate.svg" diagramAlt: "HTTP flows through SpeciesInterceptor, Trial, and OpenFeature client left to right, then down through AuditHook and FlagdProvider, connecting via gRPC to a flagd sidecar." - architecture: - - >- - 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 watches flags.json and serves evaluations from it. - - >- - Three context layers merge before flagd evaluates the targeting rules: global context (country, set at - startup), transaction context (species, set per request by the interceptor), and invocation context (dose, - passed at the call site). Precedence on conflict: invocation > transaction > global. objective: - "curl /?species=zyklop returns 'enhanced' regardless of dose or country" - "With COUNTRY=de, curl /?dose=standard returns 'sharp'; with COUNTRY=at, the same call falls through to the default" @@ -241,21 +225,25 @@ levels: - name: "jq" description: "pretty-prints the JSON evaluation details" url: "https://jqlang.org/" + - name: "tail -f" + description: "watches the application log live for [AUDIT] lines" howToPlay: - title: "Wait for Setup" - body: "Wait ~2-3 minutes for the Java toolchain to install." + body: "Wait ~2-3 minutes for the Java toolchain to install. Use `Cmd/Ctrl + Shift + P` then `View Creation Log` to watch progress. When the post-create finishes you'll have Java 21, the Maven wrapper, and the broken-state lab ready in `adventures/04-blind-by-design/intermediate/`." - title: "Confirm the Broken State" body: | - Start the lab and confirm the broken state, where no targeting fires yet. Stop the app and start fixing: + Start the lab and confirm the broken state, where no targeting fires yet: ```sh ./mvnw spring-boot:run curl 'http://localhost:8080/?species=zyklop' # returns 'blurry', wrong cohort, targeting can't fire ``` - - title: "Inspect the Targeting Rules" + + That `"blurry"` is the starting point you want: even when the request shouts `species=zyklop`, the lab has nothing in its evaluation context, so flagd's targeting cannot fire and every subject drops to the default variant. Stop the app and start fixing. + - title: "Inspect the Starting Point" body: | - Open `flags.json` and inspect the targeting. Three branches exist but none fire because nothing in the app populates the attributes yet: + The lab already has the OpenFeature SDK and the flagd contrib provider on the classpath, and the `FlagdProvider` is wired in `Resolver.RPC` mode against the flagd sidecar. Open `flags.json` and inspect the targeting. Three branches exist but none fire because nothing in the app populates `species`, `country`, or `dose` yet: ```json "targeting": { @@ -273,31 +261,42 @@ levels: Create a Spring `HandlerInterceptor` named `SpeciesInterceptor`: in `preHandle`, read `?species=` and put it on the transaction context; in `afterCompletion`, clear the context so values do not leak across pooled threads. Register a `ThreadLocalTransactionContextPropagator` once on the OpenFeature API in a - static initializer. + static initializer. Without the propagator the SDK has no way to carry per-request context across the + call into the controller, and the transaction context silently stays empty. - title: "Wire OpenFeatureConfig" body: >- Update `OpenFeatureConfig` to: register `SpeciesInterceptor` with Spring (`WebMvcConfigurer.addInterceptors`), read the `COUNTRY` environment variable and set it as the global - evaluation context, and register `AuditHook` globally on the OpenFeature API. + evaluation context, and register `AuditHook` globally on the OpenFeature API. The three context layers, + global (`country`), transaction (`species` from the interceptor), and invocation (`dose` from `Trial`), + merge before flagd evaluates the rules. Precedence on conflict is invocation over transaction over global. - title: "Update the Trial" body: >- Update `Trial` so each evaluation passes `dose` on the invocation context (the third argument to - `client.getStringDetails`). Default to 'standard', but make it overridable via `?dose=` so you can test - each branch by hand. + `client.getStringDetails`). Default to `'standard'` most of the time but occasionally to `'underdose'` or + `'overdose'`, that is the lab tech mis-measuring, and it is what makes the improper-dosing branch in + `flags.json` fire at all. Make it overridable via `?dose=` so you can test each branch by hand. If the + invocation context does not carry `dose`, the targeting rule sees `null` and the branch never fires: every + non-zyklop request lands on either the country branch or the default. - title: "Implement AuditHook" body: >- Implement `AuditHook`: in `after()`, write an `[AUDIT]` log line with flag name, variant, reason, and a - fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is 'clouded', - otherwise INFO. Implement `error()` so failed evaluations are not silent. + fixed allowlist of attributes (species, country, dose). Log at WARN when the variant is `'clouded'` so + the safety officer can grep for it, otherwise INFO. Implement `error()` so failed evaluations are not + silent. Use a fixed allowlist (`List.of("species", "country", "dose")`) rather than iterating the whole + context: audit logs outlive app logs, and logging only what you decided to log pays off the moment + something sensitive lands on the context. - title: "Test All Targeting Branches" body: | - Run the lab with country-specific scripts. These pipe output through `tee app.log`, which the verifier needs: + Run the lab with country-specific scripts. These pipe output through `tee app.log`, which the verifier greps for `[AUDIT]` lines. If you run `./mvnw spring-boot:run` directly, add `| tee app.log` or the verifier has nothing to grep: ```sh - ./run-germany.sh # COUNTRY=de - ./run-austria.sh # COUNTRY=at + ./run-germany.sh # COUNTRY=de (or: make lab-germany) + ./run-austria.sh # COUNTRY=at ``` + Three named launch configs in `.vscode/launch.json` (Germany / Austria / No country) also let you switch cohorts from the Run and Debug view. + In another terminal, verify each branch: ```sh @@ -319,6 +318,8 @@ levels: ```sh grep '\[AUDIT\]' app.log | head ``` + + You should see one `[AUDIT] flag=vision_state variant=... reason=... species=... country=... dose=...` line per request. `clouded` outcomes log at WARN. helpfulLinks: - label: OpenFeature Java SDK url: https://openfeature.dev/docs/reference/technologies/server/java/ @@ -328,6 +329,155 @@ levels: url: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html - label: flagd flag definitions url: https://flagd.dev/reference/flag-definitions/ + metaDescription: "Add OpenFeature evaluation context and an AuditHook to a Spring Boot service. Target flag evaluations by species, country, and dose to record cohort outcomes." + verification: + command: "./verify.sh" + description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion." + + - id: expert + name: "Read the Chart" + difficulty: Expert + deadline: "26 May 2026 at 23:59 CET" + audience: >- + Platform engineers, SREs, and observability-focused developers who have completed the + Beginner and Intermediate levels or are comfortable with OpenFeature evaluation context, + and want to learn how flag evaluations join distributed traces and metrics, and how to + use a flag flip as an operational lever for live rollbacks. + topics: + - OpenFeature + - OpenTelemetry + - Grafana + - Spring Boot + learnings: + - "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, closing the loop between why a flag resolved the way it did and what the operator sees in Tempo" + - "How fractional 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" + devcontainerPath: ".devcontainer/04-blind-by-design_03-expert/devcontainer.json" + discussionUrl: "/t/read-the-chart-adventure-04-expert/1530" + intro: + - >- + 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. + backstory: + - >- + 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, with 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. + architectureDiagram: "blind-by-design-expert.svg" + diagramAlt: "Architecture diagram showing four services: Spring Boot app sends traces via OTLP/gRPC to a Grafana LGTM stack, connects via OpenFeature SDK to flagd for feature flag evaluation, and a k6 load generator polls flagd and scrapes metrics from the LGTM stack." + objective: + - "Spans for fun-with-flags-java-spring are visible in Tempo with feature_flag.context.<key> attributes. Searching feature_flag.context.dose=underdose lights up requests where a subject was mis-dosed, with feature_flag.variant=clouded on the same span" + - "feature_flag_evaluation_requests_total is non-zero in Prometheus: flag evaluations show up as counters, not just spans" + - "The Feature Flag Metrics dashboard renders: variant distribution, error rate, and latency p99 are all populated from the metric counters" + - "The vision_amplifier_v2 rollout is rolled back to 100% off without redeploying the lab" + - "HTTP 5xx rate over the last minute drops below 1%: the bad arm is contained" + toolbox: + - name: "Java 21 (Temurin)" + description: "pre-installed in the devcontainer" + url: "https://adoptium.net/" + - name: "./mvnw" + description: "Spring Boot Maven Wrapper, no global Maven install required" + - name: "curl" + description: "sends requests to http://localhost:8080/ to test the lab, and to Prometheus on http://localhost:9090/ to query metrics directly" + url: "https://curl.se/" + - name: "Grafana" + description: "browser UI at http://localhost:3000 (admin/admin) for the Feature Flag Metrics dashboard and Tempo trace explorer" + - name: "jq" + description: "pretty-prints the JSON evaluation details" + url: "https://jqlang.org/" + howToPlay: + - title: "Start Your Challenge" + body: | + The sibling containers (flagd, Grafana LGTM, k6 loadgen) start automatically as part of the devcontainer compose. Wait ~2-3 minutes for them to be ready before moving on. + - title: "Start the Lab" + body: | + The sibling containers are already up. Boot the Spring Boot lab by clicking **Run** on `Laboratory` in the Spring Boot Dashboard panel (or press **F5** with `Laboratory.java` open), or from the terminal: + + ```sh + ./mvnw spring-boot:run + ``` + + Spans start flowing into Tempo on the first request. The trace pipeline is already wired. The metrics pipeline is dead (task 4a), so the Grafana dashboard panels stay empty until you fix it. + - title: "Explore the UIs" + body: | + Open the **Ports** tab and navigate to each service: + + - **Port 8080:** Spring Boot lab. Add `?userId=subject-42` for a stable fractional-rollout bucketing key. + - **Port 3000:** Grafana (admin / admin). Open **Dashboards > Feature Flag Metrics** (empty for now). Try **Explore > Tempo** to see flag evaluations as span events nested inside HTTP request spans. + - **Port 9090:** Prometheus. Query metrics directly: + + ```sh + curl 'http://localhost:9090/api/v1/query?query=feature_flag_evaluation_requests_total' + ``` + - **Port 3200:** Tempo's HTTP API, used by the verify script to assert traces are flowing. + + flagd runs on the docker-internal network only (`flagd:8013`). No port forwarding needed. + - title: "Turn On the Metrics Exporter" + body: | + OTel ships two parallel pipelines: traces (already flowing into Tempo) and metrics (dead). The OTel Java Agent attached to the lab JVM has both pipelines plumbed and pointed at the LGTM stack, but `otel.properties` (next to `pom.xml`) sets `otel.metrics.exporter=none`, so anything the meter records goes nowhere. + + Open `otel.properties` and flip the exporter on. While you're there, look at the export interval. The default makes the next steps harder than they need to be. + + Once the exporter is on, `MetricsHook` (next step) finds the working meter provider through `GlobalOpenTelemetry` without any further plumbing. You will need to restart the lab to pick up the change. + - title: "Register MetricsHook" + body: | + `OpenFeatureConfig.java` registers `TracesHook` but stops there. `MetricsHook` needs an `OpenTelemetry` handle to find the meter provider. The agent installs one globally at JVM start, so `GlobalOpenTelemetry.get()` is the way to reach it. + + Register `MetricsHook` alongside `TracesHook` in `OpenFeatureConfig`. The Feature Flag Metrics dashboard stays empty until traffic drives through. That is what the loadgen step does. + - title: "Write and Register ContextSpanHook" + body: | + The two contrib hooks tell you *what* happened: which flag, which variant, which reason. What is missing is the *why* visible in Tempo. Write a `ContextSpanHook` that copies the merged eval context attributes onto the active OTel span as `feature_flag.context.<key>`: + + ```text + before(hookCtx) { + span = active OTel span + for each allowlisted key in merged eval context: + span.setAttribute("feature_flag.context." + key, value) + } + ``` + + `HookContext.getCtx()` returns the merged evaluation context (global + transaction + invocation). Use a fixed allowlist of `List.of("species", "country", "dose")`. Never iterate the whole context: `targetingKey` joins to PII in real apps, and span attributes are retained for days in Tempo at scale. + + Register `ContextSpanHook` alongside `TracesHook` and `MetricsHook` in `OpenFeatureConfig`. The verifier searches Tempo for `feature_flag.context.dose=underdose` once you are done. + - title: "Turn On the Loadgen" + body: | + `flags.json` has two flags: `loadgen_active` (off by default) and the misbehaving `vision_amplifier_v2`. flagd watches the file and picks up changes within about a second. + + Flip `loadgen_active` to on. The k6 loadgen polls it every two seconds and starts five virtual users hammering the lab. Within a minute, latency p99 should climb ~200ms and the 5xx rate ~10% on the dashboard, confirming that the bad arm of `vision_amplifier_v2` is active. + - title: "Roll Back the Rollout" + body: | + The dashboard's variant-distribution panel shows which variant is the culprit. Roll it back by editing `flags.json` to set `vision_amplifier_v2` to 100% off. + + **No deploy. No rebuild. No restart of the lab.** + + Watch the dashboard: the 5xx rate falls back to baseline, and the next batch of subjects walks out seeing. + helpfulLinks: + - label: "OpenFeature OTel contrib hooks (Java)" + url: "https://github.com/open-feature/java-sdk-contrib/tree/main/hooks/open-telemetry" + - label: "OpenTelemetry Java Agent configuration" + url: "https://opentelemetry.io/docs/zero-code/java/agent/configuration/" + - label: "OpenFeature Hooks concept" + url: "https://openfeature.dev/docs/reference/concepts/hooks" + - label: "flagd fractional operation" + url: "https://flagd.dev/reference/custom-operations/fractional-operation/" + - label: "OpenTelemetry security guidance" + url: "https://opentelemetry.io/docs/security/" + metaDescription: "Wire OpenTelemetry metrics into an OpenFeature Java app, author a ContextSpanHook, then roll back a bad rollout by flipping a flag in flags.json. No redeploy." verification: command: "./verify.sh" description: "Once you think you've solved the challenge, run the verification script. If it fails it will tell you which checks didn't pass. If it passes, it generates a Certificate of Completion you can paste into the discussion." diff --git a/src/data/adventures/blind-by-design/expert-posts.json b/src/data/adventures/blind-by-design/expert-posts.json index b8c41dd3..b15100b5 100644 --- a/src/data/adventures/blind-by-design/expert-posts.json +++ b/src/data/adventures/blind-by-design/expert-posts.json @@ -1,3 +1,21 @@ { - "discussionUrl": "TODO: Add Discourse topic URL" + "discussionUrl": "https://community.open-ecosystem.com/t/read-the-chart-adventure-04-expert/1530", + "discussionPosts": [ + { + "username": "theharithsa", + "avatarUrl": "https://community.open-ecosystem.com/user_avatar/community.open-ecosystem.com/theharithsa/40/297_2.png", + "cooked": "Done with the challenge!!! — CERTIFICATE START — Adventure: 04-blind-by-design Level: expert User: Harithsa, Vishruth Repo: theharithsa/open-ecosystem-challenges Date: Wed May 20 12:19:44 PM UTC 2026 Url: — CERTIFICATE END — It is so satisfying to see traces flowing and turn on the lights without redeploying the lab. It was a fun challenge. Thanks again @simon.schrottner and thanks for posting @KatharinaSick", + "created_at": "2026-05-20T12:25:39.159Z", + "challengeSolved": true, + "topicUrl": "https://community.open-ecosystem.com/t/read-the-chart-adventure-04-expert/1530" + } + ], + "totalReplies": 1, + "solvers": [ + { + "username": "theharithsa", + "avatarUrl": "https://community.open-ecosystem.com/user_avatar/community.open-ecosystem.com/theharithsa/40/297_2.png", + "solvedAt": "2026-05-20T12:25:39.159Z" + } + ] } diff --git a/src/data/adventures/blind-by-design/leaderboard.json b/src/data/adventures/blind-by-design/leaderboard.json index 027ebf09..a65b2cb4 100644 --- a/src/data/adventures/blind-by-design/leaderboard.json +++ b/src/data/adventures/blind-by-design/leaderboard.json @@ -1,17 +1,17 @@ { - "updatedAt": "2026-05-26T11:26:15.332Z", + "updatedAt": "2026-05-28T12:48:50.346Z", "rows": [ { "rank": 1, "username": "theharithsa", "avatarUrl": "https://community.open-ecosystem.com/user_avatar/community.open-ecosystem.com/theharithsa/90/297.png", - "points": 200, - "challengesSolved": 2, + "points": 300, + "challengesSolved": 3, "beginnerPoints": 100, "intermediatePoints": 100, - "expertPoints": 0, + "expertPoints": 100, "singlePoints": 0, - "breakdown": ":green_circle: Wire OpenFeature + flagd into a Spring Boot service with zero setup | Adventure 04 : Beginner [beginner] rank #1 = 100 pts | 🟡 Outcome by cohort | Adventure 04: Intermediate [intermediate] rank #1 = 100 pts" + "breakdown": ":green_circle: Wire OpenFeature + flagd into a Spring Boot service with zero setup | Adventure 04 : Beginner [beginner] rank #1 = 100 pts | 🟡 Outcome by cohort | Adventure 04: Intermediate [intermediate] rank #1 = 100 pts | 🔴 Read the chart | Adventure 04: Expert [expert] rank #1 = 100 pts" } ] } diff --git a/src/data/adventures/building-cloudhaven.generated.ts b/src/data/adventures/building-cloudhaven.generated.ts index 71703410..d3cfe706 100644 --- a/src/data/adventures/building-cloudhaven.generated.ts +++ b/src/data/adventures/building-cloudhaven.generated.ts @@ -202,7 +202,7 @@ make apply ], howToPlay: [ { title: "Wait for the Environment", body: "Wait ~2 minutes for the environment to initialize." }, - { title: "Check the Mock API", body: "Open the Ports tab, find the GCP API Mock at port 30104. The port is set to public so GitHub Actions runners can reach the mock API during workflow runs." }, + { title: "Check the Mock API", body: "Open the Ports tab, find the GCP API Mock at port 30104. The port is set to public so GitHub Actions runners can reach the mock API during workflow runs. You may see a browser security warning when accessing it. This is expected. Click Continue to proceed." }, { title: "Fix the Workflows", body: `Fix the three workflows in \`.github/workflows/\`: - \`adventure02-expert-detect-drift.yaml\` @@ -211,7 +211,7 @@ make apply The OpenTofu configuration is correct, focus only on the workflow files.` }, { title: "Trigger Drift Detection", body: "Commit and push to main. Go to the Actions tab, select the drift detection workflow, and click Run workflow. The infrastructure has intentional drift, so the workflow should create a draft PR." }, - { title: "Trigger Validation", body: "Click Ready for Review on the draft PR to trigger the validation workflow. To re-trigger validation after pushing new changes, convert the PR back to draft then ready for review again." }, + { title: "Trigger Validation", body: "Click Ready for Review on the draft PR to trigger the validation workflow. To re-trigger validation after pushing new changes, convert the PR back to draft then Ready for Review again. Re-running a failed workflow uses the code from the original run, so toggling draft state is how you pick up new changes pushed to main." }, { title: "Merge and Apply", body: "When the PR is merged to main, the apply workflow runs automatically." }, { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution: diff --git a/src/data/adventures/building-cloudhaven/adventure.yaml b/src/data/adventures/building-cloudhaven/adventure.yaml index 2c912807..3075dbfa 100644 --- a/src/data/adventures/building-cloudhaven/adventure.yaml +++ b/src/data/adventures/building-cloudhaven/adventure.yaml @@ -252,7 +252,8 @@ levels: body: Wait ~2 minutes for the environment to initialize. - title: Check the Mock API body: Open the Ports tab, find the GCP API Mock at port 30104. The port is set to public so GitHub Actions runners can - reach the mock API during workflow runs. + reach the mock API during workflow runs. You may see a browser security warning when accessing it. This is expected. + Click Continue to proceed. - title: Fix the Workflows body: |- Fix the three workflows in `.github/workflows/`: @@ -267,7 +268,8 @@ levels: infrastructure has intentional drift, so the workflow should create a draft PR. - title: Trigger Validation body: Click Ready for Review on the draft PR to trigger the validation workflow. To re-trigger validation after pushing - new changes, convert the PR back to draft then ready for review again. + new changes, convert the PR back to draft then Ready for Review again. Re-running a failed workflow uses the code + from the original run, so toggling draft state is how you pick up new changes pushed to main. - title: Merge and Apply body: When the PR is merged to main, the apply workflow runs automatically. - title: Run the Smoke Test diff --git a/src/data/adventures/echoes-lost-in-orbit.generated.ts b/src/data/adventures/echoes-lost-in-orbit.generated.ts index 66b164bb..ff8da494 100644 --- a/src/data/adventures/echoes-lost-in-orbit.generated.ts +++ b/src/data/adventures/echoes-lost-in-orbit.generated.ts @@ -34,8 +34,7 @@ export const ECHOES_LOST_IN_ORBIT: Adventure = { discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40`, deadline: "10 December 2025 at 09:00 CET", intro: [ - "The Echo Server is down across both environments and messages are silent.", - "Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and restore proper multi-environment delivery to bring communications back online.", + "The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and restore proper multi-environment delivery.", ], backstory: [ "The Echo Server is misbehaving. Both environments seem to be down, and messages are silent.", @@ -99,8 +98,7 @@ adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8`, deadline: "24 December 2025 at 09:00 CET", intro: [ - "A canary rollout is stuck and the Zephyrians are still waiting to communicate.", - "Debug the broken progressive delivery system by writing PromQL health checks that let Argo Rollouts automatically validate and advance the deployment.", + "A canary rollout is stuck and the Zephyrians are still waiting to communicate. Debug the broken progressive delivery system by writing PromQL health checks that let Argo Rollouts automatically validate and advance the deployment.", ], backstory: [ "After fixing the communication outage in Level 1, the Intergalactic Union welcomed a new species: the Zephyrians.", @@ -123,14 +121,17 @@ adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh ], howToPlay: [ { title: "Wait for Infrastructure", body: "Wait ~5-10 minutes for infrastructure to deploy. After it deploys, the setup script starts port forwarding to the Argo Rollouts dashboard, keeping a terminal busy. Open a new terminal to run commands." }, - { title: "Access the Dashboards", body: `Open the Ports tab. Log into Argo CD at port 30100: + { title: "Access the Dashboards", body: `Open the **Ports** tab and navigate to each service: -\`\`\` -Username: readonly -Password: a-super-secure-password -\`\`\` +- **Port 30100:** Argo CD (shows sync status, lets you refresh applications after pushing commits): + \`\`\` + Username: readonly + Password: a-super-secure-password + \`\`\` +- **Port 30101:** Argo Rollouts. Shows canary deployment progress and analysis status. +- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. -Access Argo Rollouts at port 30101 and Prometheus at port 30102.` }, +Not a fan of user interfaces? You can also use the CLI tools to complete the challenge.` }, { title: "Fix the Manifests", body: `Review and fix the configuration in \`adventures/01-echoes-lost-in-orbit/intermediate/manifests/\`. This challenge uses Kustomize under the hood: a base set of manifests with environment-specific overlays (staging, prod). Argo CD detects and applies these automatically, so you don't need to run Kustomize commands manually.` }, @@ -150,14 +151,13 @@ Speed up Argo CD sync: argocd app get echo-server-staging --refresh argocd app get echo-server-prod --refresh \`\`\`` }, - { title: "Trigger and Monitor the Rollout", body: `After Argo CD syncs, retry the rollouts: + { title: "Trigger the Rollout", body: `After Argo CD syncs, retry the rollouts: \`\`\`sh kubectl argo rollouts retry rollout echo-server -n echo-staging kubectl argo rollouts retry rollout echo-server -n echo-prod -\`\`\` - -Watch canary progress (should advance 33% to 66% to 100%): +\`\`\`` }, + { title: "Watch the Rollout", body: `Watch canary progress (should advance 33% to 66% to 100%): \`\`\`sh kubectl argo rollouts get rollout echo-server -n echo-staging --watch @@ -197,8 +197,7 @@ adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4`, deadline: "14 January 2026 at 09:00 CET", intro: [ - "The observability pipeline is broken and HotROD's canary can't validate.", - "Wire an OpenTelemetry Collector with spanmetrics to convert distributed traces into Prometheus metrics, then write PromQL queries that catch idle canaries, high error rates, and latency spikes before they reach production.", + "The observability pipeline is broken and HotROD's canary can't validate. Wire an OpenTelemetry Collector with spanmetrics to convert distributed traces into Prometheus metrics, then write PromQL queries that catch idle canaries, high error rates, and latency spikes.", ], backstory: [ "After fixing the Zephyrian communications, word of your progressive release mastery spread across the galaxy. The Bytari, a highly advanced species from the Andromeda sector, were impressed.", @@ -220,14 +219,18 @@ adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh ], howToPlay: [ { title: "Wait for Infrastructure", body: "Wait ~5-10 minutes for infrastructure to deploy. Port forwarding starts automatically after infrastructure is ready, keeping a terminal busy. Open a new terminal to run commands." }, - { title: "Access the Dashboards", body: `Open the Ports tab. Log into Argo CD at port 30100: + { title: "Access the Dashboards", body: `Open the **Ports** tab and navigate to each service: -\`\`\` -Username: readonly -Password: a-super-secure-password -\`\`\` +- **Port 30100:** Argo CD (shows sync status, lets you refresh applications after pushing commits): + \`\`\` + Username: readonly + Password: a-super-secure-password + \`\`\` +- **Port 30101:** Argo Rollouts. Shows canary deployment progress and analysis status. +- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. +- **Port 30103:** Jaeger. Shows distributed traces from HotROD so you can verify that tracing is working end-to-end. -Access Argo Rollouts at 30101, Prometheus at 30102, and Jaeger at 30103.` }, +Not a fan of user interfaces? You can also use the CLI tools to complete the challenge.` }, { title: "Fix the Manifests", body: `Fix the manifests in \`adventures/01-echoes-lost-in-orbit/expert/manifests/\`. Use the Argo Rollouts dashboard, Prometheus UI, and Jaeger UI to debug and validate your changes.` }, { title: "Deploy Your Changes", body: `Commit and push to trigger the deployment: diff --git a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml index 0face394..c7c331c6 100644 --- a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml +++ b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml @@ -40,9 +40,9 @@ levels: discussionUrl: /t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40 deadline: "10 December 2025 at 09:00 CET" intro: - - The Echo Server is down across both environments and messages are silent. - - Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and restore proper - multi-environment delivery to bring communications back online. + - >- + The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration, + spot the templating pitfalls, and restore proper multi-environment delivery. backstory: - The Echo Server is misbehaving. Both environments seem to be down, and messages are silent. - "Your mission: investigate the Argo CD configuration and restore proper multi-environment delivery." @@ -123,9 +123,10 @@ levels: discussionUrl: /t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8 deadline: "24 December 2025 at 09:00 CET" intro: - - A canary rollout is stuck and the Zephyrians are still waiting to communicate. - - Debug the broken progressive delivery system by writing PromQL health checks that let Argo Rollouts - automatically validate and advance the deployment. + - >- + A canary rollout is stuck and the Zephyrians are still waiting to communicate. Debug the broken + progressive delivery system by writing PromQL health checks that let Argo Rollouts automatically + validate and advance the deployment. backstory: - "After fixing the communication outage in Level 1, the Intergalactic Union welcomed a new species: the Zephyrians." @@ -160,14 +161,17 @@ levels: Argo Rollouts dashboard, keeping a terminal busy. Open a new terminal to run commands. - title: Access the Dashboards body: |- - Open the Ports tab. Log into Argo CD at port 30100: + Open the **Ports** tab and navigate to each service: - ``` - Username: readonly - Password: a-super-secure-password - ``` + - **Port 30100:** Argo CD (shows sync status, lets you refresh applications after pushing commits): + ``` + Username: readonly + Password: a-super-secure-password + ``` + - **Port 30101:** Argo Rollouts. Shows canary deployment progress and analysis status. + - **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. - Access Argo Rollouts at port 30101 and Prometheus at port 30102. + Not a fan of user interfaces? You can also use the CLI tools to complete the challenge. - title: Fix the Manifests body: >- Review and fix the configuration in `adventures/01-echoes-lost-in-orbit/intermediate/manifests/`. @@ -206,7 +210,7 @@ levels: argocd app get echo-server-prod --refresh ``` - - title: Trigger and Monitor the Rollout + - title: Trigger the Rollout body: |- After Argo CD syncs, retry the rollouts: @@ -214,7 +218,8 @@ levels: kubectl argo rollouts retry rollout echo-server -n echo-staging kubectl argo rollouts retry rollout echo-server -n echo-prod ``` - + - title: Watch the Rollout + body: |- Watch canary progress (should advance 33% to 66% to 100%): ```sh @@ -259,10 +264,10 @@ levels: deadline: "14 January 2026 at 09:00 CET" discussionUrl: /t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4 intro: - - The observability pipeline is broken and HotROD's canary can't validate. - - Wire an OpenTelemetry Collector with spanmetrics to convert distributed traces into Prometheus metrics, then - write PromQL queries that catch idle canaries, high error rates, and latency spikes before they reach - production. + - >- + The observability pipeline is broken and HotROD's canary can't validate. Wire an OpenTelemetry Collector + with spanmetrics to convert distributed traces into Prometheus metrics, then write PromQL queries that + catch idle canaries, high error rates, and latency spikes. backstory: - After fixing the Zephyrian communications, word of your progressive release mastery spread across the galaxy. The Bytari, a highly advanced species from the Andromeda sector, were impressed. @@ -304,14 +309,18 @@ levels: ready, keeping a terminal busy. Open a new terminal to run commands. - title: Access the Dashboards body: |- - Open the Ports tab. Log into Argo CD at port 30100: - - ``` - Username: readonly - Password: a-super-secure-password - ``` - - Access Argo Rollouts at 30101, Prometheus at 30102, and Jaeger at 30103. + Open the **Ports** tab and navigate to each service: + + - **Port 30100:** Argo CD (shows sync status, lets you refresh applications after pushing commits): + ``` + Username: readonly + Password: a-super-secure-password + ``` + - **Port 30101:** Argo Rollouts. Shows canary deployment progress and analysis status. + - **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. + - **Port 30103:** Jaeger. Shows distributed traces from HotROD so you can verify that tracing is working end-to-end. + + Not a fan of user interfaces? You can also use the CLI tools to complete the challenge. - title: Fix the Manifests body: Fix the manifests in `adventures/01-echoes-lost-in-orbit/expert/manifests/`. Use the Argo Rollouts dashboard, Prometheus UI, and Jaeger UI to debug and validate your changes. diff --git a/src/data/adventures/summaries.ts b/src/data/adventures/summaries.ts index dbc39424..1dc29840 100644 --- a/src/data/adventures/summaries.ts +++ b/src/data/adventures/summaries.ts @@ -28,10 +28,21 @@ export const ADVENTURE_SUMMARIES: AdventureCardSummary[] = [ difficulty: "Intermediate", topics: ["OpenFeature", "flagd", "Spring Boot", "Java"], learnings: [ - "How evaluation context works in OpenFeature: passing runtime attributes (user ID, cohort, region) to influence flag resolution", - "How to configure flagd targeting rules to route specific cohorts to specific flag variants without code changes", - "Why cohort-based rollouts reduce blast radius: only the targeted segment sees the new behaviour", - "How to verify targeting is working correctly by inspecting flag evaluation results per context", + "How OpenFeature's transaction-context propagation works in a thread-per-request server, and why a ThreadLocalTransactionContextPropagator is the right primitive for Servlet-based apps", + "The difference between request-scoped context (the subject's species) and global evaluation context (the trial's country), and when each is the right tool", + "How hooks let you attach cross-cutting behaviour, audit logging today and OpenTelemetry tracing tomorrow, without modifying every flag evaluation call site", + ], + }, + { + id: "expert", + name: "Read the Chart", + difficulty: "Expert", + topics: ["OpenFeature", "OpenTelemetry", "Grafana", "Spring Boot"], + learnings: [ + "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, closing the loop between why a flag resolved the way it did and what the operator sees in Tempo", + "How fractional 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", ], }, ], diff --git a/src/data/adventures/the-ai-observatory.generated.ts b/src/data/adventures/the-ai-observatory.generated.ts index 6923600b..61c51b1b 100644 --- a/src/data/adventures/the-ai-observatory.generated.ts +++ b/src/data/adventures/the-ai-observatory.generated.ts @@ -47,7 +47,7 @@ export const THE_AI_OBSERVATORY: Adventure = { ], architecture: [ "All AI and observability infrastructure (Ollama, OpenTelemetry Collector, Jaeger) runs inside Kubernetes, while HubSystem runs as a local Python application outside the cluster.", - "This setup lets you focus on instrumentation without a build or deploy cycle: edit the Python code, run it, and see traces in Jaeger immediately.", + "This setup has two benefits: it lets you focus on instrumentation without wrestling with containers or Kubernetes deployments when updating the app, and it gives you fast iteration. Edit the Python code, run it, and see traces in Jaeger immediately. No build or deploy cycle.", ], toolbox: [ { name: "python", description: "programming language used for the HubSystem application" }, @@ -124,7 +124,7 @@ That's not normal. ART is never vague. You access the ship's diagnostic systems ], howToPlay: [ { title: "Wait for Infrastructure", body: "Wait ~15 minutes for all infrastructure to initialize." }, - { title: "Access Observability Tools", body: "Open the Ports tab: Prometheus at port 30102, Jaeger at port 30103." }, + { title: "Access Observability Tools", body: "Open the Ports tab. Prometheus at port 30102 helps you explore available metrics and test PromQL queries. Jaeger at port 30103 shows distributed traces from ART so you can verify that tracing is working end-to-end." }, { title: "Instrument and Configure", body: `The application code is in \`./art.py\`. Instrument it with OpenLLMetry and add the custom metric. The Prometheus recording rules are in \`./manifests/prometheus-rule.yaml\`. After changing the rule file, apply it to the cluster: \`\`\`sh diff --git a/src/data/adventures/the-ai-observatory/adventure.yaml b/src/data/adventures/the-ai-observatory/adventure.yaml index a056372f..22629016 100644 --- a/src/data/adventures/the-ai-observatory/adventure.yaml +++ b/src/data/adventures/the-ai-observatory/adventure.yaml @@ -62,8 +62,9 @@ levels: architecture: - All AI and observability infrastructure (Ollama, OpenTelemetry Collector, Jaeger) runs inside Kubernetes, while HubSystem runs as a local Python application outside the cluster. - - "This setup lets you focus on instrumentation without a build or deploy cycle: edit the Python code, run it, and - see traces in Jaeger immediately." + - "This setup has two benefits: it lets you focus on instrumentation without wrestling with containers or Kubernetes + deployments when updating the app, and it gives you fast iteration. Edit the Python code, run it, and see traces + in Jaeger immediately. No build or deploy cycle." toolbox: - name: python description: programming language used for the HubSystem application @@ -171,7 +172,7 @@ levels: - title: Wait for Infrastructure body: Wait ~15 minutes for all infrastructure to initialize. - title: Access Observability Tools - body: "Open the Ports tab: Prometheus at port 30102, Jaeger at port 30103." + body: "Open the Ports tab. Prometheus at port 30102 helps you explore available metrics and test PromQL queries. Jaeger at port 30103 shows distributed traces from ART so you can verify that tracing is working end-to-end." - title: Instrument and Configure body: >- The application code is in `./art.py`. Instrument it with OpenLLMetry and add the custom metric. diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts index 0482ce0a..9770880e 100644 --- a/src/data/adventures/types.ts +++ b/src/data/adventures/types.ts @@ -73,6 +73,9 @@ export type AdventureLevel = { helpfulLinks?: HelpfulLink[]; // Verification card rendered as the final section. verification: VerificationInfo; + // Optional SEO meta description (max 160 chars). Used directly in the <meta name="description"> tag. + // When absent, ChallengeDetail.tsx generates one from the level name, learnings, difficulty, and adventure title. + metaDescription?: string; // Mock community stats shown in the CommunitySidebar. Real data will replace // these once we aggregate certificate posts and cross-challenge contribution. solvedCount?: number; diff --git a/src/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx index f64f9c82..cde3f36c 100644 --- a/src/pages/ChallengeDetail.tsx +++ b/src/pages/ChallengeDetail.tsx @@ -39,10 +39,11 @@ export const meta: MetaFunction = ({ params }) => { { name: "robots", content: "noindex, nofollow" }, ]; } - const learningsSummary = level.learnings.slice(0, 2).join("; "); + const description = level.metaDescription + ?? `${level.name}: get hands-on with ${level.learnings.slice(0, 2).join("; ")}. A ${level.difficulty.toLowerCase()} challenge from ${adventure.title} on ${BRAND_NAME}.`.slice(0, 160); return buildPageMeta({ title: `${level.name} - ${adventure.title} - ${BRAND_NAME}`, - description: `${level.name}: get hands-on with ${learningsSummary}. A ${level.difficulty.toLowerCase()} challenge from ${adventure.title} on ${BRAND_NAME}.`.slice(0, 160), + description, url: `${SITE_URL}/adventures/${adventure.id}/levels/${level.id}`, ogType: "article", extra: [ @@ -86,9 +87,13 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo {/* Intro as hook */} {intro && intro.length > 0 && ( - <p className="text-[hsl(var(--text-secondary))] leading-relaxed mb-5 max-w-3xl"> - {intro[0]} - </p> + <div className="space-y-3 mb-5 max-w-3xl"> + {intro.map((p, i) => ( + <p key={i} className="text-[hsl(var(--text-secondary))] leading-relaxed"> + {p} + </p> + ))} + </div> )} </div> diff --git a/src/test/prerender.test.ts b/src/test/prerender.test.ts index d1bddd42..319787d0 100644 --- a/src/test/prerender.test.ts +++ b/src/test/prerender.test.ts @@ -105,6 +105,10 @@ const pages: PageSpec[] = [ file: "adventures/blind-by-design/levels/intermediate/index.html", check: { type: "contains", value: "Outcome by Cohort" }, }, + { + file: "adventures/blind-by-design/levels/expert/index.html", + check: { type: "contains", value: "Read the Chart" }, + }, { file: "challenges/index.html", check: { type: "exact", value: "Open Source Challenges | OffOn" }, diff --git a/src/test/seo.test.ts b/src/test/seo.test.ts index ea2a7a18..27b37c0a 100644 --- a/src/test/seo.test.ts +++ b/src/test/seo.test.ts @@ -31,6 +31,7 @@ const ROUTES = [ "/adventures/blind-by-design", "/adventures/blind-by-design/levels/beginner", "/adventures/blind-by-design/levels/intermediate", + "/adventures/blind-by-design/levels/expert", "/challenges", "/challenges/argo-cd", "/challenges/argo-rollouts", From ec306d03fa47fbec6143a5cb4c8f3d76c897243a Mon Sep 17 00:00:00 2001 From: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> Date: Thu, 28 May 2026 15:54:20 +0200 Subject: [PATCH 3/3] fix(a11y): fix WalkthroughSection disclosure pattern and focus styles - Replace conditional rendering with `hidden` attribute so `aria-controls` always references a DOM element, as required by the ARIA disclosure pattern - Add `focus-visible:ring-*` classes to the step button, matching the focus style used on all other interactive elements in the codebase - Change chevron rotation from `-rotate-90` (collapsed) to `rotate-180` (expanded), consistent with CollapsibleSection's `rotate-0`/`rotate-180` convention - Compress two-line `metaDescription` comment in types.ts to one line Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com> --- src/components/WalkthroughSection.tsx | 23 +++++++++++------------ src/data/adventures/types.ts | 3 +-- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/WalkthroughSection.tsx b/src/components/WalkthroughSection.tsx index 62897fcb..6cf847dc 100644 --- a/src/components/WalkthroughSection.tsx +++ b/src/components/WalkthroughSection.tsx @@ -31,7 +31,7 @@ export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Elem onClick={() => toggle(i)} aria-expanded={isOpen} aria-controls={contentId} - className="flex w-full items-start gap-4 p-5 text-left" + className="flex w-full items-start gap-4 p-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg" > <span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold" @@ -44,21 +44,20 @@ export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Elem </span> <ChevronDown size={16} - className={`mt-0.5 shrink-0 text-[hsl(var(--text-faint))] transition-transform duration-200 ${isOpen ? "rotate-0" : "-rotate-90"}`} + className={`mt-0.5 shrink-0 text-[hsl(var(--text-faint))] transition-transform duration-200 ${isOpen ? "rotate-180" : "rotate-0"}`} aria-hidden="true" /> </button> - {isOpen && ( - <div - id={contentId} - className="grid grid-cols-[1.25rem_1fr] gap-4 px-5 pb-5" - > - <div aria-hidden="true" /> - <div className="min-w-0 text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> - <MarkdownContent source={step.body} /> - </div> + <div + id={contentId} + hidden={!isOpen} + className="grid grid-cols-[1.25rem_1fr] gap-4 px-5 pb-5" + > + <div aria-hidden="true" /> + <div className="min-w-0 text-sm text-[hsl(var(--text-secondary))] leading-relaxed"> + <MarkdownContent source={step.body} /> </div> - )} + </div> </li> ); })} diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts index 9770880e..120a0fae 100644 --- a/src/data/adventures/types.ts +++ b/src/data/adventures/types.ts @@ -73,8 +73,7 @@ export type AdventureLevel = { helpfulLinks?: HelpfulLink[]; // Verification card rendered as the final section. verification: VerificationInfo; - // Optional SEO meta description (max 160 chars). Used directly in the <meta name="description"> tag. - // When absent, ChallengeDetail.tsx generates one from the level name, learnings, difficulty, and adventure title. + // Optional SEO meta description (max 160 chars). When absent, ChallengeDetail.tsx generates one from level name, learnings, and difficulty. metaDescription?: string; // Mock community stats shown in the CommunitySidebar. Real data will replace // these once we aggregate certificate posts and cross-challenge contribution.