1import * as logging from "@distilled.cloud/gcp/logging_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 billingAccountIdOf,
14 billingAccountParent,
15 encodeDescription,
16 hasOwnershipMarker,
17 lastSegment,
18 lookupProjectBillingAccountId,
19 parseDescription,
20 resolveBillingAccountId,
21 toPhysicalId,
22} from "./internal.ts";
23
24export type BillingExclusionProps = {
25 /**
26 * Exclusion id (the `{exclusion}` segment of
27 * `billingAccounts/{billingAccount}/exclusions/{exclusion}`). If omitted,
28 * a unique name is generated from the stack, stage, and logical id.
29 * Limited to 100 characters: letters, digits, underscores, hyphens,
30 * periods; first character must be alphanumeric. Immutable — changing
31 * it replaces the exclusion.
32 */
33 exclusionId?: string;
34 /**
35 * Billing account id (`XXXXXX-XXXXXX-XXXXXX` or
36 * `billingAccounts/{id}`). If omitted, Alchemy uses the billing
37 * account linked to the current project. Immutable — changing it
38 * replaces the exclusion.
39 */
40 billingAccountId?: string;
41 /**
42 * Advanced logs filter matching entries to exclude from the `_Default`
43 * sink. Required.
44 */
45 filter: string;
46 /**
47 * Human-readable description. Logging exclusions have no labels field,
48 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
49 * from attributes.
50 */
51 description?: string;
52 /**
53 * When true, the exclusion exists but does not exclude any entries.
54 * @default false
55 */
56 disabled?: boolean;
57};
58
59export type BillingExclusion = Resource<
60 "GCP.Logging.BillingExclusion",
61 BillingExclusionProps,
62 {
63 /** Full resource name `billingAccounts/{billingAccount}/exclusions/{exclusionId}`. */
64 name: string;
65 /** Exclusion id (last path segment). */
66 exclusionId: string;
67 /** Billing account id. */
68 billingAccountId: string;
69 /** Advanced logs filter. */
70 filter: string;
71 /** User description with the Alchemy ownership prefix stripped. */
72 description: string | undefined;
73 /** Whether the exclusion is disabled. */
74 disabled: boolean;
75 /** RFC3339 creation timestamp. */
76 createTime: string | undefined;
77 /** RFC3339 last-update timestamp. */
78 updateTime: string | undefined;
79 },
80 never,
81 Providers
82>;
83
84/**
85 * A Cloud Logging exclusion on a billing account `_Default` sink.
86 *
87 * Exclusions drop matching log entries before they are stored in the
88 * `_Default` bucket (they do not apply to `_Required`). Logging exclusions
89 * have no labels field, so Alchemy stamps ownership into the description
90 * for `list` / nuke. Name is identity — changing `exclusionId` or
91 * `billingAccountId` replaces the exclusion.
92 *
93 * ### Creating a Billing Exclusion
94 * **Example:** Generated name, drop debug logs
95 * ```typescript
96 * const exclusion = yield* GCP.Logging.BillingExclusion("DropDebug", {
97 * filter: "severity=DEBUG",
98 * description: "drop debug entries",
99 * });
100 * ```
101 *
102 * **Example:** Named exclusion on an explicit billing account
103 * ```typescript
104 * const exclusion = yield* GCP.Logging.BillingExclusion("DropDebug", {
105 * billingAccountId: "AAAAAA-BBBBBB-CCCCCC",
106 * exclusionId: "drop-debug",
107 * filter: "severity=DEBUG",
108 * });
109 * ```
110 *
111 * ### Updating a Billing Exclusion
112 * **Example:** Change the filter and disable
113 * ```typescript
114 * const exclusion = yield* GCP.Logging.BillingExclusion("DropDebug", {
115 * filter: "severity<ERROR",
116 * description: "drop non-errors",
117 * disabled: true,
118 * });
119 * ```
120 *
121 * @resource
122 * @category Logging
123 */
124export const BillingExclusion = Resource<BillingExclusion>(
125 "GCP.Logging.BillingExclusion",
126);
127
128export class BillingExclusionNotResolved extends Data.TaggedError(
129 "GCP.Logging.BillingExclusionNotResolved",
130)<{
131 name: string;
132}> {}
133
134const resourceName = (billingAccountId: string, exclusionId: string) =>
135 `${billingAccountParent(billingAccountId)}/exclusions/${exclusionId}`;
136
137const exclusionIdOf = (exclusion: logging.LogExclusion) => {
138 const raw = exclusion.name ?? "";
139 return raw.includes("/") ? lastSegment(raw) : raw;
140};
141
142const billingAccountOfName = (name: string, fallback: string) => {
143 const match = name.match(/^billingAccounts\/([^/]+)\//);
144 return match?.[1] ?? fallback;
145};
146
147const toAttrs = (exclusion: logging.LogExclusion, billingAccountId: string) => {
148 const exclusionId = exclusionIdOf(exclusion);
149 const parsed = parseDescription(exclusion.description);
150 const account = billingAccountOfName(exclusion.name ?? "", billingAccountId);
151 return {
152 name:
153 exclusion.name?.includes("/") === true
154 ? exclusion.name
155 : resourceName(account, exclusionId),
156 exclusionId,
157 billingAccountId: account,
158 filter: exclusion.filter ?? "",
159 description: parsed.description,
160 disabled: exclusion.disabled === true,
161 createTime: exclusion.createTime,
162 updateTime: exclusion.updateTime,
163 };
164};
165
166const getByName = (name: string) =>
167 logging
168 .getBillingAccountsExclusions({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171export const BillingExclusionProvider = () =>
172 Provider.succeed(BillingExclusion, {
173 stables: ["name", "exclusionId", "billingAccountId", "createTime"],
174
175 diff: Effect.fn(function* ({ news, olds, output }) {
176 if (!isResolved(news)) return undefined;
177 const previousId = olds?.exclusionId ?? output?.exclusionId;
178 const idChanged =
179 previousId !== undefined &&
180 news.exclusionId !== undefined &&
181 news.exclusionId !== previousId;
182 const previousAccount =
183 olds?.billingAccountId ?? output?.billingAccountId;
184 const accountChanged =
185 previousAccount !== undefined &&
186 news.billingAccountId !== undefined &&
187 billingAccountIdOf(news.billingAccountId) !==
188 billingAccountIdOf(previousAccount);
189 if (!idChanged && !accountChanged) return undefined;
190 return { action: "replace" as const, deleteFirst: false };
191 }),
192
193 read: Effect.fn(function* ({ id, olds, output }) {
194 const billingAccountId = yield* resolveBillingAccountId(
195 olds?.billingAccountId,
196 output?.billingAccountId,
197 );
198 const exclusionId = yield* toPhysicalId(
199 id,
200 olds?.exclusionId,
201 output?.exclusionId,
202 "e",
203 );
204 const name = output?.name ?? resourceName(billingAccountId, exclusionId);
205 const existing = yield* getByName(name);
206 if (existing === undefined) return undefined;
207 const attrs = toAttrs(existing, billingAccountId);
208 const { labels } = parseDescription(existing.description);
209 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
210 }),
211
212 list: () =>
213 Effect.gen(function* () {
214 const env = yield* GcpEnvironment.current;
215 const billingAccountId = yield* lookupProjectBillingAccountId(
216 env.project,
217 );
218 if (billingAccountId === undefined) return [];
219 return yield* logging.listBillingAccountsExclusions
220 .pages({
221 parent: billingAccountParent(billingAccountId),
222 pageSize: 1000,
223 })
224 .pipe(
225 Stream.flatMap((page) =>
226 Stream.fromIterable(page.exclusions ?? []),
227 ),
228 Stream.filter((exclusion) =>
229 hasOwnershipMarker(exclusion.description),
230 ),
231 Stream.map((exclusion) => toAttrs(exclusion, billingAccountId)),
232 Stream.runCollect,
233 Effect.map((chunk) => Array.from(chunk)),
234 Effect.catchTag("NotFound", () =>
235 Effect.succeed([] as BillingExclusion["Attributes"][]),
236 ),
237 );
238 }),
239
240 reconcile: Effect.fn(function* ({ id, news, output }) {
241 const billingAccountId = yield* resolveBillingAccountId(
242 news.billingAccountId,
243 output?.billingAccountId,
244 );
245 const exclusionId = yield* toPhysicalId(
246 id,
247 news.exclusionId,
248 output?.exclusionId,
249 "e",
250 );
251 const name = resourceName(billingAccountId, exclusionId);
252 const ownership = yield* createInternalLabels(id);
253 const desiredDescription = encodeDescription(ownership, news.description);
254
255 let current = yield* getByName(output?.name ?? name);
256
257 if (current === undefined) {
258 const created = yield* logging
259 .createBillingAccountsExclusions({
260 parent: billingAccountParent(billingAccountId),
261 body: {
262 name: exclusionId,
263 filter: news.filter,
264 description: desiredDescription,
265 disabled: news.disabled === true ? true : undefined,
266 },
267 })
268 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
269 current = created ?? undefined;
270 }
271
272 if (current === undefined) {
273 return yield* new BillingExclusionNotResolved({ name });
274 }
275
276 const desiredDisabled = news.disabled === true;
277 const filterChanged = (current.filter ?? "") !== news.filter;
278 const descriptionChanged =
279 (current.description ?? "") !== desiredDescription;
280 const disabledChanged = (current.disabled === true) !== desiredDisabled;
281
282 const updateMask = [
283 filterChanged ? "filter" : undefined,
284 descriptionChanged ? "description" : undefined,
285 disabledChanged ? "disabled" : undefined,
286 ].filter((field): field is string => field !== undefined);
287
288 if (updateMask.length > 0) {
289 current = yield* logging.patchBillingAccountsExclusions({
290 name: current.name?.includes("/") ? current.name : name,
291 updateMask: updateMask.join(","),
292 body: {
293 filter: news.filter,
294 description: desiredDescription,
295 disabled: desiredDisabled,
296 },
297 });
298 }
299
300 return toAttrs(current, billingAccountId);
301 }),
302
303 delete: Effect.fn(function* ({ output }) {
304 yield* logging
305 .deleteBillingAccountsExclusions({ name: output.name })
306 .pipe(Effect.catchTag("NotFound", () => Effect.void));
307 }),
308 });