diff --git a/schemas/adventure.schema.json b/schemas/adventure.schema.json index 7aab0284..35d8e34e 100644 --- a/schemas/adventure.schema.json +++ b/schemas/adventure.schema.json @@ -4,17 +4,21 @@ "title": "Adventure", "description": "Schema for an OffOn adventure YAML file.", "type": "object", - "required": ["id", "title", "month", "story", "tags", "levels"], + "required": ["slug", "name", "month", "story", "technologies", "levels"], "additionalProperties": false, "properties": { - "id": { + "slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", - "description": "Kebab-case identifier. Must match the folder name." + "description": "Kebab-case URL slug. Must match the folder name." }, - "title": { + "name": { "type": "string", - "description": "Display title for the adventure." + "description": "Display name for the adventure." + }, + "icon": { + "type": "string", + "description": "Lucide React icon name representing this adventure (e.g. 'FlaskConical'). Used as a visual identity marker in the adventure header." }, "month": { "type": "string", @@ -25,11 +29,11 @@ "type": "string", "description": "One-paragraph summary of the adventure." }, - "tags": { + "technologies": { "type": "array", "items": { "type": "string" }, "minItems": 1, - "description": "Technology tags (e.g. ['OpenFeature', 'Spring Boot'])." + "description": "Technology names used in this adventure (e.g. ['OpenFeature', 'Spring Boot'])." }, "contributor": { "$ref": "#/$defs/contributor" @@ -39,23 +43,15 @@ "items": { "type": "string" }, "description": "Narrative backstory paragraphs shown on the adventure overview page." }, - "context": { - "type": "object", - "required": ["title", "body"], - "additionalProperties": false, - "properties": { - "title": { "type": "string" }, - "body": { - "type": "array", - "items": { "type": "string" } - } - }, - "description": "Optional 'What you'll be using' style context section." + "overview": { + "type": "array", + "items": { "type": "string" }, + "description": "Context paragraphs explaining what technologies or concepts the adventure covers. Rendered as a bulleted list under 'Your Mission'." }, "rewards": { "$ref": "#/$defs/rewards" }, - "upcomingLevels": { + "upcoming_levels": { "type": "array", "items": { "$ref": "#/$defs/upcomingLevel" }, "description": "Placeholder levels that haven't shipped yet." @@ -80,11 +76,11 @@ }, "rewards": { "type": "object", - "required": ["deadline", "eligibility", "tiers"], + "required": ["deadline", "tiers"], "additionalProperties": false, "properties": { - "deadline": { "type": "string" }, - "eligibility": { "type": "string" }, + "deadline": { "type": "string", "description": "ISO 8601 datetime (e.g. '2026-05-26T23:59:00+01:00')." }, + "eligibility": { "type": "string", "description": "Omit to use the standard eligibility text." }, "tiers": { "type": "array", "items": { @@ -97,8 +93,8 @@ } } }, - "rankingNote": { "type": "string" }, - "rankingRulesUrl": { "type": "string" } + "ranking_note": { "type": "string", "description": "Omit to use the standard ranking note." }, + "ranking_rules_url": { "type": "string", "description": "Omit to use the default community ranking rules path." } } }, "upcomingLevel": { @@ -115,7 +111,7 @@ }, "level": { "type": "object", - "required": ["id", "name", "difficulty", "topics", "learnings", "devcontainerPath", "discussionUrl", "intro", "objective", "toolbox", "howToPlay", "verification"], + "required": ["id", "name", "difficulty", "topics", "learnings", "devcontainer_path", "discussion_url", "intro", "objective", "toolbox", "how_to_play", "verification"], "additionalProperties": false, "properties": { "id": { "type": "string" }, @@ -133,17 +129,17 @@ "items": { "type": "string" }, "minItems": 1 }, - "devcontainerPath": { + "devcontainer_path": { "type": "string", "description": "Path to the devcontainer.json relative to the challenges repo root (e.g. '.devcontainer/04-blind-by-design_01-beginner/devcontainer.json'). The codegen script builds the full Codespaces URL from this." }, - "discussionUrl": { + "discussion_url": { "type": "string", "description": "Full Discourse topic URL or a path relative to COMMUNITY_URL (e.g. '/t/topic-slug/1419')." }, "deadline": { "type": "string", - "description": "Submission deadline for this level (e.g. '10 December 2025 at 09:00 CET'). Only shown when rewards are active." + "description": "Submission deadline for this level as an ISO 8601 string (e.g. '2025-12-10T09:00:00+01:00'). Only shown when rewards are active." }, "hook": { "type": "string" }, "intro": { @@ -164,11 +160,15 @@ "type": "array", "items": { "type": "string" } }, - "architectureDiagram": { + "architecture_diagram": { "type": "string", - "description": "Filename of the SVG diagram in src/assets/diagrams/ (e.g. 'blind-by-design-intermediate.svg')." + "description": "Filename of the SVG diagram in src/assets/diagrams/ (e.g. 'blind-by-design-intermediate.svg'). Takes priority over architecture_ascii." + }, + "diagram_alt": { "type": "string" }, + "architecture_ascii": { + "type": "string", + "description": "ASCII art diagram rendered as a
block when no SVG diagram is available. Use a YAML block scalar (|) to preserve whitespace."
},
- "diagramAlt": { "type": "string" },
"toolbox": {
"type": "array",
"items": {
@@ -182,15 +182,31 @@
}
}
},
- "howToPlay": {
+ "services": {
"type": "array",
+ "description": "Services accessible in the Codespace. The generator inserts an 'Explore the UIs' how_to_play step from this list. Use internal: true for services reachable only on the docker-internal network.",
"items": {
"type": "object",
- "required": ["title", "body"],
+ "required": ["name", "description"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string" },
+ "port": { "type": ["string", "integer"] },
+ "credentials": { "type": "string" },
+ "description": { "type": "string" },
+ "internal": { "type": "boolean" }
+ }
+ }
+ },
+ "how_to_play": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["title", "content"],
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
- "body": { "type": "string", "description": "Markdown content. Can contain code blocks." }
+ "content": { "type": "string", "description": "Markdown content. Can contain code blocks." }
}
}
},
@@ -203,26 +219,27 @@
"description": { "type": "string" }
}
},
- "helpfulLinks": {
+ "helpful_links": {
"type": "array",
"items": {
"type": "object",
- "required": ["label", "url"],
+ "required": ["title", "url"],
"additionalProperties": false,
"properties": {
- "label": { "type": "string" },
- "url": { "type": "string", "format": "uri" }
+ "title": { "type": "string" },
+ "url": { "type": "string", "format": "uri" },
+ "description": { "type": "string" }
}
},
"description": "Reference documentation links shown at the end of the challenge walkthrough."
},
- "metaDescription": {
+ "meta_description": {
"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": {
+ "solved_count": { "type": "integer" },
+ "top_players": {
"type": "array",
"items": {
"type": "object",
diff --git a/scripts/generate-adventures.mjs b/scripts/generate-adventures.mjs
index 500c22f0..5d532f05 100644
--- a/scripts/generate-adventures.mjs
+++ b/scripts/generate-adventures.mjs
@@ -36,6 +36,15 @@ const SCHEMA_PATH = resolve(ROOT, "schemas/adventure.schema.json");
const validateOnly = process.argv.includes("--validate-only");
+// Constant rewards fields shared by all adventures. Omit from YAML to use these defaults.
+const DEFAULT_REWARDS_ELIGIBILITY =
+ "Complete all levels and post your solution in the community before the deadline to be eligible.";
+const DEFAULT_REWARDS_RANKING_NOTE =
+ "Ranking is determined by total points across all three levels. Points per level are awarded" +
+ " by submission order within the active week (100 for the first valid solution, 95 for the" +
+ " second, and so on; late submissions still earn 60).";
+const DEFAULT_REWARDS_RANKING_RULES_PATH = "/t/about-the-challenges-category/16";
+
// --- Helpers ---
function toConstName(id) {
@@ -116,13 +125,13 @@ function findAdventureYamls() {
function validateAdventure(data, id) {
const errors = [];
- if (!data.id) errors.push("Missing required field: id");
- else if (data.id !== id) errors.push(`id "${data.id}" does not match folder name "${id}"`);
- if (!data.title) errors.push("Missing required field: title");
+ if (!data.slug) errors.push("Missing required field: slug");
+ else if (data.slug !== id) errors.push(`slug "${data.slug}" does not match folder name "${id}"`);
+ if (!data.name) errors.push("Missing required field: name");
if (!data.month) errors.push("Missing required field: month");
if (!data.story) errors.push("Missing required field: story");
- if (!data.tags || !Array.isArray(data.tags) || data.tags.length === 0) {
- errors.push("Missing or empty required field: tags");
+ if (!data.technologies || !Array.isArray(data.technologies) || data.technologies.length === 0) {
+ errors.push("Missing or empty required field: technologies");
}
if (!data.levels || !Array.isArray(data.levels) || data.levels.length === 0) {
errors.push("Missing or empty required field: levels");
@@ -140,12 +149,12 @@ function validateAdventure(data, id) {
if (!level.learnings || level.learnings.length === 0) {
errors.push(`${prefix}: Missing learnings`);
}
- if (!level.devcontainerPath) errors.push(`${prefix}: Missing devcontainerPath`);
- if (!level.discussionUrl) errors.push(`${prefix}: Missing discussionUrl`);
+ if (!level.devcontainer_path) errors.push(`${prefix}: Missing devcontainer_path`);
+ if (!level.discussion_url) errors.push(`${prefix}: Missing discussion_url`);
if (!level.intro || level.intro.length === 0) errors.push(`${prefix}: Missing intro`);
if (!level.objective || level.objective.length === 0) errors.push(`${prefix}: Missing objective`);
if (!level.toolbox || level.toolbox.length === 0) errors.push(`${prefix}: Missing toolbox`);
- if (!level.howToPlay || level.howToPlay.length === 0) errors.push(`${prefix}: Missing howToPlay`);
+ if (!level.how_to_play || level.how_to_play.length === 0) errors.push(`${prefix}: Missing how_to_play`);
if (!level.verification) errors.push(`${prefix}: Missing verification`);
}
}
@@ -173,16 +182,16 @@ function generateLevelCode(level, adventureId, indent) {
lines.push(`${i2}learnings: ${formatStringArray(level.learnings, i2)},`);
- // Build codespacesUrl from devcontainerPath
- const encodedPath = encodeURIComponent(level.devcontainerPath).replace(/%2F/g, "%2F");
+ // Build codespacesUrl from devcontainer_path
+ const encodedPath = encodeURIComponent(level.devcontainer_path).replace(/%2F/g, "%2F");
lines.push(`${i2}codespacesUrl: \`\${CODESPACES_BASE}?devcontainer_path=${encodedPath}&quickstart=1\`,`);
// Build discussionUrl
- if (level.discussionUrl.startsWith("http")) {
- lines.push(`${i2}discussionUrl: "${escapeDoubleQuoted(level.discussionUrl)}",`);
+ if (level.discussion_url.startsWith("http")) {
+ lines.push(`${i2}discussionUrl: "${escapeDoubleQuoted(level.discussion_url)}",`);
} else {
// Relative path: prepend COMMUNITY_URL
- const path = level.discussionUrl.startsWith("/") ? level.discussionUrl : `/${level.discussionUrl}`;
+ const path = level.discussion_url.startsWith("/") ? level.discussion_url : `/${level.discussion_url}`;
lines.push(`${i2}discussionUrl: \`\${COMMUNITY_URL}${path}\`,`);
}
@@ -194,12 +203,13 @@ function generateLevelCode(level, adventureId, indent) {
if (level.scenario) lines.push(`${i2}scenario: ${formatString(level.scenario)},`);
if (level.architecture) lines.push(`${i2}architecture: ${formatStringArray(level.architecture, i2)},`);
- if (level.architectureDiagram) {
- const baseName = level.architectureDiagram.replace(/\.svg$/, "");
+ if (level.architecture_diagram) {
+ const baseName = level.architecture_diagram.replace(/\.svg$/, "");
const varName = baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
lines.push(`${i2}architectureDiagram: ${varName},`);
}
- if (level.diagramAlt) lines.push(`${i2}diagramAlt: ${formatString(level.diagramAlt)},`);
+ if (level.diagram_alt) lines.push(`${i2}diagramAlt: ${formatString(level.diagram_alt)},`);
+ if (level.architecture_ascii) lines.push(`${i2}architectureAscii: ${formatString(level.architecture_ascii)},`);
if (level.toolbox && level.toolbox.length > 0) {
lines.push(`${i2}toolbox: [`);
@@ -211,18 +221,40 @@ function generateLevelCode(level, adventureId, indent) {
lines.push(`${i2}],`);
}
- if (level.howToPlay && level.howToPlay.length > 0) {
+ const steps = level.how_to_play ? [...level.how_to_play] : [];
+ if (level.services && level.services.length > 0) {
+ const accessible = level.services.filter((s) => !s.internal);
+ const internal = level.services.filter((s) => s.internal);
+ if (accessible.length > 0) {
+ let body = "Open the **Ports** tab and navigate to each service:\n\n";
+ for (const svc of accessible) {
+ const creds = svc.credentials ? ` (${svc.credentials})` : "";
+ body += `- **Port ${String(svc.port)}:** ${svc.name}${creds}. ${svc.description}`;
+ body += "\n";
+ }
+ if (internal.length > 0) {
+ body += "\n";
+ for (const svc of internal) {
+ body += `${svc.name} runs on the docker-internal network only. No port forwarding needed.\n`;
+ }
+ }
+ steps.splice(1, 0, { title: "Explore the UIs", content: body.trim() });
+ }
+ }
+ if (steps.length > 0) {
lines.push(`${i2}howToPlay: [`);
- for (const step of level.howToPlay) {
- lines.push(`${i2} { title: "${escapeDoubleQuoted(step.title)}", body: ${formatString(step.body)} },`);
+ for (const step of steps) {
+ lines.push(`${i2} { title: "${escapeDoubleQuoted(step.title)}", content: ${formatString(step.content)} },`);
}
lines.push(`${i2}],`);
}
- if (level.helpfulLinks && level.helpfulLinks.length > 0) {
+ if (level.helpful_links && level.helpful_links.length > 0) {
lines.push(`${i2}helpfulLinks: [`);
- for (const link of level.helpfulLinks) {
- lines.push(`${i2} { label: "${escapeDoubleQuoted(link.label)}", url: "${escapeDoubleQuoted(link.url)}" },`);
+ for (const link of level.helpful_links) {
+ const parts = [`title: "${escapeDoubleQuoted(link.title)}"`, `url: "${escapeDoubleQuoted(link.url)}"`];
+ if (link.description) parts.push(`description: "${escapeDoubleQuoted(link.description)}"`);
+ lines.push(`${i2} { ${parts.join(", ")} },`);
}
lines.push(`${i2}],`);
}
@@ -234,11 +266,11 @@ 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) {
+ if (level.meta_description) lines.push(`${i2}metaDescription: ${formatString(level.meta_description)},`);
+ if (level.solved_count !== undefined) lines.push(`${i2}solvedCount: ${level.solved_count},`);
+ if (level.top_players && level.top_players.length > 0) {
lines.push(`${i2}topPlayers: [`);
- for (const p of level.topPlayers) {
+ for (const p of level.top_players) {
lines.push(`${i2} { username: "${escapeDoubleQuoted(p.username)}", count: ${p.count} },`);
}
lines.push(`${i2}],`);
@@ -250,7 +282,7 @@ function generateLevelCode(level, adventureId, indent) {
function generateAdventureTs(data) {
const lines = [];
- const constName = toConstName(data.id);
+ const constName = toConstName(data.slug);
// Imports
lines.push(`import { CODESPACES_BASE, COMMUNITY_URL } from "@/data/constants";`);
@@ -258,11 +290,11 @@ function generateAdventureTs(data) {
// Collect diagram imports — use a camelCase variable name derived from the filename
const diagrams = new Map();
for (const level of data.levels) {
- if (level.architectureDiagram) {
- const baseName = level.architectureDiagram.replace(/\.svg$/, "");
+ if (level.architecture_diagram) {
+ const baseName = level.architecture_diagram.replace(/\.svg$/, "");
const varName = baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
if (!diagrams.has(baseName)) {
- diagrams.set(baseName, { varName, file: level.architectureDiagram });
+ diagrams.set(baseName, { varName, file: level.architecture_diagram });
}
}
}
@@ -273,11 +305,12 @@ function generateAdventureTs(data) {
lines.push(`import type { Adventure } from "./types";`);
lines.push(``);
lines.push(`export const ${constName}: Adventure = {`);
- lines.push(` id: "${data.id}",`);
- lines.push(` title: "${escapeDoubleQuoted(data.title)}",`);
+ lines.push(` id: "${data.slug}",`);
+ lines.push(` title: "${escapeDoubleQuoted(data.name)}",`);
+ if (data.icon) lines.push(` icon: "${escapeDoubleQuoted(data.icon)}",`);
lines.push(` month: "${data.month}",`);
lines.push(` story: ${formatString(data.story)},`);
- lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
+ lines.push(` tags: [${data.technologies.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
if (data.contributor) {
lines.push(` contributor: {`);
@@ -291,39 +324,35 @@ function generateAdventureTs(data) {
lines.push(` backstory: ${formatStringArray(data.backstory, " ")},`);
}
- if (data.context) {
- lines.push(` context: {`);
- lines.push(` title: "${escapeDoubleQuoted(data.context.title)}",`);
- lines.push(` body: ${formatStringArray(data.context.body, " ")},`);
- lines.push(` },`);
+ if (data.overview) {
+ lines.push(` overview: ${formatStringArray(data.overview, " ")},`);
}
if (data.rewards) {
+ const eligibility = data.rewards.eligibility ?? DEFAULT_REWARDS_ELIGIBILITY;
+ const rankingNote = data.rewards.ranking_note ?? DEFAULT_REWARDS_RANKING_NOTE;
+ const rankingRulesUrl = data.rewards.ranking_rules_url ?? DEFAULT_REWARDS_RANKING_RULES_PATH;
lines.push(` rewards: {`);
lines.push(` deadline: "${escapeDoubleQuoted(data.rewards.deadline)}",`);
- lines.push(` eligibility: "${escapeDoubleQuoted(data.rewards.eligibility)}",`);
+ lines.push(` eligibility: "${escapeDoubleQuoted(eligibility)}",`);
lines.push(` tiers: [`);
for (const tier of data.rewards.tiers) {
lines.push(` { label: "${escapeDoubleQuoted(tier.label)}", description: "${escapeDoubleQuoted(tier.description)}" },`);
}
lines.push(` ],`);
- if (data.rewards.rankingNote) {
- lines.push(` rankingNote: "${escapeDoubleQuoted(data.rewards.rankingNote)}",`);
- }
- if (data.rewards.rankingRulesUrl) {
- if (data.rewards.rankingRulesUrl.startsWith("http")) {
- lines.push(` rankingRulesUrl: "${escapeDoubleQuoted(data.rewards.rankingRulesUrl)}",`);
- } else {
- const path = data.rewards.rankingRulesUrl.startsWith("/") ? data.rewards.rankingRulesUrl : `/${data.rewards.rankingRulesUrl}`;
- lines.push(` rankingRulesUrl: \`\${COMMUNITY_URL}${path}\`,`);
- }
+ lines.push(` rankingNote: "${escapeDoubleQuoted(rankingNote)}",`);
+ if (rankingRulesUrl.startsWith("http")) {
+ lines.push(` rankingRulesUrl: "${escapeDoubleQuoted(rankingRulesUrl)}",`);
+ } else {
+ const path = rankingRulesUrl.startsWith("/") ? rankingRulesUrl : `/${rankingRulesUrl}`;
+ lines.push(` rankingRulesUrl: \`\${COMMUNITY_URL}${path}\`,`);
}
lines.push(` },`);
}
- if (data.upcomingLevels && data.upcomingLevels.length > 0) {
+ if (data.upcoming_levels && data.upcoming_levels.length > 0) {
lines.push(` upcomingLevels: [`);
- for (const ul of data.upcomingLevels) {
+ for (const ul of data.upcoming_levels) {
lines.push(` { name: "${escapeDoubleQuoted(ul.name)}", difficulty: "${ul.difficulty}" },`);
}
lines.push(` ],`);
@@ -357,11 +386,11 @@ function generateSummariesTs(adventures) {
for (const data of adventures) {
lines.push(` {`);
- lines.push(` id: "${data.id}",`);
- lines.push(` title: "${escapeDoubleQuoted(data.title)}",`);
+ lines.push(` id: "${data.slug}",`);
+ lines.push(` title: "${escapeDoubleQuoted(data.name)}",`);
lines.push(` month: "${data.month}",`);
lines.push(` story: ${formatString(data.story)},`);
- lines.push(` tags: [${data.tags.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
+ lines.push(` tags: [${data.technologies.map((t) => `"${escapeDoubleQuoted(t)}"`).join(", ")}],`);
if (data.contributor) {
lines.push(` contributor: { name: "${escapeDoubleQuoted(data.contributor.name)}" },`);
}
@@ -409,8 +438,8 @@ function generateIndexTs(adventures) {
// Imports for each adventure
for (const adv of adventures) {
- const constName = toConstName(adv.id);
- lines.push(`import { ${constName} } from "./${adv.id}.generated";`);
+ const constName = toConstName(adv.slug);
+ lines.push(`import { ${constName} } from "./${adv.slug}.generated";`);
}
lines.push(`import type { Adventure, AdventureContributor, RelatedLevel } from "./types";`);
lines.push(``);
@@ -418,7 +447,7 @@ function generateIndexTs(adventures) {
lines.push(``);
lines.push(`export const ADVENTURES: Adventure[] = [`);
for (const adv of adventures) {
- lines.push(` ${toConstName(adv.id)},`);
+ lines.push(` ${toConstName(adv.slug)},`);
}
lines.push(`];`);
lines.push(``);
@@ -519,9 +548,9 @@ function main() {
// Generate .generated.ts files
for (const data of adventures) {
const tsContent = generateAdventureTs(data);
- const outPath = resolve(ADVENTURES_DIR, `${data.id}.generated.ts`);
+ const outPath = resolve(ADVENTURES_DIR, `${data.slug}.generated.ts`);
writeFileSync(outPath, tsContent);
- console.log(` Generated: src/data/adventures/${data.id}.generated.ts`);
+ console.log(` Generated: src/data/adventures/${data.slug}.generated.ts`);
}
// Generate index.ts
diff --git a/src/components/ArchitectureSection.tsx b/src/components/ArchitectureSection.tsx
index c9ae32dc..a14e9bc4 100644
--- a/src/components/ArchitectureSection.tsx
+++ b/src/components/ArchitectureSection.tsx
@@ -6,9 +6,10 @@ type ArchitectureSectionProps = {
architecture?: string;
diagram?: string;
diagramAlt?: string;
+ ascii?: string;
};
-export const ArchitectureSection = ({ architecture, diagram, diagramAlt }: ArchitectureSectionProps): JSX.Element => (
+export const ArchitectureSection = ({ architecture, diagram, diagramAlt, ascii }: ArchitectureSectionProps): JSX.Element => (
{diagram ? (
+ ) : ascii ? (
+
+ {ascii}
+
) : (
architecture &&
)}
diff --git a/src/components/RewardsCard.tsx b/src/components/RewardsCard.tsx
index adde508a..7a845d5a 100644
--- a/src/components/RewardsCard.tsx
+++ b/src/components/RewardsCard.tsx
@@ -2,6 +2,7 @@ import { type JSX } from "react";
import { ExternalLink, Trophy } from "lucide-react";
import type { AdventureRewards } from "@/data/adventures/types";
import { COMMUNITY_URL } from "@/data/constants";
+import { formatDeadline } from "@/lib/utils";
type RewardsCardProps = {
rewards: AdventureRewards;
@@ -82,7 +83,7 @@ export const RewardsCard = ({ rewards, compact = false, levelDeadline, deadlineP
Deadline:{" "}
- {compact ? levelDeadline : rewards.deadline}
+ {compact ? (levelDeadline ? formatDeadline(levelDeadline) : null) : formatDeadline(rewards.deadline)}
>
diff --git a/src/components/WalkthroughSection.tsx b/src/components/WalkthroughSection.tsx
index 6cf847dc..c7ed7651 100644
--- a/src/components/WalkthroughSection.tsx
+++ b/src/components/WalkthroughSection.tsx
@@ -55,7 +55,7 @@ export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Elem
>
-
+
diff --git a/src/data/adventures/blind-by-design.generated.ts b/src/data/adventures/blind-by-design.generated.ts
index a67bcad4..751db979 100644
--- a/src/data/adventures/blind-by-design.generated.ts
+++ b/src/data/adventures/blind-by-design.generated.ts
@@ -6,6 +6,7 @@ import type { Adventure } from "./types";
export const BLIND_BY_DESIGN: Adventure = {
id: "blind-by-design",
title: "Blind by Design",
+ icon: "FlaskConical",
month: "MAY 2026",
story: "Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot service. Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort (Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving fractional rollout (Expert). All without redeploying.",
tags: ["OpenFeature", "flagd", "Spring Boot", "Java", "OpenTelemetry", "Grafana"],
@@ -19,15 +20,12 @@ export const BLIND_BY_DESIGN: Adventure = {
"It hasn't been. For the past eight months, every subject through the door has been recorded as \"untreated\": the integration was never finished, and the lab director assumed the system was reading the chart. Worse, eight weeks ago the Institute opened its flagship Phase 3 trial: a new amplifier variant rolled out fractionally to a cohort by a targeting rule in flags.json. Four adverse-event reports have since been filed, each one a subject whose vision_state at discharge was worse than at enrollment.",
"The monitoring is dark, not by accident, but because no one ever turned the lights on. Your mission across three levels: stand up the lab so it reads the chart, read the chart by cohort so outcomes can be tracked, then turn on the lights and roll back the Phase 3 variant before the director signs off on the next enrollment batch.",
],
- context: {
- title: "What you'll be using",
- body: [
- "OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is flagd, which serves flag definitions from a JSON file, locally or remotely, and the OpenFeature SDK in your application calls it on every evaluation.",
- "In this adventure, the lab uses OpenFeature exactly the way a real engineering team would: a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the targeting rules in flags.json decide what reading every subject ends up with. By the end, you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled back a misbehaving Phase 3 trial without redeploying.",
- ],
- },
+ overview: [
+ "OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native implementation is flagd, which serves flag definitions from a JSON file, locally or remotely, and the OpenFeature SDK in your application calls it on every evaluation.",
+ "In this adventure, the lab uses OpenFeature exactly the way a real engineering team would: a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the targeting rules in flags.json decide what reading every subject ends up with. By the end, you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled back a misbehaving Phase 3 trial without redeploying.",
+ ],
rewards: {
- deadline: "26 May 2026 at 23:59 CET",
+ deadline: "2026-05-26T23:59:00+01:00",
eligibility: "Complete all levels and post your solution in the community before the deadline to be eligible.",
tiers: [
{ label: "1st place", description: "50% voucher for a Linux Foundation certification" },
@@ -51,7 +49,7 @@ export const BLIND_BY_DESIGN: Adventure = {
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F04-blind-by-design_01-beginner%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/wire-openfeature-flagd-into-a-spring-boot-service-with-zero-setup-adventure-04-beginner/1419`,
- deadline: "26 May 2026 at 23:59 CET",
+ deadline: "2026-05-26T23:59:00+01:00",
intro: [
"Wire the OpenFeature Java SDK and the flagd contrib provider into a Spring Boot service so flag evaluations are resolved by a flagd sidecar against a flags.json file. Author your first flag, then prove that editing flags.json flips the response on the next request: no app restart, no flagd restart, no redeploy.",
],
@@ -75,18 +73,20 @@ export const BLIND_BY_DESIGN: Adventure = {
{ name: "flagd sidecar", description: "already running in the devcontainer compose stack on the docker-internal network, no port forwarding needed" },
],
howToPlay: [
- { title: "Start the Lab", body: `Run the lab from the terminal, or press F5 in VS Code with \`Laboratory.java\` open. The lab starts in the broken state, returning the hard-coded 'untreated' response:
+ { title: "Start the Lab", content: `Run the lab from the terminal, or press F5 in VS Code with \`Laboratory.java\` open. The lab starts in the broken state, returning the hard-coded 'untreated' response:
\`\`\`sh
./mvnw spring-boot:run
\`\`\`
` },
- { title: "Confirm the Broken State", body: "Open the Ports tab, set port 8080 to Public, then click the forwarded address to confirm the hard-coded 'untreated' response." },
- { title: "Add Dependencies", body: `Add the OpenFeature Java SDK and flagd contrib provider to \`pom.xml\`. GroupIds, artifactIds, and versions are in the OpenFeature Java SDK docs and the flagd Java provider README.` },
- { title: "Configure the Provider", body: `Create a Spring \`@Configuration\` class that builds a \`FlagdProvider\` in RPC mode and registers it on the OpenFeature API at startup. No host or port to configure: the devcontainer pre-sets \`FLAGD_HOST\` and \`FLAGD_PORT\`.` },
- { title: "Author Your First Flag", body: `Open \`flags.json\` and add a flag named \`vision_state\` with two string variants (for example 'blurry' and 'clouded') and a \`defaultVariant\`. flagd's file watcher picks up changes within about a second, no restart needed.` },
- { title: "Wire the Evaluation", body: `Replace the hard-coded return in \`Trial\` with an OpenFeature evaluation of \`vision_state\`, returning the full evaluation details (flag key, variant, value, reason).` },
- { title: "Test Hot Reload", body: `Restart the lab. Confirm the value resolves from \`flags.json\`, then edit \`flags.json\`, change \`defaultVariant\`, save, and re-run curl without restarting anything:
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 8080:** Spring Boot lab. The lab endpoint. On first load you will see the hard-coded 'untreated' response. This is the broken state you are fixing.` },
+ { title: "Add Dependencies", content: `Add the OpenFeature Java SDK and flagd contrib provider to \`pom.xml\`. GroupIds, artifactIds, and versions are in the OpenFeature Java SDK docs and the flagd Java provider README.` },
+ { title: "Configure the Provider", content: `Create a Spring \`@Configuration\` class that builds a \`FlagdProvider\` in RPC mode and registers it on the OpenFeature API at startup. No host or port to configure: the devcontainer pre-sets \`FLAGD_HOST\` and \`FLAGD_PORT\`.` },
+ { title: "Author Your First Flag", content: `Open \`flags.json\` and add a flag named \`vision_state\` with two string variants (for example 'blurry' and 'clouded') and a \`defaultVariant\`. flagd's file watcher picks up changes within about a second, no restart needed.` },
+ { title: "Wire the Evaluation", content: `Replace the hard-coded return in \`Trial\` with an OpenFeature evaluation of \`vision_state\`, returning the full evaluation details (flag key, variant, value, reason).` },
+ { title: "Test Hot Reload", content: `Restart the lab. Confirm the value resolves from \`flags.json\`, then edit \`flags.json\`, change \`defaultVariant\`, save, and re-run curl without restarting anything:
\`\`\`sh
curl -s http://localhost:8080/ | jq
@@ -94,9 +94,9 @@ curl -s http://localhost:8080/ | jq
` },
],
helpfulLinks: [
- { label: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" },
- { label: "flagd Java provider", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd" },
- { label: "flagd flag definitions", url: "https://flagd.dev/reference/flag-definitions/" },
+ { title: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" },
+ { title: "flagd Java provider", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd" },
+ { title: "flagd flag definitions", url: "https://flagd.dev/reference/flag-definitions/" },
],
verification: {
command: "./verify.sh",
@@ -116,7 +116,7 @@ curl -s http://localhost:8080/ | jq
],
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",
+ deadline: "2026-05-26T23:59:00+01:00",
intro: [
"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.",
],
@@ -142,8 +142,11 @@ curl -s http://localhost:8080/ | jq
{ 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. 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:
+ { title: "Wait for Setup", content: `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: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 8080:** Spring Boot lab. The application under test. Access via the Ports tab or curl http://localhost:8080/.` },
+ { title: "Confirm the Broken State", content: `Start the lab and confirm the broken state, where no targeting fires yet:
\`\`\`sh
./mvnw spring-boot:run
@@ -153,7 +156,7 @@ curl 'http://localhost:8080/?species=zyklop'
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: `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:
+ { title: "Inspect the Starting Point", content: `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": {
@@ -167,11 +170,11 @@ That \`"blurry"\` is the starting point you want: even when the request shouts \
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. 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:
+ { title: "Build the SpeciesInterceptor", content: `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", content: `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", content: `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", content: `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", content: `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 (or: make lab-germany)
@@ -206,10 +209,10 @@ You should see one \`[AUDIT] flag=vision_state variant=... reason=... species=..
` },
],
helpfulLinks: [
- { label: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" },
- { label: "OpenFeature Hooks", url: "https://openfeature.dev/docs/reference/concepts/hooks" },
- { label: "Spring HandlerInterceptor", 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/" },
+ { title: "OpenFeature Java SDK", url: "https://openfeature.dev/docs/reference/technologies/server/java/" },
+ { title: "OpenFeature Hooks", url: "https://openfeature.dev/docs/reference/concepts/hooks" },
+ { title: "Spring HandlerInterceptor", url: "https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html" },
+ { title: "flagd flag definitions", url: "https://flagd.dev/reference/flag-definitions/" },
],
verification: {
command: "./verify.sh",
@@ -231,7 +234,7 @@ You should see one \`[AUDIT] flag=vision_state variant=... reason=... species=..
],
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",
+ deadline: "2026-05-26T23:59:00+01:00",
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.",
@@ -257,9 +260,17 @@ You should see one \`[AUDIT] flag=vision_state variant=... reason=... species=..
{ 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 Your Challenge", content: `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:
+ { title: "Explore the UIs", content: `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 until metrics are wired). Try Explore > Tempo to see flag evaluations as span events.
+- **Port 9090:** Prometheus. Query metrics directly via the Prometheus UI or curl http://localhost:9090/api/v1/query.
+- **Port 3200:** Tempo. Tempo HTTP API used by the verify script to assert traces are flowing.
+
+flagd runs on the docker-internal network only. No port forwarding needed.` },
+ { title: "Start the Lab", content: `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
@@ -267,30 +278,17 @@ You should see one \`[AUDIT] flag=vision_state variant=... reason=... species=..
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.
+ { title: "Turn On the Metrics Exporter", content: `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.
+ { title: "Register MetricsHook", content: `\`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.\`:
+ { title: "Write and Register ContextSpanHook", content: `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.\`:
\`\`\`text
before(hookCtx) {
@@ -304,11 +302,11 @@ before(hookCtx) {
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.
+ { title: "Turn On the Loadgen", content: `\`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.
+ { title: "Roll Back the Rollout", content: `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.**
@@ -316,11 +314,11 @@ Watch the dashboard: the 5xx rate falls back to baseline, and the next batch of
` },
],
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/" },
+ { title: "OpenFeature OTel contrib hooks (Java)", url: "https://github.com/open-feature/java-sdk-contrib/tree/main/hooks/open-telemetry" },
+ { title: "OpenTelemetry Java Agent configuration", url: "https://opentelemetry.io/docs/zero-code/java/agent/configuration/" },
+ { title: "OpenFeature Hooks concept", url: "https://openfeature.dev/docs/reference/concepts/hooks" },
+ { title: "flagd fractional operation", url: "https://flagd.dev/reference/custom-operations/fractional-operation/" },
+ { title: "OpenTelemetry security guidance", url: "https://opentelemetry.io/docs/security/" },
],
verification: {
command: "./verify.sh",
diff --git a/src/data/adventures/blind-by-design/adventure.yaml b/src/data/adventures/blind-by-design/adventure.yaml
index 0936119b..2f2c185f 100644
--- a/src/data/adventures/blind-by-design/adventure.yaml
+++ b/src/data/adventures/blind-by-design/adventure.yaml
@@ -1,12 +1,13 @@
-id: blind-by-design
-title: "Blind by Design"
+slug: blind-by-design
+name: "Blind by Design"
+icon: FlaskConical
month: "MAY 2026"
story: >-
Three levels of OpenFeature with flagd as the provider, in a Java + Spring Boot service.
Wire the SDK against a flagd sidecar (Beginner), layer evaluation context to target by cohort
(Intermediate), then instrument flag evaluations with OpenTelemetry and roll back a misbehaving
fractional rollout (Expert). All without redeploying.
-tags:
+technologies:
- OpenFeature
- flagd
- Spring Boot
@@ -43,39 +44,31 @@ backstory:
so outcomes can be tracked, then turn on the lights and roll back the Phase 3 variant before
the director signs off on the next enrollment batch.
-context:
- title: "What you'll be using"
- body:
- - >-
- OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native
- implementation is flagd, which serves flag definitions from a JSON file, locally or remotely,
- and the OpenFeature SDK in your application calls it on every evaluation.
- - >-
- In this adventure, the lab uses OpenFeature exactly the way a real engineering team would:
- a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the
- targeting rules in flags.json decide what reading every subject ends up with. By the end,
- you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled
- back a misbehaving Phase 3 trial without redeploying.
+overview:
+ - >-
+ OpenFeature is a vendor-neutral standard for feature flags. The reference cloud-native
+ implementation is flagd, which serves flag definitions from a JSON file, locally or remotely,
+ and the OpenFeature SDK in your application calls it on every evaluation.
+ - >-
+ In this adventure, the lab uses OpenFeature exactly the way a real engineering team would:
+ a Spring Boot service holds the SDK client, flagd holds the flag definitions, and the
+ targeting rules in flags.json decide what reading every subject ends up with. By the end,
+ you'll have wired the SDK in from scratch, learned to record outcomes by cohort, and rolled
+ back a misbehaving Phase 3 trial without redeploying.
rewards:
- deadline: "26 May 2026 at 23:59 CET"
- eligibility: "Complete all levels and post your solution in the community before the deadline to be eligible."
+ deadline: "2026-05-26T23:59:00+01:00"
tiers:
- label: "1st place"
description: "50% voucher for a Linux Foundation certification"
- label: "Top 3"
description: "Credly badge to showcase the achievement"
- rankingNote: >-
- Ranking is determined by total points across all three levels. Points per level are awarded
- by submission order within the active week (100 for the first valid solution, 95 for the
- second, and so on; late submissions still earn 60).
- rankingRulesUrl: "/t/about-the-challenges-category/16"
levels:
- id: beginner
name: "Stand up the Lab"
difficulty: Beginner
- deadline: "26 May 2026 at 23:59 CET"
+ deadline: "2026-05-26T23:59:00+01:00"
topics:
- OpenFeature
- flagd
@@ -88,8 +81,8 @@ levels:
- "What remote provider means in practice: the SDK calls a separate flag service (flagd) over gRPC, not parsing flags.json itself"
- "What flags.json looks like for flagd (state, variants, defaultVariant)"
- "Why hot-reload of the flag file matters operationally: configuration without redeploy"
- devcontainerPath: ".devcontainer/04-blind-by-design_01-beginner/devcontainer.json"
- discussionUrl: "/t/wire-openfeature-flagd-into-a-spring-boot-service-with-zero-setup-adventure-04-beginner/1419"
+ devcontainer_path: ".devcontainer/04-blind-by-design_01-beginner/devcontainer.json"
+ discussion_url: "/t/wire-openfeature-flagd-into-a-spring-boot-service-with-zero-setup-adventure-04-beginner/1419"
intro:
- >-
Wire the OpenFeature Java SDK and the flagd contrib provider into a Spring Boot service
@@ -132,39 +125,41 @@ levels:
url: "https://jqlang.org/"
- name: "flagd sidecar"
description: "already running in the devcontainer compose stack on the docker-internal network, no port forwarding needed"
- howToPlay:
+ services:
+ - name: "Spring Boot lab"
+ port: "8080"
+ description: "The lab endpoint. On first load you will see the hard-coded 'untreated' response. This is the broken state you are fixing."
+ how_to_play:
- title: "Start the Lab"
- body: |
+ content: |
Run the lab from the terminal, or press F5 in VS Code with `Laboratory.java` open. The lab starts in the broken state, returning the hard-coded 'untreated' response:
```sh
./mvnw spring-boot:run
```
- - title: "Confirm the Broken State"
- body: "Open the Ports tab, set port 8080 to Public, then click the forwarded address to confirm the hard-coded 'untreated' response."
- title: "Add Dependencies"
- body: "Add the OpenFeature Java SDK and flagd contrib provider to `pom.xml`. GroupIds, artifactIds, and versions are in the OpenFeature Java SDK docs and the flagd Java provider README."
+ content: "Add the OpenFeature Java SDK and flagd contrib provider to `pom.xml`. GroupIds, artifactIds, and versions are in the OpenFeature Java SDK docs and the flagd Java provider README."
- title: "Configure the Provider"
- body: "Create a Spring `@Configuration` class that builds a `FlagdProvider` in RPC mode and registers it on the OpenFeature API at startup. No host or port to configure: the devcontainer pre-sets `FLAGD_HOST` and `FLAGD_PORT`."
+ content: "Create a Spring `@Configuration` class that builds a `FlagdProvider` in RPC mode and registers it on the OpenFeature API at startup. No host or port to configure: the devcontainer pre-sets `FLAGD_HOST` and `FLAGD_PORT`."
- title: "Author Your First Flag"
- body: "Open `flags.json` and add a flag named `vision_state` with two string variants (for example 'blurry' and 'clouded') and a `defaultVariant`. flagd's file watcher picks up changes within about a second, no restart needed."
+ content: "Open `flags.json` and add a flag named `vision_state` with two string variants (for example 'blurry' and 'clouded') and a `defaultVariant`. flagd's file watcher picks up changes within about a second, no restart needed."
- title: "Wire the Evaluation"
- body: "Replace the hard-coded return in `Trial` with an OpenFeature evaluation of `vision_state`, returning the full evaluation details (flag key, variant, value, reason)."
+ content: "Replace the hard-coded return in `Trial` with an OpenFeature evaluation of `vision_state`, returning the full evaluation details (flag key, variant, value, reason)."
- title: "Test Hot Reload"
- body: |
+ content: |
Restart the lab. Confirm the value resolves from `flags.json`, then edit `flags.json`, change `defaultVariant`, save, and re-run curl without restarting anything:
```sh
curl -s http://localhost:8080/ | jq
```
- helpfulLinks:
- - label: OpenFeature Java SDK
+ helpful_links:
+ - title: OpenFeature Java SDK
url: https://openfeature.dev/docs/reference/technologies/server/java/
- - label: flagd Java provider
+ - title: flagd Java provider
url: https://github.com/open-feature/java-sdk-contrib/tree/main/providers/flagd
- - label: flagd flag definitions
+ - title: 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."
+ meta_description: "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."
@@ -172,7 +167,7 @@ levels:
- id: intermediate
name: "Outcome by Cohort"
difficulty: Intermediate
- deadline: "26 May 2026 at 23:59 CET"
+ deadline: "2026-05-26T23:59:00+01:00"
topics:
- OpenFeature
- flagd
@@ -182,8 +177,8 @@ levels:
- "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"
+ devcontainer_path: ".devcontainer/04-blind-by-design_02-intermediate/devcontainer.json"
+ discussion_url: "/t/outcome-by-cohort-adventure-04-intermediate/1485"
intro:
- >-
Populate all three OpenFeature evaluation-context layers on a Spring Boot service and register an AuditHook.
@@ -206,8 +201,8 @@ levels:
registration (set on the JVM via the COUNTRY environment variable) to the global context, pass the dose as
invocation context at the moment of the flag evaluation, and register an audit hook that records every dose
with its variant and reason.
- 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_diagram: "blind-by-design-intermediate.svg"
+ diagram_alt: "HTTP flows through SpeciesInterceptor, Trial, and OpenFeature client left to right, then down through AuditHook and FlagdProvider, connecting via gRPC to a flagd sidecar."
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"
@@ -227,11 +222,15 @@ levels:
url: "https://jqlang.org/"
- name: "tail -f"
description: "watches the application log live for [AUDIT] lines"
- howToPlay:
+ services:
+ - name: "Spring Boot lab"
+ port: "8080"
+ description: "The application under test. Access via the Ports tab or curl http://localhost:8080/."
+ how_to_play:
- 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/`."
+ content: "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: |
+ content: |
Start the lab and confirm the broken state, where no targeting fires yet:
```sh
@@ -242,7 +241,7 @@ levels:
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: |
+ content: |
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
@@ -257,21 +256,21 @@ levels:
Your job: populate `species`, `country`, and `dose` on the evaluation context so the targeting fires.
- title: "Build the SpeciesInterceptor"
- body: >-
+ content: >-
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: >-
+ content: >-
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: >-
+ content: >-
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
@@ -279,7 +278,7 @@ levels:
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: >-
+ content: >-
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
@@ -287,7 +286,7 @@ levels:
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: |
+ content: |
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
@@ -320,16 +319,16 @@ levels:
```
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
+ helpful_links:
+ - title: OpenFeature Java SDK
url: https://openfeature.dev/docs/reference/technologies/server/java/
- - label: OpenFeature Hooks
+ - title: OpenFeature Hooks
url: https://openfeature.dev/docs/reference/concepts/hooks
- - label: Spring HandlerInterceptor
+ - title: Spring HandlerInterceptor
url: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/servlet/HandlerInterceptor.html
- - label: flagd flag definitions
+ - title: 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."
+ meta_description: "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."
@@ -337,7 +336,7 @@ levels:
- id: expert
name: "Read the Chart"
difficulty: Expert
- deadline: "26 May 2026 at 23:59 CET"
+ deadline: "2026-05-26T23:59:00+01:00"
audience: >-
Platform engineers, SREs, and observability-focused developers who have completed the
Beginner and Intermediate levels or are comfortable with OpenFeature evaluation context,
@@ -353,8 +352,8 @@ levels:
- "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"
+ devcontainer_path: ".devcontainer/04-blind-by-design_03-expert/devcontainer.json"
+ discussion_url: "/t/read-the-chart-adventure-04-expert/1530"
intro:
- >-
Spans are already flowing into Tempo from the OpenFeature TracesHook, but the metrics
@@ -378,8 +377,8 @@ levels:
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."
+ architecture_diagram: "blind-by-design-expert.svg"
+ diagram_alt: "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. 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"
@@ -400,12 +399,29 @@ levels:
- name: "jq"
description: "pretty-prints the JSON evaluation details"
url: "https://jqlang.org/"
- howToPlay:
+ services:
+ - name: "Spring Boot lab"
+ port: "8080"
+ description: "Add ?userId=subject-42 for a stable fractional-rollout bucketing key."
+ - name: "Grafana"
+ port: "3000"
+ credentials: "admin / admin"
+ description: "Open Dashboards > Feature Flag Metrics (empty until metrics are wired). Try Explore > Tempo to see flag evaluations as span events."
+ - name: "Prometheus"
+ port: "9090"
+ description: "Query metrics directly via the Prometheus UI or curl http://localhost:9090/api/v1/query."
+ - name: "Tempo"
+ port: "3200"
+ description: "Tempo HTTP API used by the verify script to assert traces are flowing."
+ - name: "flagd"
+ internal: true
+ description: "Runs on the docker-internal network only (flagd:8013). The lab and loadgen reach it as flagd:8013."
+ how_to_play:
- title: "Start Your Challenge"
- body: |
+ content: |
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: |
+ content: |
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
@@ -413,34 +429,20 @@ levels:
```
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: |
+ content: |
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: |
+ content: |
`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: |
+ content: |
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.`:
```text
@@ -455,29 +457,29 @@ levels:
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: |
+ content: |
`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: |
+ content: |
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)"
+ helpful_links:
+ - title: "OpenFeature OTel contrib hooks (Java)"
url: "https://github.com/open-feature/java-sdk-contrib/tree/main/hooks/open-telemetry"
- - label: "OpenTelemetry Java Agent configuration"
+ - title: "OpenTelemetry Java Agent configuration"
url: "https://opentelemetry.io/docs/zero-code/java/agent/configuration/"
- - label: "OpenFeature Hooks concept"
+ - title: "OpenFeature Hooks concept"
url: "https://openfeature.dev/docs/reference/concepts/hooks"
- - label: "flagd fractional operation"
+ - title: "flagd fractional operation"
url: "https://flagd.dev/reference/custom-operations/fractional-operation/"
- - label: "OpenTelemetry security guidance"
+ - title: "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."
+ meta_description: "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/building-cloudhaven.generated.ts b/src/data/adventures/building-cloudhaven.generated.ts
index d3cfe706..59de1d2c 100644
--- a/src/data/adventures/building-cloudhaven.generated.ts
+++ b/src/data/adventures/building-cloudhaven.generated.ts
@@ -4,6 +4,7 @@ import type { Adventure } from "./types";
export const BUILDING_CLOUDHAVEN: Adventure = {
id: "building-cloudhaven",
title: "Building CloudHaven",
+ icon: "Cloud",
month: "JAN 2026",
story: "Join the Infrastructure Guild and modernize CloudHaven's infrastructure from manual provisioning to a self-service platform using Infrastructure as Code. A hands-on journey through infrastructure as code with OpenTofu and GitHub Actions.",
tags: ["OpenTofu", "Terraform", "GitHub Actions", "Trivy", "TDD"],
@@ -33,7 +34,7 @@ export const BUILDING_CLOUDHAVEN: Adventure = {
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_01-beginner%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/practice-infrastructure-as-code-with-zero-setup-adventure-02-beginner/656`,
- deadline: "4 February 2026 at 23:59 CET",
+ deadline: "2026-02-04T23:59:00+01:00",
intro: [
"An incomplete OpenTofu configuration is blocking the Merchant's Quarter from going live. Fix the broken backend, wire up dynamic resource provisioning with for_each, and use the new enabled meta-argument to conditionally deploy the audit database.",
],
@@ -52,33 +53,35 @@ export const BUILDING_CLOUDHAVEN: Adventure = {
{ name: "gcp-api-mock", description: "mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud SQL only)", url: "https://github.com/KatharinaSick/gcp-api-mock" },
],
howToPlay: [
- { title: "Wait for the Environment", body: "Wait ~2 minutes for the environment to initialize." },
- { title: "Explore the Mock API", body: "Open the Ports tab, find the GCP API Mock at port 30104. Use it to explore the mock cloud resources created by your configuration." },
- { title: "Find the TODOs", body: `All OpenTofu files are in \`adventures/02-building-cloudhaven/beginner/\`. Run:
+ { title: "Wait for the Environment", content: "Wait ~2 minutes for the environment to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30104:** GCP API Mock. Explore the mock cloud resources created by your configuration (Cloud Storage and Cloud SQL).` },
+ { title: "Find the TODOs", content: `All OpenTofu files are in \`adventures/02-building-cloudhaven/beginner/\`. Run:
\`\`\`sh
grep -r "TODO" .
\`\`\`
Files to review: \`main.tf\`, \`state.tf\`, \`variables.tf\`, \`merchants.tf\`, \`audit.tf\`, \`outputs.tf\`.` },
- { title: "Apply the Configuration", body: `After fixing the TODOs, run:
+ { title: "Apply the Configuration", content: `After fixing the TODOs, run:
\`\`\`sh
tofu apply
\`\`\`
If you changed the backend configuration, run \`tofu init -migrate-state\` first.` },
- { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution:
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution:
\`\`\`sh
./smoke-test.sh
\`\`\`` },
],
helpfulLinks: [
- { label: "OpenTofu documentation", url: "https://opentofu.org/docs/" },
- { label: "OpenTofu meta-arguments", url: "https://opentofu.org/docs/language/meta-arguments/count/" },
- { label: "OpenTofu backend configuration", url: "https://opentofu.org/docs/language/settings/backends/configuration/" },
- { label: "Google Cloud provider", url: "https://registry.terraform.io/providers/hashicorp/google/latest/docs" },
+ { title: "OpenTofu documentation", url: "https://opentofu.org/docs/" },
+ { title: "OpenTofu meta-arguments", url: "https://opentofu.org/docs/language/meta-arguments/count/" },
+ { title: "OpenTofu backend configuration", url: "https://opentofu.org/docs/language/settings/backends/configuration/" },
+ { title: "Google Cloud provider", url: "https://registry.terraform.io/providers/hashicorp/google/latest/docs" },
],
verification: {
command: "./smoke-test.sh",
@@ -98,7 +101,7 @@ If you changed the backend configuration, run \`tofu init -migrate-state\` first
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_02-intermediate%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/adventure-02-building-cloudhaven-intermediate-the-modular-metropolis/723/10`,
- deadline: "4 February 2026 at 23:59 CET",
+ deadline: "2026-02-04T23:59:00+01:00",
intro: [
"A senior engineer wrote the tests first and then left. The module code is buggy and the integration test is incomplete. Fix the implementation to match the test expectations, complete the end-to-end test, and use moved blocks to refactor without destroying state.",
],
@@ -117,9 +120,11 @@ If you changed the backend configuration, run \`tofu init -migrate-state\` first
{ name: "gcp-api-mock", description: "mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud SQL only)", url: "https://github.com/KatharinaSick/gcp-api-mock" },
],
howToPlay: [
- { title: "Wait for the Environment", body: "Wait ~2 minutes for the environment to initialize." },
- { title: "Explore the Mock API", body: "Open the Ports tab, find the GCP API Mock at port 30104. Use it to explore mock cloud resources." },
- { title: "Fix the Failing Tests", body: `All files are in \`adventures/02-building-cloudhaven/intermediate/\`. The tests define the expected behaviour: your job is to fix the implementation to match what the tests expect. Don't modify existing tests unless a comment tells you to.
+ { title: "Wait for the Environment", content: "Wait ~2 minutes for the environment to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30104:** GCP API Mock. Explore mock cloud resources to verify your module configuration.` },
+ { title: "Fix the Failing Tests", content: `All files are in \`adventures/02-building-cloudhaven/intermediate/\`. The tests define the expected behaviour: your job is to fix the implementation to match what the tests expect. Don't modify existing tests unless a comment tells you to.
\`\`\`
adventures/02-building-cloudhaven/intermediate/
@@ -144,23 +149,23 @@ Run tests to see what fails:
\`\`\`sh
make test
\`\`\`` },
- { title: "Apply the Infrastructure", body: `Once all tests pass, apply the infrastructure:
+ { title: "Apply the Infrastructure", content: `Once all tests pass, apply the infrastructure:
\`\`\`sh
make test
make apply
\`\`\`` },
- { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution:
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution:
\`\`\`sh
./smoke-test.sh
\`\`\`` },
],
helpfulLinks: [
- { label: "OpenTofu testing", url: "https://opentofu.org/docs/cli/commands/test/" },
- { label: "OpenTofu modules", url: "https://opentofu.org/docs/language/modules/" },
- { label: "Input validation rules", url: "https://opentofu.org/docs/language/values/variables/#custom-validation-rules" },
- { label: "Moved blocks", url: "https://opentofu.org/docs/language/modules/develop/refactoring/" },
+ { title: "OpenTofu testing", url: "https://opentofu.org/docs/cli/commands/test/" },
+ { title: "OpenTofu modules", url: "https://opentofu.org/docs/language/modules/" },
+ { title: "Input validation rules", url: "https://opentofu.org/docs/language/values/variables/#custom-validation-rules" },
+ { title: "Moved blocks", url: "https://opentofu.org/docs/language/modules/develop/refactoring/" },
],
verification: {
command: "./smoke-test.sh",
@@ -179,7 +184,7 @@ make apply
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F02-building-cloudhaven_03-expert%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/adventure-02-building-cloudhaven-expert-the-guardian-protocols/782/8`,
- deadline: "4 February 2026 at 23:59 CET",
+ deadline: "2026-02-04T23:59:00+01:00",
intro: [
"Three broken GitHub Actions workflows stand between CloudHaven and automated infrastructure governance. Fix drift detection that creates PRs, PR validation with Trivy security scanning and service-container integration tests, and automatic apply on merge.",
],
@@ -201,19 +206,21 @@ make apply
{ name: "GitHub Actions", description: "the workflows you will fix are in .github/workflows/", url: "https://docs.github.com/en/actions" },
],
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. 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/\`:
+ { title: "Wait for the Environment", content: "Wait ~2 minutes for the environment to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30104:** GCP API Mock. Port is set to public so GitHub Actions runners can reach it during workflow runs. You may see a browser security warning. Click Continue to proceed.` },
+ { title: "Fix the Workflows", content: `Fix the three workflows in \`.github/workflows/\`:
- \`adventure02-expert-detect-drift.yaml\`
- \`adventure02-expert-validate-changes.yaml\`
- \`adventure02-expert-apply-infrastructure.yaml\`
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. 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:
+ { title: "Trigger Drift Detection", content: "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", content: "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", content: "When the PR is merged to main, the apply workflow runs automatically." },
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution:
\`\`\`sh
cd adventures/02-building-cloudhaven/expert
@@ -221,11 +228,11 @@ cd adventures/02-building-cloudhaven/expert
\`\`\`` },
],
helpfulLinks: [
- { label: "GitHub Actions documentation", url: "https://docs.github.com/en/actions" },
- { label: "GitHub Actions service containers", url: "https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers" },
- { label: "OpenTofu plan command", url: "https://opentofu.org/docs/cli/commands/plan/" },
- { label: "Trivy action", url: "https://github.com/aquasecurity/trivy-action" },
- { label: "TF-via-PR action", url: "https://github.com/OP5dev/TF-via-PR" },
+ { title: "GitHub Actions documentation", url: "https://docs.github.com/en/actions" },
+ { title: "GitHub Actions service containers", url: "https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers" },
+ { title: "OpenTofu plan command", url: "https://opentofu.org/docs/cli/commands/plan/" },
+ { title: "Trivy action", url: "https://github.com/aquasecurity/trivy-action" },
+ { title: "TF-via-PR action", url: "https://github.com/OP5dev/TF-via-PR" },
],
verification: {
command: "./smoke-test.sh",
diff --git a/src/data/adventures/building-cloudhaven/adventure.yaml b/src/data/adventures/building-cloudhaven/adventure.yaml
index 3075dbfa..2ea1df2c 100644
--- a/src/data/adventures/building-cloudhaven/adventure.yaml
+++ b/src/data/adventures/building-cloudhaven/adventure.yaml
@@ -1,10 +1,11 @@
-id: building-cloudhaven
-title: Building CloudHaven
+slug: building-cloudhaven
+name: Building CloudHaven
+icon: Cloud
month: JAN 2026
story: Join the Infrastructure Guild and modernize CloudHaven's infrastructure from manual provisioning to a
self-service platform using Infrastructure as Code. A hands-on journey through infrastructure as code with
OpenTofu and GitHub Actions.
-tags:
+technologies:
- OpenTofu
- Terraform
- GitHub Actions
@@ -39,9 +40,9 @@ levels:
- Remote state management with GCS backend
- Dynamic resource provisioning with for_each
- Conditional resources with the enabled meta-argument, new in OpenTofu
- devcontainerPath: .devcontainer/02-building-cloudhaven_01-beginner/devcontainer.json
- discussionUrl: /t/practice-infrastructure-as-code-with-zero-setup-adventure-02-beginner/656
- deadline: "4 February 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/02-building-cloudhaven_01-beginner/devcontainer.json
+ discussion_url: /t/practice-infrastructure-as-code-with-zero-setup-adventure-02-beginner/656
+ deadline: "2026-02-04T23:59:00+01:00"
intro:
- An incomplete OpenTofu configuration is blocking the Merchant's Quarter from going live. Fix the broken backend,
wire up dynamic resource provisioning with for_each, and use the new enabled meta-argument to conditionally
@@ -64,14 +65,15 @@ levels:
description: mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud
SQL only)
url: https://github.com/KatharinaSick/gcp-api-mock
- howToPlay:
+ services:
+ - name: "GCP API Mock"
+ port: "30104"
+ description: "Explore the mock cloud resources created by your configuration (Cloud Storage and Cloud SQL)."
+ how_to_play:
- title: Wait for the Environment
- body: Wait ~2 minutes for the environment to initialize.
- - title: Explore the Mock API
- body: Open the Ports tab, find the GCP API Mock at port 30104. Use it to explore the mock cloud resources created by
- your configuration.
+ content: Wait ~2 minutes for the environment to initialize.
- title: Find the TODOs
- body: |-
+ content: |-
All OpenTofu files are in `adventures/02-building-cloudhaven/beginner/`. Run:
```sh
@@ -80,7 +82,7 @@ levels:
Files to review: `main.tf`, `state.tf`, `variables.tf`, `merchants.tf`, `audit.tf`, `outputs.tf`.
- title: Apply the Configuration
- body: |-
+ content: |-
After fixing the TODOs, run:
```sh
@@ -89,20 +91,20 @@ levels:
If you changed the backend configuration, run `tofu init -migrate-state` first.
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution:
```sh
./smoke-test.sh
```
- helpfulLinks:
- - label: OpenTofu documentation
+ helpful_links:
+ - title: OpenTofu documentation
url: https://opentofu.org/docs/
- - label: OpenTofu meta-arguments
+ - title: OpenTofu meta-arguments
url: https://opentofu.org/docs/language/meta-arguments/count/
- - label: OpenTofu backend configuration
+ - title: OpenTofu backend configuration
url: https://opentofu.org/docs/language/settings/backends/configuration/
- - label: Google Cloud provider
+ - title: Google Cloud provider
url: https://registry.terraform.io/providers/hashicorp/google/latest/docs
verification:
command: "./smoke-test.sh"
@@ -118,9 +120,9 @@ levels:
- Test-Driven Development (TDD) workflow
- Input validation with custom rules
- Refactoring infrastructure safely with moved blocks
- devcontainerPath: .devcontainer/02-building-cloudhaven_02-intermediate/devcontainer.json
- discussionUrl: /t/adventure-02-building-cloudhaven-intermediate-the-modular-metropolis/723/10
- deadline: "4 February 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/02-building-cloudhaven_02-intermediate/devcontainer.json
+ discussion_url: /t/adventure-02-building-cloudhaven-intermediate-the-modular-metropolis/723/10
+ deadline: "2026-02-04T23:59:00+01:00"
intro:
- A senior engineer wrote the tests first and then left. The module code is buggy and the integration test is
incomplete. Fix the implementation to match the test expectations, complete the end-to-end test, and use moved
@@ -144,13 +146,15 @@ levels:
description: mock GCP API running locally to simulate cloud resources without real cloud costs (Cloud Storage and Cloud
SQL only)
url: https://github.com/KatharinaSick/gcp-api-mock
- howToPlay:
+ services:
+ - name: "GCP API Mock"
+ port: "30104"
+ description: "Explore mock cloud resources to verify your module configuration."
+ how_to_play:
- title: Wait for the Environment
- body: Wait ~2 minutes for the environment to initialize.
- - title: Explore the Mock API
- body: Open the Ports tab, find the GCP API Mock at port 30104. Use it to explore mock cloud resources.
+ content: Wait ~2 minutes for the environment to initialize.
- title: Fix the Failing Tests
- body: |-
+ content: |-
All files are in `adventures/02-building-cloudhaven/intermediate/`. The tests define the expected behaviour: your job is to fix the implementation to match what the tests expect. Don't modify existing tests unless a comment tells you to.
```
@@ -177,7 +181,7 @@ levels:
make test
```
- title: Apply the Infrastructure
- body: |-
+ content: |-
Once all tests pass, apply the infrastructure:
```sh
@@ -185,20 +189,20 @@ levels:
make apply
```
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution:
```sh
./smoke-test.sh
```
- helpfulLinks:
- - label: OpenTofu testing
+ helpful_links:
+ - title: OpenTofu testing
url: https://opentofu.org/docs/cli/commands/test/
- - label: OpenTofu modules
+ - title: OpenTofu modules
url: https://opentofu.org/docs/language/modules/
- - label: Input validation rules
+ - title: Input validation rules
url: https://opentofu.org/docs/language/values/variables/#custom-validation-rules
- - label: Moved blocks
+ - title: Moved blocks
url: https://opentofu.org/docs/language/modules/develop/refactoring/
verification:
command: "./smoke-test.sh"
@@ -214,9 +218,9 @@ levels:
- GitHub Actions for drift detection and plan/apply
- Integration tests with service containers
- Security scanning with Trivy
- devcontainerPath: .devcontainer/02-building-cloudhaven_03-expert/devcontainer.json
- discussionUrl: /t/adventure-02-building-cloudhaven-expert-the-guardian-protocols/782/8
- deadline: "4 February 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/02-building-cloudhaven_03-expert/devcontainer.json
+ discussion_url: /t/adventure-02-building-cloudhaven-expert-the-guardian-protocols/782/8
+ deadline: "2026-02-04T23:59:00+01:00"
intro:
- Three broken GitHub Actions workflows stand between CloudHaven and automated infrastructure governance. Fix
drift detection that creates PRs, PR validation with Trivy security scanning and service-container integration
@@ -247,15 +251,15 @@ levels:
- name: GitHub Actions
description: the workflows you will fix are in .github/workflows/
url: https://docs.github.com/en/actions
- howToPlay:
+ services:
+ - name: "GCP API Mock"
+ port: "30104"
+ description: "Port is set to public so GitHub Actions runners can reach it during workflow runs. You may see a browser security warning. Click Continue to proceed."
+ how_to_play:
- 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. You may see a browser security warning when accessing it. This is expected.
- Click Continue to proceed.
+ content: Wait ~2 minutes for the environment to initialize.
- title: Fix the Workflows
- body: |-
+ content: |-
Fix the three workflows in `.github/workflows/`:
- `adventure02-expert-detect-drift.yaml`
@@ -264,32 +268,32 @@ levels:
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
+ content: 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
+ content: 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.
+ content: When the PR is merged to main, the apply workflow runs automatically.
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution:
```sh
cd adventures/02-building-cloudhaven/expert
./smoke-test.sh
```
- helpfulLinks:
- - label: GitHub Actions documentation
+ helpful_links:
+ - title: GitHub Actions documentation
url: https://docs.github.com/en/actions
- - label: GitHub Actions service containers
+ - title: GitHub Actions service containers
url: https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers
- - label: OpenTofu plan command
+ - title: OpenTofu plan command
url: https://opentofu.org/docs/cli/commands/plan/
- - label: Trivy action
+ - title: Trivy action
url: https://github.com/aquasecurity/trivy-action
- - label: TF-via-PR action
+ - title: TF-via-PR action
url: https://github.com/OP5dev/TF-via-PR
verification:
command: "./smoke-test.sh"
diff --git a/src/data/adventures/echoes-lost-in-orbit.generated.ts b/src/data/adventures/echoes-lost-in-orbit.generated.ts
index ff8da494..517afa61 100644
--- a/src/data/adventures/echoes-lost-in-orbit.generated.ts
+++ b/src/data/adventures/echoes-lost-in-orbit.generated.ts
@@ -4,6 +4,7 @@ import type { Adventure } from "./types";
export const ECHOES_LOST_IN_ORBIT: Adventure = {
id: "echoes-lost-in-orbit",
title: "Echoes Lost in Orbit",
+ icon: "Satellite",
month: "DEC 2025",
story: "Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and observability pipelines across three galactic missions.",
tags: ["Argo CD", "Argo Rollouts", "OpenTelemetry", "Jaeger", "PromQL"],
@@ -32,7 +33,7 @@ export const ECHOES_LOST_IN_ORBIT: Adventure = {
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_beginner%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40`,
- deadline: "10 December 2025 at 09:00 CET",
+ deadline: "2025-12-10T09:00:00+01:00",
intro: [
"The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration, spot the templating pitfalls, and restore proper multi-environment delivery.",
],
@@ -52,14 +53,11 @@ export const ECHOES_LOST_IN_ORBIT: Adventure = {
{ name: "k9s", description: "terminal UI for managing and inspecting your cluster", url: "https://k9scli.io/" },
],
howToPlay: [
- { title: "Wait for Infrastructure", body: "Wait around 5 minutes for the Codespace to provision a Kubernetes cluster, Argo CD, and the sample app. Press Cmd+Shift+P (or Ctrl+Shift+P on Windows/Linux) and search for 'View Creation Log' to track progress." },
- { title: "Access Argo CD", body: `Open the Ports tab, find port 30100 (Argo CD), and click the forwarded address. Log in with:
+ { title: "Wait for Infrastructure", content: "Wait around 5 minutes for the Codespace to provision a Kubernetes cluster, Argo CD, and the sample app. Press Cmd+Shift+P (or Ctrl+Shift+P on Windows/Linux) and search for 'View Creation Log' to track progress." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
-\`\`\`
-Username: readonly
-Password: a-super-secure-password
-\`\`\`` },
- { title: "Fix the ApplicationSet", body: `All errors are in this file:
+- **Port 30100:** Argo CD (readonly / a-super-secure-password). View application sync status and manage Argo CD resources.` },
+ { title: "Fix the ApplicationSet", content: `All errors are in this file:
\`\`\`
adventures/01-echoes-lost-in-orbit/beginner/manifests/appset.yaml
@@ -72,7 +70,7 @@ After making changes, apply them:
\`\`\`sh
kubectl apply -n argocd -f adventures/01-echoes-lost-in-orbit/beginner/manifests/appset.yaml
\`\`\`` },
- { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution locally:
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution locally:
\`\`\`sh
adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh
@@ -96,7 +94,7 @@ adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_intermediate%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8`,
- deadline: "24 December 2025 at 09:00 CET",
+ deadline: "2025-12-24T09:00:00+01:00",
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.",
],
@@ -120,22 +118,16 @@ adventures/01-echoes-lost-in-orbit/beginner/smoke-test.sh
{ name: "Argo Rollouts kubectl plugin", description: "extended kubectl commands for managing rollouts", url: "https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/" },
],
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 and navigate to each service:
+ { title: "Wait for Infrastructure", content: "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: "Explore the UIs", content: `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 30100:** Argo CD (readonly / a-super-secure-password). Shows sync status. Use to refresh applications after pushing commits.
- **Port 30101:** Argo Rollouts. Shows canary deployment progress and analysis status.
-- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries.
-
-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/\`.
+- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal.` },
+ { title: "Fix the Manifests", content: `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.` },
- { title: "Deploy Your Changes", body: `Commit and push your changes to trigger the deployment:
+ { title: "Deploy Your Changes", content: `Commit and push your changes to trigger the deployment:
\`\`\`sh
git add adventures/01-echoes-lost-in-orbit/intermediate/manifests/
@@ -151,13 +143,13 @@ Speed up Argo CD sync:
argocd app get echo-server-staging --refresh
argocd app get echo-server-prod --refresh
\`\`\`` },
- { title: "Trigger the Rollout", body: `After Argo CD syncs, retry the rollouts:
+ { title: "Trigger the Rollout", content: `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
\`\`\`` },
- { title: "Watch the Rollout", body: `Watch canary progress (should advance 33% to 66% to 100%):
+ { title: "Watch the Rollout", content: `Watch canary progress (should advance 33% to 66% to 100%):
\`\`\`sh
kubectl argo rollouts get rollout echo-server -n echo-staging --watch
@@ -165,17 +157,17 @@ kubectl argo rollouts get rollout echo-server -n echo-prod --watch
\`\`\`
In real-world progressive delivery, staging is updated first, validated, and then changes are promoted to production. This challenge skips that separation so you can focus on the canary rollout mechanics and health checks without managing two promotion steps.` },
- { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution:
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution:
\`\`\`sh
adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh
\`\`\`` },
],
helpfulLinks: [
- { label: "Argo Rollouts documentation", url: "https://argo-rollouts.readthedocs.io/en/stable/" },
- { label: "Analysis and progressive delivery", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" },
- { label: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" },
- { label: "kube-state-metrics exposed metrics", url: "https://github.com/kubernetes/kube-state-metrics/tree/main/docs#exposed-metrics" },
+ { title: "Argo Rollouts documentation", url: "https://argo-rollouts.readthedocs.io/en/stable/" },
+ { title: "Analysis and progressive delivery", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" },
+ { title: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" },
+ { title: "kube-state-metrics exposed metrics", url: "https://github.com/kubernetes/kube-state-metrics/tree/main/docs#exposed-metrics" },
],
verification: {
command: "adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh",
@@ -195,7 +187,7 @@ adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F01-echoes-lost-in-orbit_expert%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4`,
- deadline: "14 January 2026 at 09:00 CET",
+ deadline: "2026-01-14T09:00:00+01:00",
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.",
],
@@ -218,21 +210,15 @@ adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh
{ name: "Argo Rollouts kubectl plugin", description: "extended kubectl commands for managing rollouts", url: "https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/" },
],
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 and navigate to each service:
+ { title: "Wait for Infrastructure", content: "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: "Explore the UIs", content: `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 30100:** Argo CD (readonly / a-super-secure-password). Shows sync status. Use to refresh applications after pushing commits.
- **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.` },
- { title: "Deploy Your Changes", body: `Commit and push to trigger the deployment:
+- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal.
+- **Port 30103:** Jaeger. Shows distributed traces from HotROD to verify that tracing is working end-to-end.` },
+ { title: "Fix the Manifests", content: `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", content: `Commit and push to trigger the deployment:
\`\`\`sh
git add adventures/01-echoes-lost-in-orbit/expert/manifests/
@@ -260,22 +246,22 @@ If you changed the OTel Collector config, restart it:
\`\`\`sh
kubectl rollout restart daemonset/collector -n otel
\`\`\`` },
- { title: "Watch the Rollout", body: `Watch rollout progress. The rollout should progress automatically based on analysis metrics:
+ { title: "Watch the Rollout", content: `Watch rollout progress. The rollout should progress automatically based on analysis metrics:
\`\`\`sh
kubectl argo rollouts get rollout hotrod -n hotrod --watch
\`\`\`` },
- { title: "Run the Smoke Test", body: `Run the smoke test to verify your solution:
+ { title: "Run the Smoke Test", content: `Run the smoke test to verify your solution:
\`\`\`sh
adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh
\`\`\`` },
],
helpfulLinks: [
- { label: "OpenTelemetry Collector configuration", url: "https://opentelemetry.io/docs/collector/configuration/" },
- { label: "Span Metrics Connector", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/spanmetricsconnector" },
- { label: "Argo Rollouts analysis", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" },
- { label: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" },
+ { title: "OpenTelemetry Collector configuration", url: "https://opentelemetry.io/docs/collector/configuration/" },
+ { title: "Span Metrics Connector", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/spanmetricsconnector" },
+ { title: "Argo Rollouts analysis", url: "https://argo-rollouts.readthedocs.io/en/stable/features/analysis/" },
+ { title: "PromQL basics", url: "https://prometheus.io/docs/prometheus/latest/querying/basics/" },
],
verification: {
command: "adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh",
diff --git a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml
index c7c331c6..c1a0d3b7 100644
--- a/src/data/adventures/echoes-lost-in-orbit/adventure.yaml
+++ b/src/data/adventures/echoes-lost-in-orbit/adventure.yaml
@@ -1,9 +1,10 @@
-id: echoes-lost-in-orbit
-title: Echoes Lost in Orbit
+slug: echoes-lost-in-orbit
+name: Echoes Lost in Orbit
+icon: Satellite
month: DEC 2025
story: Restore interstellar communications by fixing broken GitOps setups, progressive delivery systems, and
observability pipelines across three galactic missions.
-tags:
+technologies:
- Argo CD
- Argo Rollouts
- OpenTelemetry
@@ -36,9 +37,9 @@ levels:
- ApplicationSet templating & pitfalls
- Environment isolation & namespaces
- "Sync policies: automated, prune & self-heal"
- devcontainerPath: .devcontainer/01-echoes-lost-in-orbit_beginner/devcontainer.json
- discussionUrl: /t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40
- deadline: "10 December 2025 at 09:00 CET"
+ devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_beginner/devcontainer.json
+ discussion_url: /t/adventure-01-echoes-lost-in-orbit-easy-broken-echoes/117/40
+ deadline: "2025-12-10T09:00:00+01:00"
intro:
- >-
The Echo Server is down across both environments. Investigate the Argo CD ApplicationSet configuration,
@@ -61,20 +62,17 @@ levels:
- name: k9s
description: terminal UI for managing and inspecting your cluster
url: https://k9scli.io/
- howToPlay:
+ services:
+ - name: "Argo CD"
+ port: "30100"
+ credentials: "readonly / a-super-secure-password"
+ description: "View application sync status and manage Argo CD resources."
+ how_to_play:
- title: Wait for Infrastructure
- body: Wait around 5 minutes for the Codespace to provision a Kubernetes cluster, Argo CD, and the sample app. Press
+ content: Wait around 5 minutes for the Codespace to provision a Kubernetes cluster, Argo CD, and the sample app. Press
Cmd+Shift+P (or Ctrl+Shift+P on Windows/Linux) and search for 'View Creation Log' to track progress.
- - title: Access Argo CD
- body: |-
- Open the Ports tab, find port 30100 (Argo CD), and click the forwarded address. Log in with:
-
- ```
- Username: readonly
- Password: a-super-secure-password
- ```
- title: Fix the ApplicationSet
- body: >-
+ content: >-
All errors are in this file:
@@ -99,7 +97,7 @@ levels:
```
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution locally:
```sh
@@ -119,9 +117,9 @@ levels:
- Canary deployments & automated analysis
- Write PromQL queries for health validation
- Kube-state-metrics for deployment decisions
- devcontainerPath: .devcontainer/01-echoes-lost-in-orbit_intermediate/devcontainer.json
- discussionUrl: /t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8
- deadline: "24 December 2025 at 09:00 CET"
+ devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_intermediate/devcontainer.json
+ discussion_url: /t/adventure-01-echoes-lost-in-orbit-intermediate-the-silent-canary/310/8
+ deadline: "2025-12-24T09:00:00+01:00"
intro:
- >-
A canary rollout is stuck and the Zephyrians are still waiting to communicate. Debug the broken
@@ -155,25 +153,23 @@ levels:
- name: Argo Rollouts kubectl plugin
description: extended kubectl commands for managing rollouts
url: https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/
- howToPlay:
+ services:
+ - name: "Argo CD"
+ port: "30100"
+ credentials: "readonly / a-super-secure-password"
+ description: "Shows sync status. Use to refresh applications after pushing commits."
+ - name: "Argo Rollouts"
+ port: "30101"
+ description: "Shows canary deployment progress and analysis status."
+ - name: "Prometheus"
+ port: "30102"
+ description: "Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal."
+ how_to_play:
- title: Wait for Infrastructure
- body: Wait ~5-10 minutes for infrastructure to deploy. After it deploys, the setup script starts port forwarding to the
+ content: 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 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.
-
- Not a fan of user interfaces? You can also use the CLI tools to complete the challenge.
- title: Fix the Manifests
- body: >-
+ content: >-
Review and fix the configuration in `adventures/01-echoes-lost-in-orbit/intermediate/manifests/`.
@@ -181,7 +177,7 @@ levels:
(staging, prod). Argo CD detects and applies these automatically, so you don't need to run Kustomize commands
manually.
- title: Deploy Your Changes
- body: >-
+ content: >-
Commit and push your changes to trigger the deployment:
@@ -211,7 +207,7 @@ levels:
```
- title: Trigger the Rollout
- body: |-
+ content: |-
After Argo CD syncs, retry the rollouts:
```sh
@@ -219,7 +215,7 @@ levels:
kubectl argo rollouts retry rollout echo-server -n echo-prod
```
- title: Watch the Rollout
- body: |-
+ content: |-
Watch canary progress (should advance 33% to 66% to 100%):
```sh
@@ -229,20 +225,20 @@ levels:
In real-world progressive delivery, staging is updated first, validated, and then changes are promoted to production. This challenge skips that separation so you can focus on the canary rollout mechanics and health checks without managing two promotion steps.
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution:
```sh
adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh
```
- helpfulLinks:
- - label: Argo Rollouts documentation
+ helpful_links:
+ - title: Argo Rollouts documentation
url: https://argo-rollouts.readthedocs.io/en/stable/
- - label: Analysis and progressive delivery
+ - title: Analysis and progressive delivery
url: https://argo-rollouts.readthedocs.io/en/stable/features/analysis/
- - label: PromQL basics
+ - title: PromQL basics
url: https://prometheus.io/docs/prometheus/latest/querying/basics/
- - label: kube-state-metrics exposed metrics
+ - title: kube-state-metrics exposed metrics
url: https://github.com/kubernetes/kube-state-metrics/tree/main/docs#exposed-metrics
verification:
command: "adventures/01-echoes-lost-in-orbit/intermediate/smoke-test.sh"
@@ -260,9 +256,9 @@ levels:
- Spanmetrics connector (traces to metrics)
- Detect idle canaries with traffic validation
- Distributed tracing with Jaeger
- devcontainerPath: .devcontainer/01-echoes-lost-in-orbit_expert/devcontainer.json
- deadline: "14 January 2026 at 09:00 CET"
- discussionUrl: /t/adventure-01-echoes-lost-in-orbit-expert-hyperspace-operations-transport/351/4
+ devcontainer_path: .devcontainer/01-echoes-lost-in-orbit_expert/devcontainer.json
+ deadline: "2026-01-14T09:00:00+01:00"
+ discussion_url: /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
@@ -303,29 +299,29 @@ levels:
- name: Argo Rollouts kubectl plugin
description: extended kubectl commands for managing rollouts
url: https://argo-rollouts.readthedocs.io/en/stable/features/kubectl-plugin/
- howToPlay:
+ services:
+ - name: "Argo CD"
+ port: "30100"
+ credentials: "readonly / a-super-secure-password"
+ description: "Shows sync status. Use to refresh applications after pushing commits."
+ - name: "Argo Rollouts"
+ port: "30101"
+ description: "Shows canary deployment progress and analysis status."
+ - name: "Prometheus"
+ port: "30102"
+ description: "Explore available metrics and test PromQL queries. CLI tools work equally well if you prefer the terminal."
+ - name: "Jaeger"
+ port: "30103"
+ description: "Shows distributed traces from HotROD to verify that tracing is working end-to-end."
+ how_to_play:
- title: Wait for Infrastructure
- body: Wait ~5-10 minutes for infrastructure to deploy. Port forwarding starts automatically after infrastructure is
+ content: 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 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,
+ content: 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: >-
+ content: >-
Commit and push to trigger the deployment:
@@ -375,27 +371,27 @@ levels:
```
- title: Watch the Rollout
- body: |-
+ content: |-
Watch rollout progress. The rollout should progress automatically based on analysis metrics:
```sh
kubectl argo rollouts get rollout hotrod -n hotrod --watch
```
- title: Run the Smoke Test
- body: |-
+ content: |-
Run the smoke test to verify your solution:
```sh
adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh
```
- helpfulLinks:
- - label: OpenTelemetry Collector configuration
+ helpful_links:
+ - title: OpenTelemetry Collector configuration
url: https://opentelemetry.io/docs/collector/configuration/
- - label: Span Metrics Connector
+ - title: Span Metrics Connector
url: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/spanmetricsconnector
- - label: Argo Rollouts analysis
+ - title: Argo Rollouts analysis
url: https://argo-rollouts.readthedocs.io/en/stable/features/analysis/
- - label: PromQL basics
+ - title: PromQL basics
url: https://prometheus.io/docs/prometheus/latest/querying/basics/
verification:
command: "adventures/01-echoes-lost-in-orbit/expert/smoke-test.sh"
diff --git a/src/data/adventures/the-ai-observatory.generated.ts b/src/data/adventures/the-ai-observatory.generated.ts
index 61c51b1b..09a2ae6e 100644
--- a/src/data/adventures/the-ai-observatory.generated.ts
+++ b/src/data/adventures/the-ai-observatory.generated.ts
@@ -4,6 +4,7 @@ import type { Adventure } from "./types";
export const THE_AI_OBSERVATORY: Adventure = {
id: "the-ai-observatory",
title: "The AI Observatory",
+ icon: "Telescope",
month: "FEB 2026",
story: "Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with OpenTelemetry, OpenLLMetry, and Jaeger.",
tags: ["OpenTelemetry", "OpenLLMetry", "Jaeger", "Prometheus", "Python"],
@@ -29,7 +30,7 @@ export const THE_AI_OBSERVATORY: Adventure = {
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_01-beginner%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/instrument-your-first-llm-adventure-03-beginner-is-live/865/8`,
- deadline: "8 March 2026 at 23:59 CET",
+ deadline: "2026-03-08T23:59:00+01:00",
intro: [
"Something is eating 847% of your station's bandwidth and nobody knows what. Instrument HubSystem with OpenLLMetry, send traces to the OpenTelemetry Collector, and use Jaeger to uncover what the AI is doing behind the scenes.",
],
@@ -56,21 +57,23 @@ export const THE_AI_OBSERVATORY: Adventure = {
{ name: "k9s", description: "terminal UI for managing and inspecting your cluster", url: "https://k9scli.io/" },
],
howToPlay: [
- { title: "Wait for Infrastructure", body: "Wait ~10 minutes for all infrastructure to initialize." },
- { title: "Open Jaeger", body: "Open the Ports tab, find Jaeger at port 30103. This is where you will analyze the traces sent by HubSystem." },
- { title: "Instrument the App", body: `The application code is in \`./hubsystem.py\`. Add OpenTelemetry instrumentation using OpenLLMetry. The OTel
+ { title: "Wait for Infrastructure", content: "Wait ~10 minutes for all infrastructure to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30103:** Jaeger. Analyze the traces sent by HubSystem.` },
+ { title: "Instrument the App", content: `The application code is in \`./hubsystem.py\`. Add OpenTelemetry instrumentation using OpenLLMetry. The OTel
Collector and Jaeger are already configured correctly; you only need to instrument the app. You do not need to
interact with Kubernetes directly. The cluster is already running, so focus on the Python code.` },
- { title: "Run and Investigate", body: `Run the application, interact with the AI to generate traces, then check Jaeger:
+ { title: "Run and Investigate", content: `Run the application, interact with the AI to generate traces, then check Jaeger:
\`\`\`sh
make hubsystem
\`\`\`` },
- { title: "Answer the Quiz", body: `Find the trace responsible for the high bandwidth usage and inspect its attributes to answer \`quiz.txt\`.` },
+ { title: "Answer the Quiz", content: `Find the trace responsible for the high bandwidth usage and inspect its attributes to answer \`quiz.txt\`.` },
],
helpfulLinks: [
- { label: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" },
- { label: "Jaeger documentation", url: "https://www.jaegertracing.io/docs/latest/" },
+ { title: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" },
+ { title: "Jaeger documentation", url: "https://www.jaegertracing.io/docs/latest/" },
],
verification: {
command: "./verify.sh",
@@ -89,7 +92,7 @@ make hubsystem
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_02-intermediate%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/instrument-debug-a-rag-pipeline-adventure-03-intermediate-is-live/936/2`,
- deadline: "8 March 2026 at 23:59 CET",
+ deadline: "2026-03-08T23:59:00+01:00",
intro: [
"ART's RAG pipeline is retrieving entertainment data instead of navigation coordinates and won't calculate your jump. Instrument the full retrieval pipeline with OpenLLMetry, build a custom OTel metric to quantify the distraction, and write a Prometheus recording rule to prove it.",
],
@@ -123,27 +126,30 @@ That's not normal. ART is never vague. You access the ship's diagnostic systems
{ name: "k9s", description: "terminal UI for managing and inspecting your cluster", url: "https://k9scli.io/" },
],
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 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:
+ { title: "Wait for Infrastructure", content: "Wait ~15 minutes for all infrastructure to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30102:** Prometheus. Explore available metrics and test PromQL queries.
+- **Port 30103:** Jaeger. Shows distributed traces from ART to verify that tracing is working end-to-end.` },
+ { title: "Instrument and Configure", content: `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
make apply
\`\`\`` },
- { title: "Generate Traffic", body: `Run the application to interact with ART ("Calculate jump"), or generate continuous traffic for your metric graphs:
+ { title: "Generate Traffic", content: `Run the application to interact with ART ("Calculate jump"), or generate continuous traffic for your metric graphs:
\`\`\`sh
make art
# or for continuous traffic:
make traffic
\`\`\`` },
- { title: "Fix the Navigation", body: "Verify traces in Jaeger and the recording rule in Prometheus. Fix the navigation system so ART returns jump coordinates to RaviHyral." },
+ { title: "Fix the Navigation", content: "Verify traces in Jaeger and the recording rule in Prometheus. Fix the navigation system so ART returns jump coordinates to RaviHyral." },
],
helpfulLinks: [
- { label: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" },
- { label: "OpenTelemetry Python metrics", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#metrics" },
- { label: "Prometheus recording rules", url: "https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/" },
- { label: "Qdrant filtering", url: "https://qdrant.tech/documentation/concepts/filtering/" },
+ { title: "OpenLLMetry SDK for Python", url: "https://traceloop.com/docs/openllmetry/getting-started-python" },
+ { title: "OpenTelemetry Python metrics", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#metrics" },
+ { title: "Prometheus recording rules", url: "https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/" },
+ { title: "Qdrant filtering", url: "https://qdrant.tech/documentation/concepts/filtering/" },
],
verification: {
command: "./verify.sh",
@@ -161,7 +167,7 @@ make traffic
],
codespacesUrl: `${CODESPACES_BASE}?devcontainer_path=.devcontainer%2F03-the-ai-observatory_03-expert%2Fdevcontainer.json&quickstart=1`,
discussionUrl: `${COMMUNITY_URL}/t/reduce-telemetry-noise-adventure-03-expert-is-live/999/1`,
- deadline: "8 March 2026 at 23:59 CET",
+ deadline: "2026-03-08T23:59:00+01:00",
intro: [
"ART is flooding Jaeger with 40,000 non-standard spans an hour. Fix the chat span to follow OpenTelemetry GenAI semantic conventions with proper token usage attributes, then configure tail sampling in the Collector to keep only traces that contain errors or exceed 5 seconds.",
],
@@ -197,13 +203,15 @@ ART: "...Fine."`,
{ name: "k9s", description: "terminal UI for managing and inspecting your cluster", url: "https://k9scli.io/" },
],
howToPlay: [
- { title: "Wait for Infrastructure", body: "Wait ~15 minutes for all infrastructure to initialize." },
- { title: "Open Jaeger", body: "Open the Ports tab, find Jaeger at port 30103. Verify your spans look correct and that sampling works as expected." },
- { title: "Fix Instrumentation and Sampling", body: `Fix two things:
+ { title: "Wait for Infrastructure", content: "Wait ~15 minutes for all infrastructure to initialize." },
+ { title: "Explore the UIs", content: `Open the **Ports** tab and navigate to each service:
+
+- **Port 30103:** Jaeger. Verify your spans look correct and that tail sampling works as expected.` },
+ { title: "Fix Instrumentation and Sampling", content: `Fix two things:
1. The application code in \`./art.py\`: update the \`chat\` span to follow OpenTelemetry GenAI semantic conventions, including token usage attributes.
2. The collector config in \`./manifests/otel-collector-config.yaml\`: configure tail sampling to keep only traces that contain errors or take longer than 5 seconds.` },
- { title: "Apply and Test", body: `After changing \`art.py\`, restart traffic to pick up new instrumentation. After changing the collector config, apply it:
+ { title: "Apply and Test", content: `After changing \`art.py\`, restart traffic to pick up new instrumentation. After changing the collector config, apply it:
\`\`\`sh
kubectl apply -f manifests/otel-collector-config.yaml -n otel
@@ -219,10 +227,10 @@ make traffic
Verify in Jaeger that spans follow conventions and only errors and slow traces appear.` },
],
helpfulLinks: [
- { label: "OpenTelemetry GenAI semantic conventions", url: "https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/" },
- { label: "OpenTelemetry Python: recording exceptions", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#record-exceptions" },
- { label: "OTel Collector tail sampling processor", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor" },
- { label: "Python contextlib.contextmanager", url: "https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager" },
+ { title: "OpenTelemetry GenAI semantic conventions", url: "https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/" },
+ { title: "OpenTelemetry Python: recording exceptions", url: "https://opentelemetry.io/docs/languages/python/instrumentation/#record-exceptions" },
+ { title: "OTel Collector tail sampling processor", url: "https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor" },
+ { title: "Python contextlib.contextmanager", url: "https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager" },
],
verification: {
command: "./verify.sh",
diff --git a/src/data/adventures/the-ai-observatory/adventure.yaml b/src/data/adventures/the-ai-observatory/adventure.yaml
index 22629016..47ebd372 100644
--- a/src/data/adventures/the-ai-observatory/adventure.yaml
+++ b/src/data/adventures/the-ai-observatory/adventure.yaml
@@ -1,9 +1,10 @@
-id: the-ai-observatory
-title: The AI Observatory
+slug: the-ai-observatory
+name: The AI Observatory
+icon: Telescope
month: FEB 2026
story: Investigate a mysterious bandwidth anomaly at a remote research station by instrumenting its AI system with
OpenTelemetry, OpenLLMetry, and Jaeger.
-tags:
+technologies:
- OpenTelemetry
- OpenLLMetry
- Jaeger
@@ -32,9 +33,9 @@ levels:
learnings:
- Instrument Python AI apps with OpenLLMetry
- Analyze traces in Jaeger
- devcontainerPath: .devcontainer/03-the-ai-observatory_01-beginner/devcontainer.json
- discussionUrl: /t/instrument-your-first-llm-adventure-03-beginner-is-live/865/8
- deadline: "8 March 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/03-the-ai-observatory_01-beginner/devcontainer.json
+ discussion_url: /t/instrument-your-first-llm-adventure-03-beginner-is-live/865/8
+ deadline: "2026-03-08T23:59:00+01:00"
intro:
- Something is eating 847% of your station's bandwidth and nobody knows what. Instrument HubSystem with
OpenLLMetry, send traces to the OpenTelemetry Collector, and use Jaeger to uncover what the AI is doing behind
@@ -77,29 +78,31 @@ levels:
- name: k9s
description: terminal UI for managing and inspecting your cluster
url: https://k9scli.io/
- howToPlay:
+ services:
+ - name: "Jaeger"
+ port: "30103"
+ description: "Analyze the traces sent by HubSystem."
+ how_to_play:
- title: Wait for Infrastructure
- body: Wait ~10 minutes for all infrastructure to initialize.
- - title: Open Jaeger
- body: Open the Ports tab, find Jaeger at port 30103. This is where you will analyze the traces sent by HubSystem.
+ content: Wait ~10 minutes for all infrastructure to initialize.
- title: Instrument the App
- body: |-
+ content: |-
The application code is in `./hubsystem.py`. Add OpenTelemetry instrumentation using OpenLLMetry. The OTel
Collector and Jaeger are already configured correctly; you only need to instrument the app. You do not need to
interact with Kubernetes directly. The cluster is already running, so focus on the Python code.
- title: Run and Investigate
- body: |-
+ content: |-
Run the application, interact with the AI to generate traces, then check Jaeger:
```sh
make hubsystem
```
- title: Answer the Quiz
- body: Find the trace responsible for the high bandwidth usage and inspect its attributes to answer `quiz.txt`.
- helpfulLinks:
- - label: OpenLLMetry SDK for Python
+ content: Find the trace responsible for the high bandwidth usage and inspect its attributes to answer `quiz.txt`.
+ helpful_links:
+ - title: OpenLLMetry SDK for Python
url: https://traceloop.com/docs/openllmetry/getting-started-python
- - label: Jaeger documentation
+ - title: Jaeger documentation
url: https://www.jaegertracing.io/docs/latest/
verification:
command: ./verify.sh
@@ -117,9 +120,9 @@ levels:
- Instrument RAG pipelines with OpenLLMetry
- Create custom OpenTelemetry metrics in Python
- Write PromQL queries & recording rules in Prometheus
- devcontainerPath: .devcontainer/03-the-ai-observatory_02-intermediate/devcontainer.json
- discussionUrl: /t/instrument-debug-a-rag-pipeline-adventure-03-intermediate-is-live/936/2
- deadline: "8 March 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/03-the-ai-observatory_02-intermediate/devcontainer.json
+ discussion_url: /t/instrument-debug-a-rag-pipeline-adventure-03-intermediate-is-live/936/2
+ deadline: "2026-03-08T23:59:00+01:00"
intro:
- ART's RAG pipeline is retrieving entertainment data instead of navigation coordinates and won't calculate your
jump. Instrument the full retrieval pipeline with OpenLLMetry, build a custom OTel metric to quantify the
@@ -168,13 +171,18 @@ levels:
- name: k9s
description: terminal UI for managing and inspecting your cluster
url: https://k9scli.io/
- howToPlay:
+ services:
+ - name: "Prometheus"
+ port: "30102"
+ description: "Explore available metrics and test PromQL queries."
+ - name: "Jaeger"
+ port: "30103"
+ description: "Shows distributed traces from ART to verify that tracing is working end-to-end."
+ how_to_play:
- 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 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."
+ content: Wait ~15 minutes for all infrastructure to initialize.
- title: Instrument and Configure
- body: >-
+ content: >-
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:
@@ -186,7 +194,7 @@ levels:
```
- title: Generate Traffic
- body: |-
+ content: |-
Run the application to interact with ART ("Calculate jump"), or generate continuous traffic for your metric graphs:
```sh
@@ -195,16 +203,16 @@ levels:
make traffic
```
- title: Fix the Navigation
- body: Verify traces in Jaeger and the recording rule in Prometheus. Fix the navigation system so ART returns jump
+ content: Verify traces in Jaeger and the recording rule in Prometheus. Fix the navigation system so ART returns jump
coordinates to RaviHyral.
- helpfulLinks:
- - label: OpenLLMetry SDK for Python
+ helpful_links:
+ - title: OpenLLMetry SDK for Python
url: https://traceloop.com/docs/openllmetry/getting-started-python
- - label: OpenTelemetry Python metrics
+ - title: OpenTelemetry Python metrics
url: https://opentelemetry.io/docs/languages/python/instrumentation/#metrics
- - label: Prometheus recording rules
+ - title: Prometheus recording rules
url: https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/
- - label: Qdrant filtering
+ - title: Qdrant filtering
url: https://qdrant.tech/documentation/concepts/filtering/
verification:
command: ./verify.sh
@@ -220,9 +228,9 @@ levels:
learnings:
- OpenTelemetry GenAI semantic conventions
- Tail sampling in the OTel Collector
- devcontainerPath: .devcontainer/03-the-ai-observatory_03-expert/devcontainer.json
- discussionUrl: /t/reduce-telemetry-noise-adventure-03-expert-is-live/999/1
- deadline: "8 March 2026 at 23:59 CET"
+ devcontainer_path: .devcontainer/03-the-ai-observatory_03-expert/devcontainer.json
+ discussion_url: /t/reduce-telemetry-noise-adventure-03-expert-is-live/999/1
+ deadline: "2026-03-08T23:59:00+01:00"
intro:
- ART is flooding Jaeger with 40,000 non-standard spans an hour. Fix the chat span to follow OpenTelemetry GenAI
semantic conventions with proper token usage attributes, then configure tail sampling in the Collector to keep
@@ -272,19 +280,21 @@ levels:
- name: k9s
description: terminal UI for managing and inspecting your cluster
url: https://k9scli.io/
- howToPlay:
+ services:
+ - name: "Jaeger"
+ port: "30103"
+ description: "Verify your spans look correct and that tail sampling works as expected."
+ how_to_play:
- title: Wait for Infrastructure
- body: Wait ~15 minutes for all infrastructure to initialize.
- - title: Open Jaeger
- body: Open the Ports tab, find Jaeger at port 30103. Verify your spans look correct and that sampling works as expected.
+ content: Wait ~15 minutes for all infrastructure to initialize.
- title: Fix Instrumentation and Sampling
- body: |-
+ content: |-
Fix two things:
1. The application code in `./art.py`: update the `chat` span to follow OpenTelemetry GenAI semantic conventions, including token usage attributes.
2. The collector config in `./manifests/otel-collector-config.yaml`: configure tail sampling to keep only traces that contain errors or take longer than 5 seconds.
- title: Apply and Test
- body: >-
+ content: >-
After changing `art.py`, restart traffic to pick up new instrumentation. After changing the collector config,
apply it:
@@ -309,14 +319,14 @@ levels:
Verify in Jaeger that spans follow conventions and only errors and slow traces appear.
- helpfulLinks:
- - label: OpenTelemetry GenAI semantic conventions
+ helpful_links:
+ - title: OpenTelemetry GenAI semantic conventions
url: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
- - label: "OpenTelemetry Python: recording exceptions"
+ - title: "OpenTelemetry Python: recording exceptions"
url: https://opentelemetry.io/docs/languages/python/instrumentation/#record-exceptions
- - label: OTel Collector tail sampling processor
+ - title: OTel Collector tail sampling processor
url: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/tailsamplingprocessor
- - label: Python contextlib.contextmanager
+ - title: Python contextlib.contextmanager
url: https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager
verification:
command: ./verify.sh
diff --git a/src/data/adventures/types.ts b/src/data/adventures/types.ts
index 120a0fae..1c636a41 100644
--- a/src/data/adventures/types.ts
+++ b/src/data/adventures/types.ts
@@ -5,10 +5,10 @@ export type ToolboxItem = {
url?: string;
}
-/** One step in the Walkthrough section. body is rendered as markdown so it can contain code blocks and links. */
+/** One step in the Walkthrough section. content is rendered as markdown so it can contain code blocks and links. */
export type WalkthroughStep = {
title: string;
- body: string;
+ content: string;
}
/** Compact card shown beneath the Walkthrough, summarising how to confirm completion. */
@@ -19,8 +19,9 @@ export type VerificationInfo = {
/** A reference documentation link shown at the end of the challenge walkthrough. */
export type HelpfulLink = {
- label: string;
+ title: string;
url: string;
+ description?: string;
}
/** Mock entry in the "Top players" leaderboard inside the CommunitySidebar. */
@@ -61,10 +62,12 @@ export type AdventureLevel = {
scenario?: string;
// Plain-prose architectural context paragraphs for the challenge.
architecture?: string[];
- // SVG import for an architecture diagram (rendered as an image).
+ // SVG import for an architecture diagram (rendered as an image). Takes priority over architectureAscii.
architectureDiagram?: string;
// Accessible alt text for the architecture diagram image.
diagramAlt?: string;
+ // ASCII art diagram rendered as a block when no SVG diagram is available.
+ architectureAscii?: string;
// Tools pre-installed in the Codespace, rendered as a row of cards.
toolbox: ToolboxItem[];
// Numbered walkthrough rendered as a vertical stepper.
@@ -105,8 +108,10 @@ export type Adventure = {
contributor?: { name: string; url?: string; about?: string };
// Narrative backstory paragraphs shown on the adventure overview page.
backstory?: string[];
- // Optional "What you'll be using" style context section shown after the backstory.
- context?: { title: string; body: string[] };
+ // Context paragraphs explaining what technologies or concepts the adventure covers.
+ overview?: string[];
+ // Lucide React icon name representing this adventure (e.g. 'FlaskConical').
+ icon?: string;
rewards?: AdventureRewards;
// Mock placeholders for levels that haven't shipped yet. Rendered in the
// "More levels" sidebar card alongside actual sibling levels.
@@ -130,7 +135,7 @@ export type RelatedLevel = {
/**
* Lightweight level shape used for card and filter views on the home/challenges pages.
* Contains only the fields needed to render AdventureCard and FilteredLevelCard.
- * Generated into summaries.ts — do not import the full AdventureLevel where this suffices.
+ * Generated into summaries.ts. Do not import the full AdventureLevel where this suffices.
*/
export type AdventureLevelSummary = {
id: string;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 4be2c8d5..320d68d7 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -6,12 +6,26 @@ export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
-/**
- * Returns true if the deadline string represents a date in the past.
- * Handles the format "10 December 2025 at 09:00 CET" by extracting the date portion.
- */
+/** Returns true if the ISO 8601 deadline string represents a date in the past. */
export function isDeadlinePast(deadline: string | undefined): boolean {
if (!deadline) return false;
- const date = new Date(deadline.split(" at ")[0].trim());
+ const date = new Date(deadline);
return !isNaN(date.getTime()) && date < new Date();
}
+
+/**
+ * Formats an ISO 8601 deadline string for human display.
+ * Preserves the stored UTC offset rather than converting to the viewer's local timezone.
+ * "+01:00" displays as CET, "+02:00" displays as CEST.
+ */
+export function formatDeadline(iso: string): string {
+ const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}([+-]\d{2}:\d{2})$/);
+ if (!match) return iso;
+ const [, year, month, day, hours, minutes, offset] = match;
+ const monthNames = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December",
+ ];
+ const tzLabel = offset === "+01:00" ? "CET" : offset === "+02:00" ? "CEST" : `UTC${offset}`;
+ return `${parseInt(day)} ${monthNames[parseInt(month) - 1]} ${year} at ${hours}:${minutes} ${tzLabel}`;
+}
diff --git a/src/pages/AdventureDetail.tsx b/src/pages/AdventureDetail.tsx
index 9c039ab2..618ca8d5 100644
--- a/src/pages/AdventureDetail.tsx
+++ b/src/pages/AdventureDetail.tsx
@@ -1,7 +1,14 @@
import { type JSX } from "react";
import { useParams, Link, useLoaderData } from "react-router";
import type { MetaFunction, LoaderFunctionArgs } from "react-router";
-import { ArrowRight } from "lucide-react";
+import { ArrowRight, FlaskConical, Satellite, Cloud, Telescope, type LucideIcon } from "lucide-react";
+
+const ADVENTURE_ICONS: Record = {
+ FlaskConical,
+ Satellite,
+ Cloud,
+ Telescope,
+};
import { ADVENTURES, type AdventureLevel } from "@/data/adventures";
import { NotFoundPage } from "@/components/NotFoundPage";
import { Navbar } from "@/components/Navbar";
@@ -99,7 +106,13 @@ const AdventureDetail = (): JSX.Element => {
{adventure.month}
- {adventure.title}
+
+ {adventure.title}
+ {adventure.icon && ADVENTURE_ICONS[adventure.icon] && (() => {
+ const Icon = ADVENTURE_ICONS[adventure.icon!];
+ return ;
+ })()}
+
{adventure.contributor && (
@@ -134,10 +147,10 @@ const AdventureDetail = (): JSX.Element => {
)}
{/* Your Mission */}
- {adventure.context && (
-
+ {adventure.overview && adventure.overview.length > 0 && (
+
- {adventure.context.body.map((para, i) => (
+ {adventure.overview.map((para, i) => (
-
{para}
diff --git a/src/pages/ChallengeDetail.tsx b/src/pages/ChallengeDetail.tsx
index cde3f36c..fcbebb2d 100644
--- a/src/pages/ChallengeDetail.tsx
+++ b/src/pages/ChallengeDetail.tsx
@@ -70,7 +70,7 @@ type StructuredLayoutProps = {
};
const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayoutProps): JSX.Element => {
- const { intro, objective, toolbox, backstory, architecture, architectureDiagram, diagramAlt, howToPlay, helpfulLinks, verification } = level;
+ const { intro, objective, toolbox, backstory, architecture, architectureDiagram, diagramAlt, architectureAscii, howToPlay, helpfulLinks, verification } = level;
return (
<>
{/* Header */}
@@ -146,11 +146,12 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo
{backstory && backstory.length > 0 && (
)}
- {((architecture && architecture.length > 0) || architectureDiagram) && (
+ {((architecture && architecture.length > 0) || architectureDiagram || architectureAscii) && (
0 ? architecture.join("\n\n") : undefined}
diagram={architectureDiagram}
diagramAlt={diagramAlt}
+ ascii={architectureAscii}
/>
)}
@@ -174,7 +175,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo
{howToPlay && howToPlay.length > 0 && (
encodeURIComponent(c))}). The devcontainer is pre-configured and starts automatically. When you push from Codespaces, GitHub forks the repository to your account automatically.\n\nPrefer working locally? Clone the repo and open it in any editor that supports the Dev Containers specification (VS Code, JetBrains IDEs, and others). The devcontainer config will be detected automatically.` },
+ { title: "Get Started", content: `[Open in GitHub Codespaces](${level.codespacesUrl.replace(/[()]/g, (c) => encodeURIComponent(c))}). The devcontainer is pre-configured and starts automatically. When you push from Codespaces, GitHub forks the repository to your account automatically.\n\nPrefer working locally? Clone the repo and open it in any editor that supports the Dev Containers specification (VS Code, JetBrains IDEs, and others). The devcontainer config will be detected automatically.` },
...howToPlay,
]}
/>
@@ -275,7 +276,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo
rel="noopener noreferrer"
className="docs-ext-link font-medium"
>
- {link.label}
+ {link.title}
(opens in new tab)
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
index 2fd0d9e9..ed143798 100644
--- a/src/pages/NotFound.tsx
+++ b/src/pages/NotFound.tsx
@@ -56,7 +56,7 @@ const NotFound = (): JSX.Element => {
- {/* Cards + CTA — full width */}
+ {/* Cards + CTA, full width */}