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