跳到內容

Analytics Engine:自己蓋一個分析後台

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

⚠️ 本章的 SQL API 部分需要 Cloudflare API token,無法在離線環境驗證,已在對應段落標明來源。

LinkForge 現在缺一塊:點擊分析。每一次短網址被點開,我們想記下來源國家、referrer、device、以及回應時間,然後能回答「過去 7 天這個租戶的前 10 大來源國家」。

第 9 章的 D1 是最直覺的答案,但它會在兩個地方撞牆。第一是寫入:D1 是單寫入者架構,一個高流量短網址每秒幾百次點擊,全部變成 INSERT 打向同一個 database,你會先耗盡 rows_written 配額,然後開始看到寫入延遲。第二是查詢:GROUP BY country 掃描一張三個月、上億列的表,這不是 SQLite 該做的事。

Durable Objects(第 14 章)可以做 pre-aggregation —— 每個租戶一顆 DO,在記憶體裡累加計數器。這解決了寫入問題,但你只能查到「你事先決定要累加的那些維度」。事後有人問「前 10 大 referrer 之中,哪些來自 iOS?」你答不出來,因為你當初沒開那個計數器。

Analytics Engine 賣的就是這件事:高基數、無需預先聚合的時序寫入,加上一個 ClickHouse 方言的 SQL 查詢端點。代價是它會抽樣,而抽樣正是本章的核心。


{
"analytics_engine_datasets": [
{ "binding": "AE", "dataset": "ch22_clicks" },
{ "binding": "AE_NO_DATASET" }
]
}

node_modules/wrangler/config-schema.json,這個 binding 的 schema 極其簡單:

{
"type": "object",
"properties": {
"binding": { "type": "string" },
"dataset": { "type": "string" }
},
"required": ["binding"],
"additionalProperties": false
}

只有兩個欄位,dataset 可省略。省略時它預設為 binding 名稱 —— 這一點 wrangler dev 的啟動表格直接印出來給你看:

env.AE (ch22_clicks) Analytics Engine Dataset local
env.AE_NO_DATASET (AE_NO_DATASET) Analytics Engine Dataset local

括號裡就是實際的 dataset 名稱。dataset 不需要事先建立,第一次寫入時自動建立。

additionalProperties: false 還告訴我們一件事:這個 binding 沒有 remote 欄位。KV、D1、R2 都支援 "remote": true(第 4 章)把本地開發接到 production 資源,Analytics Engine 不行。實測加上去:

▲ WARNING Processing wrangler.jsonc configuration:
- Unexpected fields found in analytics_engine_datasets[0] field: "remote"

只是 warning,不是 error —— 也就是說你打錯欄位名,deploy 照樣會成功,而那個欄位靜靜地被忽略。

env.AE.writeDataPoint({
blobs: [slug, country, userAgent, referrer], // 字串維度,最多 20
doubles: [1, latencyMs], // 數值,最多 20
indexes: [slug], // sampling key,只能 1 個
});

三個陣列都是位置對應的:blobs[0]blob1 欄位,doubles[1]double2indexes[0]index1。沒有欄位名稱的概念。這代表:

一旦你決定了「blob2 是 country」,就永遠不能改。schema 完全存在於你的腦袋和註解裡,平台不會幫你記。務必在程式碼裡用常數把 schema 寫死。

// 建議:把 schema 集中宣告
const CLICK = {
blobs: (d: Click) => [d.slug, d.country, d.ua, d.referrer, d.tenantId],
doubles: (d: Click) => [1, d.latencyMs],
indexes: (d: Click) => [d.tenantId],
} as const;
// blob1=slug blob2=country blob3=ua blob4=referrer blob5=tenantId
// double1=count double2=latencyMs index1=tenantId

生成的型別是:

interface AnalyticsEngineDataset {
writeDataPoint(event?: AnalyticsEngineDataPoint): void;
}
interface AnalyticsEngineDataPoint {
indexes?: ((ArrayBuffer | string) | null)[];
doubles?: number[];
blobs?: ((ArrayBuffer | string) | null)[];
}

三件官方文件沒說的事:

(一)參數本身是 optional。 writeDataPoint() 不帶參數在型別上合法。 (二)回傳 void,不是 Promise 實測 writeDataPoint(...) 回傳 undefinedinstanceof Promisefalse。所以 不需要 await,也不需要 ctx.waitUntil()。文件只說「不用 await」,型別進一步證實它根本不是非同步的 —— runtime 在背景處理實際傳送。 (三)blobsindexes 接受 ArrayBuffernull 文件只說「字串」。型別明確允許 ArrayBuffer | string | nullnull 可以用來佔位(保持位置對應而不寫值),這在 schema 演進時很有用。


22.2 本地 binding 是一個完全不驗證的 no-op

Section titled “22.2 本地 binding 是一個完全不驗證的 no-op”

這是本章最重要、也最容易讓人在 production 吃虧的一段。

範例的 /limits 路由把所有文件記載與未記載的限制各戳一次,每一次都包在 try/catch 裡:

out.noArgs = t(() => ae.writeDataPoint()); // 不給參數
out.blobs21 = t(() => ae.writeDataPoint({ blobs: Array(21).fill("a") }));
out.doubles21 = t(() => ae.writeDataPoint({ doubles: Array(21).fill(1) }));
out.indexes2 = t(() => ae.writeDataPoint({ indexes: ["a", "b"] })); // 文件:不會被記錄
out.index97 = t(() => ae.writeDataPoint({ indexes: ["x".repeat(97)] }));
out.blobs16kPlus1 = t(() => ae.writeDataPoint({ blobs: ["x".repeat(16 * 1024 + 1)] }));
out.blobNumber = t(() => ae.writeDataPoint({ blobs: [123 as unknown as string] }));
out.doubleNaN = t(() => ae.writeDataPoint({ doubles: [NaN] }));
out.doubleInfinity= t(() => ae.writeDataPoint({ doubles: [Infinity] }));
out.unknownKey = t(() => ae.writeDataPoint({ blobs: ["u"], tags: ["nope"] } as never));

23 個探針,實測結果全部相同

{ "ok": "undefined", "isPromise": false }

沒有任何一個丟出例外,dev server log 裡也沒有任何一行警告。連 /burst?n=300(文件明訂單次 invocation 上限 250 個 data point)也是:

{ "attempted": 300, "firstErrors": [], "errorCount": 0 }

再看檔案系統。wrangler dev 會為各種 binding 在 .wrangler/state/v3/ 底下建目錄(cacheworkflowsobservability…),但沒有 analytics-engine

把這些拼起來:

protoKeys: ["constructor", "writeDataPoint"]
ctorName: "LocalAnalyticsEngineDataset"

本地 binding 的類別名字就叫 LocalAnalyticsEngineDataset,它做的事情是:接收參數、什麼都不做、回傳 undefined。不驗證、不儲存、不能查詢。

這代表 Analytics Engine 是本系列到目前為止本地可測性最差的 primitive。 對照一下前面幾章:

Primitive本地能驗證什麼
KV(第 8 章)幾乎全部,連 400 You can request a maximum of 100 keys 都會丟
D1(第 9 章)全部 SQL 語意,rows_read 也會回報
Queues(第 19 章)重試次數、DLQ 路由
Workflows(第 21 章)除了 hibernate/replay 之外的全部
Analytics Engine什麼都不能

實務對策有三層:

第一層 —— 自己驗證。 既然平台不驗,就在寫入的包裝函式裡自己驗,並在開發環境 throw:

const MAX_BLOBS = 20, MAX_DOUBLES = 20, MAX_BLOB_BYTES = 16 * 1024, MAX_INDEX_BYTES = 96;
export function writeClick(ae: AnalyticsEngineDataset, dp: AnalyticsEngineDataPoint, dev: boolean) {
if (dev) {
const enc = new TextEncoder();
const blobBytes = (dp.blobs ?? []).reduce<number>(
(n, b) => n + (typeof b === "string" ? enc.encode(b).length : (b?.byteLength ?? 0)), 0);
if ((dp.blobs ?? []).length > MAX_BLOBS) throw new Error("too many blobs");
if ((dp.doubles ?? []).length > MAX_DOUBLES) throw new Error("too many doubles");
if ((dp.indexes ?? []).length > 1) throw new Error("exactly one index");
if (blobBytes > MAX_BLOB_BYTES) throw new Error(`blobs ${blobBytes}B > 16KB`);
const idx = dp.indexes?.[0];
if (typeof idx === "string" && enc.encode(idx).length > MAX_INDEX_BYTES)
throw new Error("index > 96 bytes");
}
ae.writeDataPoint(dp);
}

第二層 —— 注意 16 KB 是最容易在 production 才炸的那一條。 blobs 總計 16 KB 聽起來很寬鬆,直到你把 user-agentreferer 原封不動寫進去。惡意或異常的 referer header 可以輕易是好幾 KB。永遠截斷:

const cut = (s: string | null, n: number) => (s ?? "").slice(0, n);
blobs: [cut(slug, 128), country, cut(ua, 512), cut(referrer, 512)]

第三層 —— 部署後立刻用 SQL API 對一次帳。 這是唯一能確認「我寫的東西真的進去了、而且欄位位置正確」的方法。詳見 22.5。

一個文件明確記載、但本地測不出來的行為:indexes 給超過 1 個,整個 data point 不會被記錄。不是截斷、不是報錯,是整筆丟掉。這是唯一被官方文件寫出來的「超限後果」;其餘所有限制(>20 blobs、>16 KB、>96 bytes、>250 points)超過之後會怎樣,文件完全沒有說。本章不臆測。


Analytics Engine 不保證儲存你寫進去的每一筆。它在兩個地方抽樣。

官方說法是「我們會均衡每個 index 值被儲存的事件數量」。對於罕見的 index 值,可能全部寫入;對於某個 index 值寫入量特別大時,開始抽樣。

這解釋了 indexes 為什麼叫「sampling key」而不是「索引」—— 它的主要作用不是加速查詢,是決定抽樣的分組單位

實務推論:index 應該選你最常拿來切分資料的那個維度。LinkForge 的例子裡,tenantId 是好的選擇 —— 這樣一個超大租戶的流量被抽樣時,不會連帶把小租戶的資料也抽掉。如果你把 index 設成 country,那麼「台灣」的高流量會壓縮到「台灣」自己的取樣率,而不會影響「冰島」。

官方 FAQ 給了一個量級參考:以他們自家 CDN 的工作負載觀察,每個 index 值大約要每秒 100 筆以上,抽樣才會開始明顯。這是觀察值不是保證。

兩個明確的反面建議:

  • 不要用 UUID 之類的唯一值當 index。「這會讓你可以很快撈到單一筆資料,但會拖慢絕大多數聚合與時序查詢。」
  • 不要一次查詢跨很多 index 值。「在一次查詢中讀取很多 index,會得到低解析度的資料 —— 可能低到不堪用。」

需要多維度切分時,官方認可的做法是把值串起來"$tenantId:$country"),或者同一份資料用不同 index 寫兩次到兩個 dataset。後者聽起來浪費,但計費是按 data point 計數的定額(22.6),所以成本可預測。

資料以多種解析度儲存(100%、10%、1%…),查詢時系統依據查詢的複雜度自動挑一個,好讓查詢能在固定時間內完成。查的時間範圍越長,讀到的解析度越低。

_sample_interval:唯一正確的處理方式

Section titled “_sample_interval:唯一正確的處理方式”

每一列都帶一個 _sample_interval 欄位,它是該列的取樣率倒數。1% 取樣 → _sample_interval = 100

關鍵在於官方那句話:

「sample interval 是每一列各自的屬性…由於 equitable sampling 的實作方式,每一列的 sample interval 可能不同。因此查詢時你必須考慮這個欄位。單純把查詢結果乘上一個固定的取樣係數是不夠的。

所以每一個聚合都要改寫:

你想算沒抽樣時會寫正確寫法
事件計數count()sum(_sample_interval)
數值加總sum(bytes)sum(bytes * _sample_interval)
平均avg(bytes)sum(bytes * _sample_interval) / sum(_sample_interval)
分位數quantile(0.5)(bytes)quantileExactWeighted(0.5)(bytes, _sample_interval)

count() 仍然有用,但意思變了:它是「這次查詢實際讀到幾列」,也就是結果的可信度指標。官方 FAQ 說得很直接:不要用 _sample_interval 判斷準確度,要用 count();「如果你是從一兩列推估出來的,這個結果不太可能有代表性。」

所以正式的儀表板查詢應該同時輸出兩者:

SELECT
blob2 AS country,
sum(_sample_interval) AS clicks,
sum(double2 * _sample_interval) / sum(_sample_interval) AS avg_latency,
quantileExactWeighted(0.95)(double2, _sample_interval) AS p95_latency,
count() AS rows_read
FROM ch22_clicks
WHERE timestamp > NOW() - INTERVAL '1' DAY
GROUP BY country
ORDER BY clicks DESC
LIMIT 20
FORMAT JSON

前端拿到 rows_read 很小的那幾列,就該標上「樣本不足」而不是直接畫成柱狀圖。

這一段是選型的關鍵,值得單獨列出。官方明列的取捨:

  • 非 index 欄位的 unique count 不一定準確(例如「有多少個不重複的 referrer」)。
  • 非常罕見的非 index 值可能完全看不到
  • 不保證能撈到任何一筆特定紀錄。
  • 無法重建精確的事件序列。

所以:Analytics Engine 適合「趨勢、比例、分佈」,不適合「稽核軌跡、逐筆對帳、單筆查詢」。如果 LinkForge 之後要做「這個 API key 在幾點幾分做了什麼」的稽核日誌,那是第 23 章 Pipelines → R2 的工作,不是 Analytics Engine 的。


實際的表結構(官方 SQL API 文件):

欄位型別
datasetstring
timestampDateTime(UTC)
_sample_intervalinteger
index1string
blob1blob20string
double1double20double

限制(官方 limits 頁的完整內容,沒有更大的表):

限制
每個 data point 的 blobs 數20
每個 data point 的 doubles 數20
每個 data point 的 indexes 數1
每個 data point 的 blobs 總大小16 KB
單一 index 大小96 bytes
每次 Worker invocation 的 data point 數250
資料保留期3 個月

文件沒有記載的:寫入速率上限、doubles 的位元組限制、index 基數上限、查詢速率、查詢逾時、結果列數上限。這產品被明確行銷為「unlimited-cardinality」,所以基數上限的缺席可能是刻意的。

保留期 3 個月不可設定。想要更長,官方給的路徑是去 Developers Discord 的 #analytics-engine 頻道說明你的用途。這一點對架構決策影響很大:任何需要跨年比較的報表,都不能只靠 Analytics Engine。要嘛定期把聚合結果寫進 D1,要嘛走第 23 章的 Pipelines → R2 長期歸檔。


本節內容來自官方文件;本章的離線驗證環境無法連到 api.cloudflare.com,所以以下未附實測輸出。

端點:

POST https://api.cloudflare.com/client/v4/accounts/<account_id>/analytics_engine/sql
Authorization: Bearer <token>
<SQL 直接放在 request body>

Token 必須是自訂 API token,帶 Account Analytics Read 權限。注意這是 Account 層級權限,不是 Zone。

回應(預設 FORMAT JSON):

{
"meta": [{ "name": "country", "type": "String" }],
"data": [{ "country": "TW", "clicks": 1234 }],
"rows": 1
}

FORMAT 支援 JSON(預設)、JSONEachRowTabSeparated沒有 CSV —— 官方 statements 參考只列了這三個。

  1. 只能查單一表。UNIONJOIN 等目前不支援。」這一條決定了很多設計:想要跨 dataset 關聯?做不到,只能在應用層合併,或者一開始就把需要的維度寫進同一個 dataset。
  2. 沒有 DDL / DML。 表由寫入自動建立,不能 ALTER、不能 DELETE。schema 演進只能靠「新欄位往後加」或「開新 dataset」。
  3. SHOW TABLES 是唯一的目錄查詢。

支援的 SELECT 子句:FROM(單表或子查詢)、WHEREGROUP BYHAVINGORDER BYLIMITOFFSETFORMATAS 別名。

可用函式(節錄):

  • 聚合countsumavgminmaxquantileExactWeightedargMinargMaxtopKtopKWeightedcountIfsumIfavgIf
  • 日期時間toStartOfIntervaltoStartOfHour/Day/FiveMinutes…、formatDateTimetoUnixTimestampnowtoday
  • 數學intDivroundfloorceilpowlog
  • 型別轉換toUInt8toUInt32
  • 運算子LIKE / ILIKE / NOT LIKE / NOT ILIKE(2026-01 才加入)

官方的慣用寫法是拿 intDiv 做整數除法再乘回去:

SELECT
intDiv(toUInt32(timestamp), 300) * 300 AS t,
sum(_sample_interval) AS clicks
FROM ch22_clicks
WHERE timestamp > NOW() - INTERVAL '1' DAY
GROUP BY t
ORDER BY t
FORMAT JSON

t 是 epoch 秒。也可以用 toStartOfFiveMinutes(timestamp) 得到 DateTime,看你前端想吃哪一種。

沒有「查詢 binding」—— 就是一般的 fetch,token 放 secret:

const res = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/analytics_engine/sql`,
{ method: "POST", headers: { authorization: `Bearer ${env.CF_API_TOKEN}` }, body: query },
);

這是一個 subrequest,計入 Worker 的 subrequest 配額,也會算進 Analytics Engine 的「讀取查詢」計費維度。不要在每個使用者請求都打一次;把儀表板查詢的結果快取進 KV(第 8 章)或 Cache API(第 6 章),TTL 幾分鐘。

⚠️ 安全:env.CF_API_TOKEN 是 Account Analytics Read 權限的 token。絕對不要把查詢字串交給前端拼接,那等於開放任意 SQL 給使用者。Worker 端應該只暴露幾個參數化的報表端點,SQL 模板寫死在程式碼裡。多租戶場景下,WHERE blob5 = '<tenantId>'tenantId 必須來自驗證過的 session,不能來自 query string。

官方支援用 Altinity 的 ClickHouse plugin 指向 SQL API URL,加上自訂 Authorization header,並把 DateTime 欄位設成 timestamp 以啟用 $timeSeries / $timeFilter 巨集。這是目前把 Analytics Engine 接到現成儀表板最省事的路。

官方 get-started 頁面說有兩種查詢方式,第二種是 GraphQL API,並連到 Analytics GraphQL 文件。但翻遍 GraphQL 的 datasets 參考,找不到任何一個節點會回傳使用者自己寫入的 Analytics Engine 資料(Workers 相關的只有 workersInvocationsAdaptive,那是平台自己的指標)。

結論:目前實務上就是 SQL API 一條路。get-started 那個連結看起來是過時或誤導。


官方 pricing 頁列了兩個計費維度,沒有儲存費用

方案寫入 data point讀取查詢
Workers Paid每月含 1,000 萬,超出 $0.25 / 百萬每月含 100 萬,超出 $1.00 / 百萬
Workers Free每日 10 萬每日 1 萬

兩個定額性質值得注意:

  • 每個 data point 花費相同,無論你放了幾個維度、基數多高。所以「多寫幾個 blob」是免費的,「多寫一筆 data point」才要錢。這直接影響設計 —— 寧可一筆 data point 塞滿 20 個 blob,也不要拆成三筆。
  • 每個讀取查詢花費相同,無論複雜度或回傳列數。所以「一次查完再前端切分」比「打三次 API」便宜三倍。

一個大到必須加粗的但書,直接引用官方 pricing 頁:

「Currently, you will not be billed for your use of Workers Analytics Engine.」(目前你不會因為使用 Workers Analytics Engine 而被收費。)

費率是預先公告以便你估算成本,尚未實際開始計費。這句話在官方頁面上已經掛了很久。查證日期:2026-08-01。 讀到這篇文章時請自行重新確認 —— 這是全系列少數幾個「今天免費、未來會收費」的 primitive,把它當成免費資源來設計架構是有風險的。


放進第 5 章的 Hono 骨架裡:

src/analytics.ts
export type ClickEvent = {
tenantId: string;
slug: string;
country: string;
referrer: string;
ua: string;
latencyMs: number;
};
const cut = (s: string | null | undefined, n: number) => (s ?? "").slice(0, n);
// blob1=slug blob2=country blob3=referrer blob4=ua blob5=tenantId
// double1=count double2=latencyMs
// index1=tenantId
export function recordClick(ae: AnalyticsEngineDataset, e: ClickEvent): void {
ae.writeDataPoint({
blobs: [cut(e.slug, 128), cut(e.country, 2), cut(e.referrer, 512), cut(e.ua, 512), e.tenantId],
doubles: [1, e.latencyMs],
indexes: [e.tenantId],
});
}

重導向路由:

app.get("/:slug", async (c) => {
const started = Date.now();
const target = await resolve(c.env, c.req.param("slug"));
if (!target) return c.notFound();
// 同步、非阻塞、不需要 waitUntil
recordClick(c.env.AE, {
tenantId: target.tenantId,
slug: target.slug,
country: c.req.raw.cf?.country as string ?? "XX",
referrer: c.req.header("referer") ?? "",
ua: c.req.header("user-agent") ?? "",
latencyMs: Date.now() - started,
});
return c.redirect(target.url, 302);
});

注意 index1tenantId:每個租戶的抽樣率獨立計算,大租戶的流量不會把小租戶的資料抽掉。同時所有租戶層級的報表(絕大多數)都只讀一個 index 值,避免了「跨多 index 查詢解析度低」的問題。

報表端點(記得快取):

app.get("/api/reports/countries", requireAuth, async (c) => {
const tenantId = c.get("session").tenantId; // 來自驗證,不是 query string
const cacheKey = `report:countries:${tenantId}`;
const hit = await c.env.KV.get(cacheKey, "json");
if (hit) return c.json(hit);
const sql = `
SELECT blob2 AS country,
sum(_sample_interval) AS clicks,
count() AS rows_read
FROM linkforge_clicks
WHERE index1 = '${tenantId}' AND timestamp > NOW() - INTERVAL '7' DAY
GROUP BY country ORDER BY clicks DESC LIMIT 20 FORMAT JSON`;
const data = await queryAE(c.env, sql);
await c.env.KV.put(cacheKey, JSON.stringify(data), { expirationTtl: 300 });
return c.json(data);
});

tenantId 來自 session 而非請求參數,而且我們自己控制它的格式(UUID),所以字串內插在這裡是安全的。如果任何一個值來自使用者輸入,就必須先做嚴格的白名單驗證 —— SQL API 沒有 prepared statement,你沒有第二道防線。


22.8 什麼時候不要用 Analytics Engine

Section titled “22.8 什麼時候不要用 Analytics Engine”

官方沒有寫這一段,以下是綜合前面各章實測的判斷。

需求Analytics Engine該用什麼
「上週前 10 大來源國家」✅ 正解
「每 5 分鐘的 p95 延遲趨勢」✅ 正解
「這個 API key 昨天 14:32 做了什麼」❌ 不保證撈得到任何特定一筆Pipelines → R2(第 23 章)
「本月精確用量,用來開帳單」⚠️ 抽樣有誤差DO 計數器(第 14 章)或 D1
「即時顯示這篇文章的瀏覽數」❌ 查詢延遲 + 抽樣DO(第 14 章)
「跨年度同期比較」❌ 只留 3 個月定期把聚合結果寫進 D1
「不重複訪客數」❌ 非 index 欄位的 unique count 不準需要另外設計

計費相關的用量統計特別值得展開。官方 recipe 頁面有一篇 usage-based billing 的做法,核心技巧是在結帳時「一個客戶跑一次查詢」,因為單一 index 值的查詢受抽樣影響最小。但這仍然是統計估計。真金白銀的計量,我的建議是雙軌:DO 計數器負責精確值(第 14 章實測過它的 input gate 提供的序列化保證),Analytics Engine 負責高維度的分析與異常偵測。


#結論影響
1writeDataPoint 回傳 undefined,非 Promise;型別 (event?) => void不需要 await,也不需要 ctx.waitUntil()
2型別允許 blobs/indexesArrayBuffer | string | null(文件只說字串)null 可用來佔位以保持欄位位置
3參數本身 optional,writeDataPoint() 合法且不報錯空寫入是靜默的
4本地 binding 類別名為 LocalAnalyticsEngineDataset明示這是模擬物
523 個違規輸入(21 blobs、2 indexes、97B index、16KB+1、NaN、Infinity、錯型別、未知鍵)全部靜默通過平台完全不做本地驗證
6單次 invocation 寫 300 筆(文件上限 250)本地無錯上限只在 production 生效
7.wrangler/state/v3/ 底下沒有 analytics-engine 目錄本地不持久化、不可查詢
8config schema 只有 binding(必填)與 datasetadditionalProperties: false沒有 remote 支援
9加上 "remote": true 只出 warning,deploy 仍成功欄位打錯會被靜默忽略
10省略 dataset 時,dev 啟動表格顯示 dataset = binding 名稱文件所述行為得到驗證
11官方唯一記載的超限後果:indexes > 1 → 整筆不記錄其餘超限行為文件未載,不臆測
12GraphQL API 沒有任何節點可讀自訂 dataset實務上只有 SQL API 一條路
13pricing 頁公告費率,同時聲明「目前不會被收費」需標註查證日期,架構上不可假設永久免費

  1. /limits 的探針改寫成一個 assertValidDataPoint() 函式,在 wrangler dev 下 throw、在 production 下只記錄警告,並用 vitest 涵蓋全部 23 個案例(第 38 章會用到這套測試骨架)。
  2. 為 LinkForge 寫兩個版本的「過去 24 小時每 5 分鐘點擊數」查詢:一個用 count(),一個用 sum(_sample_interval)。部署後製造 10 萬次點擊,比較兩者差異。
  3. 把同一份點擊事件用兩個 index(tenantIdcountry)寫進兩個 dataset,比較同一個「前 10 大國家」查詢在兩邊的 rows_read
  4. 設計一個每天執行的 Cron Trigger(第 20 章),把 Analytics Engine 的日聚合結果寫進 D1,讓 LinkForge 能做超過 3 個月的同期比較。

下一章(第 23 章):Pipelines 與 R2 SQL —— 當 Analytics Engine 的抽樣與 3 個月保留期不夠用時,把事件原封不動落地成資料湖。注意該章仍在 open beta,且官方文件與 binding 鍵名有已知的不一致。