# Monitor hạn chót: việc hẹn giờ phải xong trước hạn

> Đăng ký từng lượt bằng khoá ingest hoặc API, kết thúc bằng span, log hoặc check-in; lượt không thành công trước hạn chót sẽ cảnh báo.

**Monitor hạn chót** canh những việc app của anh hẹn cho một thời điểm cụ thể: bài hẹn đăng lúc 08:30, đợt xuất hoá đơn ngày mùng 1, báo cáo hứa gửi trước 09:00. App **đăng ký từng lượt (occurrence) trước giờ hẹn**; nếu đến giờ hẹn cộng thời gian ân hạn vẫn chưa có thành công, Belvador cảnh báo `MONITOR_DEADLINE_MISSED`.

> Lượt **chưa từng được đăng ký** thì không thể báo là lỡ. Hãy đăng ký lúc lên lịch công việc, không phải lúc nó bắt đầu chạy.

## 1. Tạo monitor

**Monitor → Tạo monitor → Hạn chót**. Chọn:

- **Ân hạn sau giờ hẹn** — từ 0 đến 1440 phút. Hạn chót của mỗi lượt = giờ hẹn + ân hạn.
- **Cũng cảnh báo khi lượt đã bắt đầu im lặng** (không bắt buộc) — xem [lượt chạy bị treo](https://belvador.dev/vi/docs/monitors/signals#tien-trien-luot-chay-bi-treo).
- **Môi trường** — mỗi môi trường được xét riêng.

**Slug** của monitor là thứ app gửi lên.

## 2. Lấy khoá ingest

Ở **Project → Cài đặt → Khoá ingest**, tạo khoá có scope **monitors** (có thể giới hạn theo môi trường) và lưu làm secret phía server, ví dụ `BELVADOR_INGEST_KEY`. Khoá ingest là bí mật: chỉ đăng ký từ **server**, không bao giờ từ trình duyệt — yêu cầu có header `Origin` bị từ chối.

## 3. Đăng ký từng lượt

```bash
curl -fsS -m 10 --retry 3 -X POST "https://ingest.belvador.dev/api/<project id>/occurrences/" \
  -H "Authorization: Bearer $BELVADOR_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monitor_slug":"scheduled-posts","occurrence_id":"post_8f2a91","due_at":"2026-11-02T08:30:00Z","environment":"production"}'
```

Hoặc bằng TypeScript trên server:

```ts
// Server only: the ingest key is a secret. Call when the job is scheduled, before it is due.
export async function expectRun(occurrenceId: string, dueAt: Date, environment = "production") {
  const res = await fetch("https://ingest.belvador.dev/api/<project id>/occurrences/", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.BELVADOR_INGEST_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      monitor_slug: "scheduled-posts",
      occurrence_id: occurrenceId, // the same id again moves its due time
      due_at: dueAt.toISOString(),
      environment,
    }),
    signal: AbortSignal.timeout(10_000),
  });
  // 200 registered | rescheduled | duplicate. 429 and 503 carry Retry-After: try again later.
  if (!res.ok) throw new Error(`Belvador occurrence: HTTP ${res.status}`);
}
```

| Trường | Quy tắc |
|---|---|
| `monitor_slug` | Slug của monitor hạn chót |
| `occurrence_id` | Id của lượt này do app đặt, 1–128 ký tự `A–Z a–z 0–9 . _ : -`. Dùng UUID nếu lượt kết thúc bằng check-in |
| `due_at` | Thời điểm ISO 8601 có múi giờ, từ 5 phút trước đến tối đa 35 ngày tới |
| `environment` | Không bắt buộc, mặc định `production`; phải là môi trường monitor canh và khoá cho phép |

Thân yêu cầu tối đa 4 KB. Kết quả:

| Mã | Ý nghĩa |
|---|---|
| `200` `registered` / `rescheduled` / `duplicate` | Đã nhận. Gửi lại cùng id với `due_at` mới sẽ dời lượt đang chờ |
| `400` | Thân yêu cầu không đúng các trường ở trên |
| `401` / `403` | Thiếu hoặc sai khoá, khoá không có scope monitors hoặc của project khác, yêu cầu từ trình duyệt, hoặc môi trường khoá không cho phép |
| `404` | Project không có monitor hạn chót với slug này |
| `422` | `due_at` ngoài khoảng cho phép, môi trường monitor không canh, hoặc lượt đã kết thúc |
| `429` | Quá nhiều lượt đang chờ (mặc định của gói: 10,000 mỗi monitor, 100,000 mỗi project). Thử lại sau `Retry-After` (300 giây); mỗi lần bị từ chối được đếm trên trang monitor |
| `503` | Node đang không liên lạc được. Chưa có gì được đăng ký — thử lại sau `Retry-After` |

Cùng thao tác này có ở `POST /v1/occurrences` bằng API token (scope `monitors:write`), cho công cụ và AI agent.

## 4. Kết thúc lượt

Một trong các tín hiệu sau sẽ chốt lượt có cùng id:

- **span** hoặc **log** có `belvador.run.id` = occurrence id và `belvador.outcome` = `success` (hoặc `failure`):

```ts
import { trace } from "@opentelemetry/api";

// Inside the job: the span that does the work names the run and its outcome.
await trace.getTracer("jobs").startActiveSpan("publish-post", async (span) => {
  span.setAttribute("belvador.run.id", occurrenceId);
  try {
    await publishPost();
    span.setAttribute("belvador.outcome", "success");
  } catch (err) {
    span.setAttribute("belvador.outcome", "failure");
    throw err;
  } finally {
    span.end();
  }
});
```

- **check-in** `ok` (hoặc `error`) có `check_in_id` là occurrence id — id của check-in là UUID, nên dùng occurrence id dạng UUID với cách này.

Thành công trước hạn chót là **đúng hạn**. Thất bại sẽ cảnh báo `MONITOR_FAILED`. Thành công đến sau khi đã lỡ hạn được ghi là **trễ** (trong vòng 7 ngày); cảnh báo lỡ hạn vẫn nằm trong lịch sử và monitor hồi phục.

## Huỷ một lượt

Nếu app dời hoặc bỏ công việc, hãy huỷ lượt đang chờ để nó không bao giờ cảnh báo:

```bash
curl -fsS -m 10 --retry 3 -X DELETE "https://ingest.belvador.dev/api/<project id>/occurrences/" \
  -H "Authorization: Bearer $BELVADOR_INGEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"monitor_slug":"scheduled-posts","occurrence_id":"post_8f2a91","environment":"production"}'
```

`DELETE /v1/occurrences/<monitor_slug>/<occurrence_id>` làm việc tương tự qua API.

## Trang monitor hiển thị gì

**Hạn gần nhất** kèm đếm ngược, dải 24 giờ qua (đúng hạn · trễ · lỡ hạn · thất bại · đã huỷ · đang chờ) và bảng các lượt. Occurrence id hiển thị đúng như app gửi và không bao giờ được sao chép ra khỏi node; cảnh báo không bao giờ chứa chúng.
