1import * as rum from "@distilled.cloud/cloudflare/rum";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.Rum.Rule" as const;
15type TypeId = typeof TypeId;
16
17export type RuleProps = {
18 /**
19 * The identifier of the Web Analytics ruleset the rule belongs to.
20 * Each zone-based (orange-clouded) `Site` owns one implicit ruleset —
21 * pass its `rulesetId` attribute. Changing this property triggers a
22 * replacement.
23 */
24 rulesetId: string;
25 /**
26 * The hostname the rule applies to (e.g. `example.com`).
27 */
28 host?: string;
29 /**
30 * The paths the rule applies to (e.g. `["/blog/*"]`).
31 */
32 paths?: string[];
33 /**
34 * Whether the rule includes (`true`) or excludes (`false`) matching
35 * traffic from being measured.
36 * @default true
37 */
38 inclusive?: boolean;
39 /**
40 * Whether the rule is paused.
41 * @default false
42 */
43 isPaused?: boolean;
44};
45
46export type RuleAttributes = {
47 /**
48 * The Web Analytics rule identifier. Stable for the lifetime of the rule.
49 */
50 id: string;
51 /**
52 * The identifier of the ruleset the rule belongs to.
53 */
54 rulesetId: string;
55 /**
56 * The Cloudflare account the rule belongs to.
57 */
58 accountId: string;
59 /**
60 * The hostname the rule applies to.
61 */
62 host: string | undefined;
63 /**
64 * The paths the rule applies to.
65 */
66 paths: string[] | undefined;
67 /**
68 * Whether the rule includes or excludes matching traffic from being
69 * measured.
70 */
71 inclusive: boolean;
72 /**
73 * Whether the rule is paused.
74 */
75 isPaused: boolean;
76 /**
77 * The rule's evaluation priority within its ruleset (assigned by
78 * Cloudflare).
79 */
80 priority: number | undefined;
81 /**
82 * When the rule was created.
83 */
84 created: string | undefined;
85};
86
87export type Rule = Resource<
88 TypeId,
89 RuleProps,
90 RuleAttributes,
91 never,
92 Providers
93>;
94
95/**
96 * A Cloudflare Web Analytics (RUM) rule.
97 *
98 * Rules include or exclude traffic from Web Analytics measurement by
99 * hostname and path patterns. They live under the implicit ruleset of a
100 * zone-based (orange-clouded) `Site` — pass the site's `rulesetId`
101 * attribute. Host, paths, `inclusive`, and `isPaused` are all mutable in
102 * place; changing `rulesetId` triggers a replacement.
103 *
104 * Web Analytics is available on free accounts.
105 * ### Excluding traffic
106 * **Example:** Exclude a path from measurement
107 * ```typescript
108 * const zone = yield* Cloudflare.Zone.Zone("Zone", { name: "example.com" });
109 *
110 * const site = yield* Cloudflare.Rum.Site("Analytics", {
111 * zoneTag: zone.zoneId,
112 * autoInstall: true,
113 * });
114 *
115 * yield* Cloudflare.Rum.Rule("ExcludeAdmin", {
116 * rulesetId: site.rulesetId.as<string>(),
117 * host: "example.com",
118 * paths: ["/admin/*"],
119 * inclusive: false,
120 * });
121 * ```
122 *
123 * ### Pausing a rule
124 * **Example:** Keep the rule but stop applying it
125 * ```typescript
126 * yield* Cloudflare.Rum.Rule("ExcludeAdmin", {
127 * rulesetId: site.rulesetId.as<string>(),
128 * host: "example.com",
129 * paths: ["/admin/*"],
130 * inclusive: false,
131 * isPaused: true,
132 * });
133 * ```
134 *
135 * @see https://developers.cloudflare.com/web-analytics/
136 *
137 * @resource
138 * @product RUM
139 * @category Observability & Analytics
140 */
141export const Rule = Resource<Rule>(TypeId);
142
143/**
144 * Returns true if the given value is a Rule resource.
145 */
146export const isRule = (value: unknown): value is Rule =>
147 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
148
149export const RuleProvider = () =>
150 Provider.succeed(Rule, {
151 stables: ["id", "rulesetId", "accountId", "created"],
152
153 list: Effect.fn(function* () {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155 // Rules live under a Site's implicit ruleset and there is no
156 // account-wide rule list. Enumerate every site (paginated), collect
157 // their ruleset ids, then fan out the per-ruleset rule list and
158 // flatten — the same listRules read path each rule's `read` uses.
159 const sites = yield* rum.listSiteInfos.pages({ accountId }).pipe(
160 Stream.runCollect,
161 Effect.map((chunk) =>
162 Array.from(chunk).flatMap((page) => page.result ?? []),
163 ),
164 );
165 const rulesetIds = Array.from(
166 new Set(
167 sites
168 .map((site) => site.ruleset?.id ?? undefined)
169 .filter((id): id is string => id !== undefined),
170 ),
171 );
172 const rows = yield* Effect.forEach(
173 rulesetIds,
174 (rulesetId) =>
175 listRules(accountId, rulesetId).pipe(
176 Effect.map((rules) =>
177 (rules ?? []).map((rule) =>
178 toAttributes(rule, rulesetId, accountId),
179 ),
180 ),
181 // A freshly minted scoped token can briefly 403 across the
182 // edge — skip rulesets we momentarily can't read.
183 Effect.catchTag("Forbidden", () => Effect.succeed([])),
184 ),
185 { concurrency: 10 },
186 );
187 return rows.flat();
188 }),
189
190 diff: Effect.fn(function* ({ news, output }) {
191 const { accountId } = yield* yield* CloudflareEnvironment;
192 if (!isResolved(news)) return undefined;
193 if ((output?.accountId ?? accountId) !== accountId) {
194 return { action: "replace" } as const;
195 }
196 // A rule cannot be moved between rulesets — moving it means a new
197 // rule under the new ruleset.
198 if (output !== undefined && news.rulesetId !== output.rulesetId) {
199 return { action: "replace" } as const;
200 }
201 return undefined;
202 }),
203
204 read: Effect.fn(function* ({ output, olds }) {
205 const { accountId } = yield* yield* CloudflareEnvironment;
206 const acct = output?.accountId ?? accountId;
207 const rulesetId =
208 output?.rulesetId ??
209 (typeof olds?.rulesetId === "string" ? olds.rulesetId : undefined);
210 if (rulesetId === undefined) return undefined;
211
212 // There is no getRule — read through the ruleset's rule list. A
213 // deleted ruleset (the parent site is gone) means the rule is gone.
214 const rules = yield* listRules(acct, rulesetId);
215 if (rules === undefined) return undefined;
216
217 // Owned path: refresh by our persisted rule id.
218 if (output?.id) {
219 const observed = rules.find((rule) => rule.id === output.id);
220 return observed ? toAttributes(observed, rulesetId, acct) : undefined;
221 }
222
223 // Adoption path: rules carry no ownership markers, so a rule matching
224 // the same host+paths cannot be proven ours — brand it `Unowned` so
225 // the engine refuses to take over unless `adopt` is set.
226 if (olds !== undefined && isResolved(olds)) {
227 const match = rules
228 .filter(
229 (rule) =>
230 (rule.host ?? undefined) === olds.host &&
231 samePaths(rule.paths ?? undefined, olds.paths),
232 )
233 .sort((a, b) => (a.created ?? "").localeCompare(b.created ?? ""))
234 .at(0);
235 if (match) return Unowned(toAttributes(match, rulesetId, acct));
236 }
237 return undefined;
238 }),
239
240 reconcile: Effect.fn(function* ({ news, output }) {
241 const { accountId } = yield* yield* CloudflareEnvironment;
242 // Inputs have been resolved to concrete strings by Plan.
243 const rulesetId = news.rulesetId as string;
244
245 // 1. Observe — the rule id cached on `output` is a hint, not a
246 // guarantee: a missing rule (or ruleset) falls through to create.
247 const rules =
248 output?.id !== undefined && output.rulesetId === rulesetId
249 ? yield* listRules(accountId, rulesetId)
250 : undefined;
251 let observed = rules?.find((rule) => rule.id === output?.id);
252
253 // 2. Ensure — create when missing. Rules have no uniqueness
254 // constraint, so there is no AlreadyExists race to tolerate. The
255 // API rejects bodies with absent `inclusive`/`is_paused`
256 // (`web_analytics.configuration.form.*.invalid`), so always send
257 // the full body with defaults. The create response omits
258 // `priority`, so re-read the rule from the list for the complete
259 // state.
260 if (!observed) {
261 const created = yield* rum
262 .createRule({
263 accountId,
264 rulesetId,
265 host: news.host,
266 paths: news.paths ?? [],
267 inclusive: news.inclusive ?? true,
268 isPaused: news.isPaused ?? false,
269 })
270 .pipe(
271 // The implicit ruleset of a freshly-created Site is eventually
272 // consistent: a createRule issued immediately after the site
273 // deploy can briefly 404 (`RulesetNotFound`, code
274 // `web_analytics.configuration.api.notFound`) before the ruleset is
275 // visible. Ride out the propagation window with a bounded retry.
276 Effect.retry({
277 while: (e) => e._tag === "RulesetNotFound",
278 schedule: Schedule.max([
279 Schedule.exponential("500 millis"),
280 Schedule.recurs(8),
281 ]),
282 }),
283 );
284 const fresh = yield* listRules(accountId, rulesetId);
285 observed =
286 fresh?.find((rule) => rule.id === created.id) ?? toRule(created);
287 return toAttributes(observed, rulesetId, accountId);
288 }
289
290 // 3. Sync — diff observed cloud state against desired. The update
291 // API is a PUT; send the full desired body, but skip the call
292 // entirely on a no-op. Unspecified props keep their observed
293 // values.
294 const desired = {
295 host: news.host ?? observed.host ?? undefined,
296 paths: news.paths ?? [...(observed.paths ?? [])],
297 inclusive: news.inclusive ?? observed.inclusive ?? true,
298 isPaused: news.isPaused ?? observed.isPaused ?? false,
299 };
300 const dirty =
301 (observed.host ?? undefined) !== desired.host ||
302 !samePaths(observed.paths ?? undefined, desired.paths) ||
303 (observed.inclusive ?? true) !== desired.inclusive ||
304 (observed.isPaused ?? false) !== desired.isPaused;
305 if (!dirty) {
306 return toAttributes(observed, rulesetId, accountId);
307 }
308
309 const updated = yield* rum.updateRule({
310 accountId,
311 rulesetId,
312 ruleId: observed.id ?? "",
313 ...desired,
314 });
315 return toAttributes(
316 { ...toRule(updated), priority: updated.priority ?? observed.priority },
317 rulesetId,
318 accountId,
319 );
320 }),
321
322 delete: Effect.fn(function* ({ output }) {
323 yield* rum
324 .deleteRule({
325 accountId: output.accountId,
326 rulesetId: output.rulesetId,
327 ruleId: output.id,
328 })
329 .pipe(
330 // `RuleNotFound` (code 10003) — the rule is already gone;
331 // `RulesetNotFound` (404) — the parent site/ruleset is gone,
332 // taking the rule with it. Both make delete a success.
333 Effect.catchTag(
334 ["RuleNotFound", "RulesetNotFound"],
335 () => Effect.void,
336 ),
337 );
338 }),
339 });
340
341type ObservedRule = NonNullable<
342 NonNullable<rum.ListRulesResponse["rules"]>
343>[number];
344
345/**
346 * List the rules of a ruleset, mapping "ruleset gone" (`RulesetNotFound`,
347 * HTTP 404 `web_analytics.configuration.api.notFound`) to `undefined`.
348 */
349const listRules = (accountId: string, rulesetId: string) =>
350 rum.listRules({ accountId, rulesetId }).pipe(
351 Effect.map(
352 (response): readonly ObservedRule[] | undefined => response.rules ?? [],
353 ),
354 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
355 );
356
357const toRule = (
358 rule: rum.CreateRuleResponse | rum.UpdateRuleResponse,
359): ObservedRule => rule;
360
361const samePaths = (
362 observed: readonly string[] | undefined,
363 desired: readonly string[] | undefined,
364) =>
365 (observed ?? []).length === (desired ?? []).length &&
366 (observed ?? []).join("") === (desired ?? []).join("");
367
368const toAttributes = (
369 rule: ObservedRule,
370 rulesetId: string,
371 accountId: string,
372): RuleAttributes => ({
373 id: rule.id ?? "",
374 rulesetId,
375 accountId,
376 host: rule.host ?? undefined,
377 paths: rule.paths ? [...rule.paths] : undefined,
378 inclusive: rule.inclusive ?? true,
379 isPaused: rule.isPaused ?? false,
380 priority: rule.priority ?? undefined,
381 created: rule.created ?? undefined,
382});