# API Monitoring and Logging

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: Production-Ready APIs
Source: https://www.devtutorials.ai/learn/building-apis/production-ready/monitoring-and-observability

> Use structured logs, correlation ids, health checks and RED metrics to see what your API is doing and find bugs fast.

**API monitoring and logging** means collecting information about what your API is doing, so that you can find problems, understand failures, and keep the system healthy.

If users report errors, slow responses, or missing data, monitoring and logging help you discover what happened and why. Without visibility into your API, diagnosing problems becomes difficult. Good monitoring helps you detect issues early, often before customers notice them.

## Watch a Bug Hide in the Logs

Customers report that some orders fail, while others work normally. Press **Run**.

Results:

- The first order succeeds with `201 Created`.
- The second order fails with `500 Internal Server Error`.
- The logs show: `Order received`, `Order received`, `Something failed`

The problem is that these logs do not answer important questions. Which request failed? Which customer submitted it? Which application version sent it? What caused the failure? The logs record that something happened, but not enough information to investigate it.

**In simple words:** logs are only useful if they contain enough information to explain what happened.

In these examples, log entries are stored in memory and shown through `GET /debug/logs`. Real applications send logs to central systems such as Seq, Elasticsearch, Azure Monitor, or Application Insights. Never expose a `/debug/logs` endpoint in production.

## TL;DR

- **Logs** record individual events.
- **Metrics** show numbers over time.
- **Health checks** indicate whether the API can currently serve requests.
- Use **structured logs** with named fields.
- Give every request a **correlation id**.
- Return the correlation id in the response.
- Create liveness and readiness health checks.
- Monitor the rate, the errors, and the duration (RED metrics).
- Monitor **p95** latency, not only averages.
- Never log passwords, tokens, API keys, or full card numbers.

## A Real-World Example

Think about an aircraft flight recorder. It does not record messages such as "The flight was fine." Instead, it records specific values, such as the speed, the altitude, the engine temperature, and the positions of the switches. Investigators can rebuild exactly what happened.

Good API monitoring works the same way. Instead of recording vague messages, it records facts that can be searched, filtered, and analyzed.

## What You Will Learn

In this lesson, you will learn:

1. Why plain text logs are often insufficient.
2. How structured logging improves troubleshooting.
3. How correlation ids track requests across systems.
4. How health checks work.
5. How RED metrics measure API performance.
6. How monitoring helps identify root causes quickly.

## Structured Logs

A structured log stores properties with names instead of writing plain sentences.

Example: `{"level": "Error", "event": "OrderFailed", "path": "/orders", "status": 500, "appVersion": "2.3", "error": "quantity is not a number"}`

Each property can be searched and filtered.

When you review the logs, you see that the successful orders come from app version `2.4`, and the failed orders come from app version `2.3`. The error message is: `quantity is not a number`

The root cause becomes obvious. The older application version is submitting invalid data.

Because the information is stored as fields, you can easily filter with `level = Error` and see only the failures.

Use the same field names everywhere in the application. For example, `appVersion` should always be called `appVersion`, not sometimes `appVer` or `version`. Consistent names make searching and reporting much easier.

### Structured Logging in ASP.NET Core

Real ASP.NET Core applications commonly use `ILogger`.

Example:

```csharp
logger.LogError(
    "Order failed for app {AppVersion}: {Error}",
    "2.3",
    "quantity is not a number");
```

The values inside braces become structured fields that can be queried later.

Never log passwords, access tokens, API keys, refresh tokens, or full credit card numbers. If card information is needed, log only the last four digits.

## Follow a Request with Correlation IDs

A single request often creates multiple log entries. For example: `OrderReceived`, `ValidatePayment`, `PaymentCharged`, `OrderCompleted`

Without a shared identifier, it is difficult to tell which entries belong together. A **correlation id** solves this problem.

Middleware performs the following steps:

1. Reads `X-Correlation-Id` if provided.
2. Generates one if missing.
3. Adds it to every log entry.
4. Returns it in the response.

Example: the client request has the header `X-Correlation-Id: app-7f3a91`, and the response has the header `X-Correlation-Id: app-7f3a91`.

If no value is provided, a value such as `X-Correlation-Id: req-0001` is generated automatically.

Now every log entry contains the same identifier. Searching for `req-0001` shows the complete story of that request.

A common industry standard is **W3C Trace Context**. It uses a `traceparent` header, so that tracing information can flow between services. **OpenTelemetry** builds on this standard and collects logs, metrics, and traces across distributed systems.

## Health Checks

A health check endpoint reports whether the API is currently healthy. Load balancers, containers, and orchestration systems use these endpoints to decide whether traffic should be sent to the service.

### Liveness Check

It answers the question: **Is the application running?** Example path: `/health/live`. If the process is alive, it returns `200 OK`.

### Readiness Check

It answers the question: **Can the application serve requests right now?** The readiness check verifies dependencies such as databases, message queues, payment services, and external APIs.

Example: `/health/live` returns `200 OK` with `Healthy`, while `/health` returns `503 Service Unavailable`, because the payments dependency is unavailable. The response explains which dependency failed.

### ASP.NET Core Example

```csharp
builder.Services.AddHealthChecks()
    .AddCheck("database", () => HealthCheckResult.Healthy())
    .AddCheck("payments", () => HealthCheckResult.Unhealthy());

app.MapHealthChecks("/health");
```

## RED Metrics

Logs explain individual events. Metrics show overall system performance. A popular monitoring approach is **RED Metrics**:

### Rate

How many requests are received. Example: `500 requests per minute`

### Errors

The percentage of requests that fail. Example: `2% 5xx responses`

### Duration

How long requests take. Measure the duration using `p95` rather than averages.

Results:

### GET /menu

The results are: `0% errors`, `Average: 30ms` and `p95: 90ms`.

### POST /orders

The results are: `40% errors`, `Average: 220ms` and `p95: 440ms`.

Grouping by application version shows: `Version 2.4 → 0 errors` and `Version 2.3 → 4 errors`.

The root cause becomes clear. Only version `2.3` is failing.

### Why p95 Matters

The average hides slow requests.

Example: there are `10 requests`. `9 requests = 20ms`, and `1 request = 400ms`. The average is `58ms`, so the average looks acceptable. But the slow customer experienced `400ms`.

p95 helps reveal those slow experiences.

## Logs vs Metrics vs Health Checks

| Tool | Purpose |
|---|---|
| Logs | Explain individual events |
| Metrics | Measure system trends over time |
| Health Checks | Show whether the application can currently serve requests |
| Correlation IDs | Link related events together |
| Traces | Follow requests across multiple services |

You need all of them to operate a production system effectively.

## Where You See This

- Azure Application Insights, Grafana, Datadog, and similar platforms collect logs and metrics automatically.
- Kubernetes uses liveness and readiness probes to restart unhealthy containers.
- Support teams ask customers for correlation ids to investigate problems.
- Mobile development teams group failures by app version.
- Production systems use RED metrics to detect outages before users report them.

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| Logs do not explain failures | Logs contain only plain text | Use structured logging |
| Related events cannot be connected | No correlation id exists | Add correlation-id middleware |
| Traffic continues reaching unhealthy services | Readiness checks are incomplete | Verify dependencies in readiness checks |
| Customers report slowness but dashboards look healthy | Only averages are monitored | Track p95 and p99 latency |
| Secrets appear in log systems | Sensitive information is logged | Remove tokens, passwords, and private data from logs |
| Bugs affect one client version for weeks | Errors are not grouped by app version | Include version information in logs and metrics |

## Exercises

## What to Remember

- You cannot fix what you cannot see. Logs, metrics and health checks let you see.
- Log **structured entries** with named fields, and never log secrets.
- A **correlation id** ties together every log line of one request.
- **Liveness** says that the process runs. **Readiness** says that it can serve customers. Return `503` when it cannot.
- Watch **Rate, Errors and Duration** for each endpoint, and use **p95**, not the average.

**Next:** API Testing. You will write automated tests that check every endpoint on every change, so that a bug never comes back.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// A real API sends logs to a log store. Here we keep them in a list,
// and GET /debug/logs shows them (only for this demo).
var logs = new List<string>();
int nextId = 101;

app.MapPost("/orders", async (HttpContext context) =>
{
    logs.Add("Order received");
    using var doc = await JsonDocument.ParseAsync(context.Request.Body);
    JsonElement quantity = doc.RootElement.GetProperty("quantity");
    if (quantity.ValueKind != JsonValueKind.Number)
    {
        logs.Add("Something failed"); // But what? For whom? We cannot tell.
        return Results.StatusCode(500);
    }
    int id = nextId++;
    return Results.Created("/orders/" + id, new { id, quantity = quantity.GetInt32() });
});

app.MapGet("/debug/logs", () => Results.Ok(logs));

app.Run();`;

// A real API sends logs to a log store. Here we keep them in a list,
// and GET /debug/logs shows them (only for this demo).
const logs: string[] = [];
let nextId = 101;

const app = new Hono();

app.post("/orders", async (c) => {
  logs.push("Order received");
  const body = await c.req.json();
  if (typeof body.quantity !== "number") {
    logs.push("Something failed"); // But what? For whom? We cannot tell.
    return c.body(null, 500);
  }
  const id = nextId++;
  c.header("Location", "/orders/" + id);
  return c.json({ id, quantity: body.quantity }, 201);
});

app.get("/debug/logs", (c) => c.json(logs, 200));

X-App-Version: 2.4
Content-Type: application/json

{"item":"Latte","quantity":2}

###
POST /orders
X-App-Version: 2.3
Content-Type: application/json

{"item":"Latte","quantity":"2"}

###
GET /debug/logs`;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Each log entry is an object with named fields, not a sentence.
var logs = new List<(string Level, object Entry)>();
int nextId = 101;

app.MapPost("/orders", async (HttpContext context) =>
{
    string appVersion = context.Request.Headers["X-App-Version"].ToString();
    using var doc = await JsonDocument.ParseAsync(context.Request.Body);
    JsonElement quantity = doc.RootElement.GetProperty("quantity");
    if (quantity.ValueKind != JsonValueKind.Number)
    {
        logs.Add(("Error", new { level = "Error", @event = "OrderFailed", path = "/orders", status = 500, appVersion, error = "quantity is not a number" }));
        return Results.StatusCode(500);
    }
    int id = nextId++;
    logs.Add(("Information", new { level = "Information", @event = "OrderPlaced", path = "/orders", status = 201, appVersion, orderId = id }));
    return Results.Created("/orders/" + id, new { id, quantity = quantity.GetInt32() });
});

// Fields can be filtered: /debug/logs?level=Error shows only errors.
app.MapGet("/debug/logs", (HttpContext context) =>
{
    string level = context.Request.Query["level"].ToString();
    return Results.Ok(logs.Where(l => level == "" || l.Level == level).Select(l => l.Entry).ToList());
});

app.Run();`;

// Each log entry is an object with named fields, not a sentence.
const logs: Record<string, unknown>[] = [];
let nextId = 101;

const app = new Hono();

app.post("/orders", async (c) => {
  const appVersion = c.req.header("X-App-Version") ?? "";
  const body = await c.req.json();
  if (typeof body.quantity !== "number") {
    logs.push({ level: "Error", event: "OrderFailed", path: "/orders", status: 500, appVersion, error: "quantity is not a number" });
    return c.body(null, 500);
  }
  const id = nextId++;
  logs.push({ level: "Information", event: "OrderPlaced", path: "/orders", status: 201, appVersion, orderId: id });
  c.header("Location", "/orders/" + id);
  return c.json({ id, quantity: body.quantity }, 201);
});

// Fields can be filtered: /debug/logs?level=Error shows only errors.
app.get("/debug/logs", (c) => {
  const level = c.req.query("level") ?? "";
  return c.json(logs.filter((l) => level === "" || l.level === level), 200);
});

X-App-Version: 2.4
Content-Type: application/json

{"item":"Latte","quantity":2}

###
POST /orders
X-App-Version: 2.3
Content-Type: application/json

{"item":"Masala Chai","quantity":"1"}

###
POST /orders
X-App-Version: 2.4
Content-Type: application/json

{"item":"Cold Coffee","quantity":1}

###
POST /orders
X-App-Version: 2.3
Content-Type: application/json

{"item":"Latte","quantity":"3"}

###
GET /debug/logs

###
GET /debug/logs?level=Error`;

var app = builder.Build();

var logs = new List<object>();
int nextRequest = 0;

// Middleware runs for every request, before the endpoint.
app.Use(async (context, next) =>
{
    // Keep the caller's id if it sent one. Otherwise make a new one.
    string id = context.Request.Headers["X-Correlation-Id"].ToString();
    bool made = id == "";
    if (made) id = "req-" + (nextRequest + 1).ToString("D4");
    context.Items["CorrelationId"] = id;
    context.Response.Headers["X-Correlation-Id"] = id; // the caller can quote it later
    await next();
    if (made && context.Response.StatusCode != 404) nextRequest++; // unknown URLs do not use up a number
});

app.MapPost("/orders", (HttpContext context) =>
{
    string correlationId = (string)context.Items["CorrelationId"]!;
    logs.Add(new { level = "Information", @event = "OrderReceived", correlationId });
    logs.Add(new { level = "Information", @event = "PaymentCharged", correlationId });
    return Results.StatusCode(202);
});

app.MapGet("/debug/logs", () => Results.Ok(logs));

app.Run();`;

const logs: Record<string, unknown>[] = [];
let nextRequest = 0;

const app = new Hono<{ Variables: { correlationId: string } }>();

// Middleware runs for every request, before the endpoint.
app.use("*", async (c, next) => {
  // Keep the caller's id if it sent one. Otherwise make a new one.
  let id = c.req.header("X-Correlation-Id") ?? "";
  const made = id === "";
  if (made) id = "req-" + String(nextRequest + 1).padStart(4, "0");
  c.set("correlationId", id);
  c.header("X-Correlation-Id", id); // the caller can quote it later
  await next();
  if (made && c.res.status !== 404) nextRequest++; // unknown URLs do not use up a number
});

app.post("/orders", (c) => {
  const correlationId = c.get("correlationId");
  logs.push({ level: "Information", event: "OrderReceived", correlationId });
  logs.push({ level: "Information", event: "PaymentCharged", correlationId });
  return c.body(null, 202);
});

app.get("/debug/logs", (c) => c.json(logs, 200));

X-Correlation-Id: app-7f3a91

###
POST /orders

###
GET /debug/logs`;

var app = builder.Build();

// What each dependency reports right now. In a real API each check
// would try the database or the payment service.
var checks = new Dictionary<string, string>
{
    ["database"] = "Healthy",
    ["payments"] = "Unhealthy",
};

// Liveness: is the process running at all? No dependencies checked.
app.MapGet("/health/live", () => Results.Ok(new { status = "Healthy" }));

// Readiness: can we serve customers? Every dependency must be healthy.
app.MapGet("/health", () =>
{
    string status = checks.Values.All(v => v == "Healthy") ? "Healthy" : "Unhealthy";
    return Results.Json(new { status, checks }, statusCode: status == "Healthy" ? 200 : 503);
});

app.Run();`;

// What each dependency reports right now. In a real API each check
// would try the database or the payment service.
const checks: Record<string, string> = {
  database: "Healthy",
  payments: "Unhealthy",
};

const app = new Hono();

// Liveness: is the process running at all? No dependencies checked.
app.get("/health/live", (c) => c.json({ status: "Healthy" }, 200));

// Readiness: can we serve customers? Every dependency must be healthy.
app.get("/health", (c) => {
  const status = Object.values(checks).every((v) => v === "Healthy") ? "Healthy" : "Unhealthy";
  return c.json({ status, checks }, status === "Healthy" ? 200 : 503);
});

###
GET /health`;

var records = new (string Path, int Status, int Ms, string App)[]
{
    ("GET /menu", 200, 20, "2.4"), ("POST /orders", 201, 80, "2.4"), ("GET /menu", 200, 22, "2.3"),
    ("POST /orders", 500, 410, "2.3"), ("GET /menu", 200, 25, "2.4"), ("POST /orders", 201, 85, "2.4"),
    ("GET /menu", 200, 21, "2.4"), ("POST /orders", 201, 90, "2.4"), ("POST /orders", 500, 430, "2.3"),
    ("GET /menu", 200, 24, "2.3"), ("POST /orders", 201, 82, "2.4"), ("GET /menu", 200, 23, "2.4"),
    ("POST /orders", 500, 400, "2.3"), ("GET /menu", 200, 26, "2.4"), ("POST /orders", 201, 88, "2.4"),
    ("GET /menu", 200, 90, "2.3"), ("POST /orders", 201, 95, "2.4"), ("GET /menu", 200, 22, "2.4"),
    ("POST /orders", 500, 440, "2.3"), ("GET /menu", 200, 27, "2.4"),
};

// p95 (nearest rank): sort, then take the value at position ceil(0.95 * n).
int P95(List<int> ms)
{
    var sorted = ms.OrderBy(x => x).ToList();
    return sorted[(int)Math.Ceiling(0.95 * sorted.Count) - 1];
}

Console.WriteLine("Endpoint      Rate  Errors  Avg     p95");
foreach (var g in records.GroupBy(r => r.Path))
{
    int count = g.Count();
    int errors = g.Count(r => r.Status >= 500);
    var ms = g.Select(r => r.Ms).ToList();
    Console.WriteLine($"{g.Key,-13} {count,4} {errors * 100 / count,6}% {ms.Sum() / count,4} ms {P95(ms),4} ms");
}

Console.WriteLine();
Console.WriteLine("Errors by app version:");
foreach (var g in records.GroupBy(r => r.App))
    Console.WriteLine($"  {g.Key}: {g.Count(r => r.Status >= 500)} errors in {g.Count()} requests");`;

type Rec = { path: string; status: number; ms: number; app: string };
const r = (path: string, status: number, ms: number, app: string): Rec => ({ path, status, ms, app });
const records: Rec[] = [
  r("GET /menu", 200, 20, "2.4"), r("POST /orders", 201, 80, "2.4"), r("GET /menu", 200, 22, "2.3"),
  r("POST /orders", 500, 410, "2.3"), r("GET /menu", 200, 25, "2.4"), r("POST /orders", 201, 85, "2.4"),
  r("GET /menu", 200, 21, "2.4"), r("POST /orders", 201, 90, "2.4"), r("POST /orders", 500, 430, "2.3"),
  r("GET /menu", 200, 24, "2.3"), r("POST /orders", 201, 82, "2.4"), r("GET /menu", 200, 23, "2.4"),
  r("POST /orders", 500, 400, "2.3"), r("GET /menu", 200, 26, "2.4"), r("POST /orders", 201, 88, "2.4"),
  r("GET /menu", 200, 90, "2.3"), r("POST /orders", 201, 95, "2.4"), r("GET /menu", 200, 22, "2.4"),
  r("POST /orders", 500, 440, "2.3"), r("GET /menu", 200, 27, "2.4"),
];

// p95 (nearest rank): sort, then take the value at position ceil(0.95 * n).
function p95(ms: number[]): number {
  const sorted = [...ms].sort((a, b) => a - b);
  return sorted[Math.ceil(0.95 * sorted.length) - 1];
}

function groupBy(key: (x: Rec) => string): Map<string, Rec[]> {
  const groups = new Map<string, Rec[]>();
  for (const x of records) groups.set(key(x), [...(groups.get(key(x)) ?? []), x]);
  return groups;
}

console.log("Endpoint      Rate  Errors  Avg     p95");
for (const [path, g] of groupBy((x) => x.path)) {
  const count = g.length;
  const errors = g.filter((x) => x.status >= 500).length;
  const ms = g.map((x) => x.ms);
  const avg = Math.trunc(ms.reduce((a, b) => a + b, 0) / count);
  console.log(\`\${path.padEnd(13)} \${String(count).padStart(4)} \${String(Math.trunc((errors * 100) / count)).padStart(6)}% \${String(avg).padStart(4)} ms \${String(p95(ms)).padStart(4)} ms\`);
}

console.log("");
console.log("Errors by app version:");
for (const [app, g] of groupBy((x) => x.app))
  console.log(\`  \${app}: \${g.filter((x) => x.status >= 500).length} errors in \${g.length} requests\`);`;

  {
    prompt: "The starter prints an error rate of 0% and prints the average as p95. Count the requests with status 500 or above, and print the real p95 with the nearest-rank method.",
    hint: "Sort the durations. The p95 is at position ceil(0.95 * n), counting from 1, so the index is that number minus 1.",
    explain: "Two of ten requests failed, so the error rate is 20%. With 10 values, ceil(9.5) is 10, so p95 is the largest value: 430 ms. The average (153 ms) hides the two slow requests.",
    csharp: { starter: `// Ten POST /orders requests: status code and duration in ms.
int[] statuses = { 201, 201, 500, 201, 201, 201, 500, 201, 201, 201 };
int[] durations = { 80, 85, 410, 90, 82, 88, 430, 95, 84, 86 };

// TODO 1: count the errors (status 500 or above) and print the error rate in %.
int errors = 0;
Console.WriteLine($"Error rate: {errors * 100 / statuses.Length}%");

// TODO 2: print the p95, not the average.
// Sort the durations and take the value at position ceil(0.95 * n) (1-based).
int average = durations.Sum() / durations.Length;
Console.WriteLine($"p95: {average} ms");`, solution: `// Ten POST /orders requests: status code and duration in ms.
int[] statuses = { 201, 201, 500, 201, 201, 201, 500, 201, 201, 201 };
int[] durations = { 80, 85, 410, 90, 82, 88, 430, 95, 84, 86 };

// Errors are server failures: status 500 or above.
int errors = statuses.Count(s => s >= 500);
Console.WriteLine($"Error rate: {errors * 100 / statuses.Length}%");

// p95 (nearest rank): sort, then take position ceil(0.95 * n).
int[] sorted = durations.OrderBy(d => d).ToArray();
int p95 = sorted[(int)Math.Ceiling(0.95 * sorted.Length) - 1];
Console.WriteLine($"p95: {p95} ms");`, mode: "console" },
    react: { starter: `// Ten POST /orders requests: status code and duration in ms.
const statuses = [201, 201, 500, 201, 201, 201, 500, 201, 201, 201];
const durations = [80, 85, 410, 90, 82, 88, 430, 95, 84, 86];

// TODO 1: count the errors (status 500 or above) and print the error rate in %.
const errors = 0;
console.log(\`Error rate: \${Math.trunc((errors * 100) / statuses.length)}%\`);

// TODO 2: print the p95, not the average.
// Sort the durations and take the value at position ceil(0.95 * n) (1-based).
const average = Math.trunc(durations.reduce((a, b) => a + b, 0) / durations.length);
console.log(\`p95: \${average} ms\`);`, solution: `// Ten POST /orders requests: status code and duration in ms.
const statuses = [201, 201, 500, 201, 201, 201, 500, 201, 201, 201];
const durations = [80, 85, 410, 90, 82, 88, 430, 95, 84, 86];

// Errors are server failures: status 500 or above.
const errors = statuses.filter((s) => s >= 500).length;
console.log(\`Error rate: \${Math.trunc((errors * 100) / statuses.length)}%\`);

// p95 (nearest rank): sort, then take position ceil(0.95 * n).
const sorted = [...durations].sort((a, b) => a - b);
const p95 = sorted[Math.ceil(0.95 * sorted.length) - 1];
console.log(\`p95: \${p95} ms\`);`, mode: "console" }
  },
  {
    prompt: "Add middleware that sends back an X-Correlation-Id on every response. Keep the id of the caller if it sent one. Otherwise, make req-0001, req-0002 and so on.",
    hint: "Use app.Use in C# or app.use in Hono. Set the response header before you call next.",
    explain: "The first request keeps app-55c2. The next two get req-0001 and req-0002. Support staff can now ask a customer for this id, and they can find every log line for that request.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

int nextRequest = 0;

// TODO: add middleware with app.Use(async (context, next) => { ... }).
// Read the X-Correlation-Id request header. If it is empty, make "req-0001",
// "req-0002", ... from nextRequest (count only requests that are not 404). Put the id in the X-Correlation-Id
// response header, then call await next().

app.MapGet("/orders/101", () => Results.Ok(new { id = 101, item = "Latte" }));

app.Run();`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

int nextRequest = 0;

// Keep the caller's id, or make a new one, and send it back.
app.Use(async (context, next) =>
{
    string id = context.Request.Headers["X-Correlation-Id"].ToString();
    bool made = id == "";
    if (made) id = "req-" + (nextRequest + 1).ToString("D4");
    context.Response.Headers["X-Correlation-Id"] = id;
    await next();
    if (made && context.Response.StatusCode != 404) nextRequest++; // unknown URLs do not use up a number
});

app.MapGet("/orders/101", () => Results.Ok(new { id = 101, item = "Latte" }));

app.Run();`, mode: "aspnet", requests: `GET /orders/101
X-Correlation-Id: app-55c2

###
GET /orders/101

###
GET /orders/101` },
    react: { starter: `import { Hono } from "hono";

let nextRequest = 0;

const app = new Hono();

// TODO: add middleware with app.use("*", async (c, next) => { ... }).
// Read the X-Correlation-Id request header. If it is empty, make "req-0001",
// "req-0002", ... from nextRequest (count only requests that are not 404). Put the id in the X-Correlation-Id
// response header, then call await next().

app.get("/orders/101", (c) => c.json({ id: 101, item: "Latte" }, 200));

let nextRequest = 0;

const app = new Hono();

// Keep the caller's id, or make a new one, and send it back.
app.use("*", async (c, next) => {
  let id = c.req.header("X-Correlation-Id") ?? "";
  const made = id === "";
  if (made) id = "req-" + String(nextRequest + 1).padStart(4, "0");
  c.header("X-Correlation-Id", id);
  await next();
  if (made && c.res.status !== 404) nextRequest++; // unknown URLs do not use up a number
});

app.get("/orders/101", (c) => c.json({ id: 101, item: "Latte" }, 200));

X-Correlation-Id: app-55c2

###
GET /orders/101

###
GET /orders/101` },
    checks: `GET /orders/101
X-Correlation-Id: mobile-9

###
GET /orders/101`
  },
  {
    prompt: "Add GET /health that returns 200 with {\"status\":\"Healthy\"}, so that a load balancer can check the API.",
    hint: "One MapGet in C# or one app.get in Hono, which returns an object with a status field.",
    explain: "Before, /health gave 404, so a load balancer would think that the API is down. Now it answers 200 with Healthy.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/menu", () => Results.Ok(new[] { "Latte", "Masala Chai" }));

// TODO: add GET /health that returns 200 with {"status":"Healthy"},
// so the load balancer knows this API is up.

app.Run();`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/menu", () => Results.Ok(new[] { "Latte", "Masala Chai" }));

// The load balancer calls this to know the API is up.
app.MapGet("/health", () => Results.Ok(new { status = "Healthy" }));

app.Run();`, mode: "aspnet", requests: `GET /health

###
GET /menu` },
    react: { starter: `import { Hono } from "hono";

const app = new Hono();

app.get("/menu", (c) => c.json(["Latte", "Masala Chai"], 200));

// TODO: add GET /health that returns 200 with {"status":"Healthy"},
// so the load balancer knows this API is up.

// Unknown URLs: 404 with no body (the same as ASP.NET Core).
app.notFound((c) => c.body(null, 404));

const app = new Hono();

app.get("/menu", (c) => c.json(["Latte", "Masala Chai"], 200));

// The load balancer calls this to know the API is up.
app.get("/health", (c) => c.json({ status: "Healthy" }, 200));

// Unknown URLs: 404 with no body (the same as ASP.NET Core).
app.notFound((c) => c.body(null, 404));

###
GET /menu` },
    checks: `GET /health?probe=lb

###
GET /health
User-Agent: load-balancer`
  }
];
