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 DEFAULT_LOCATION,
14 billingAccountIdOf,
15 billingAccountParent,
16 encodeDescription,
17 hasOwnershipMarker,
18 jsonEqual,
19 lastSegment,
20 lookupProjectBillingAccountId,
21 parseDescription,
22 resolveBillingAccountId,
23 toPhysicalId,
24} from "./internal.ts";
25
26export type BillingSavedQuerySummaryField = {
27 /**
28 * LogEntry field to include in the Logs Explorer summary line
29 * (e.g. `resource.type` or `jsonPayload.name`).
30 */
31 field?: string;
32};
33
34export type BillingSavedQueryLoggingQuery = {
35 /**
36 * Advanced logs filter. Max 20000 characters.
37 */
38 filter: string;
39 /**
40 * Characters counted from the start of the string in the summary line.
41 */
42 summaryFieldStart?: number;
43 /**
44 * Characters counted from the end of the string in the summary line.
45 */
46 summaryFieldEnd?: number;
47 /**
48 * Summary fields shown for this query in Logs Explorer.
49 */
50 summaryFields?: BillingSavedQuerySummaryField[];
51};
52
53export type BillingSavedQueryOpsAnalyticsQuery = {
54 /**
55 * Log Analytics SQL query text. Used when non-empty even if
56 * `queryBuilder` is also set.
57 */
58 sqlQueryText?: string;
59};
60
61export type BillingSavedQueryProps = {
62 /**
63 * Saved query id (the `{query}` segment of
64 * `billingAccounts/{billingAccount}/locations/{location}/savedQueries/{query}`).
65 * If omitted, a unique name is generated from the stack, stage, and
66 * logical id. Limited to 100 characters: letters, digits, underscores,
67 * hyphens, periods; first character must be alphanumeric. Immutable —
68 * changing it replaces the query.
69 */
70 savedQueryId?: string;
71 /**
72 * Billing account id (`XXXXXX-XXXXXX-XXXXXX` or
73 * `billingAccounts/{id}`). If omitted, Alchemy uses the billing
74 * account linked to the current project. Immutable — changing it
75 * replaces the query.
76 */
77 billingAccountId?: string;
78 /**
79 * Location of the saved query. Immutable after create — changing it
80 * replaces the query.
81 * @default "global"
82 */
83 location?: string;
84 /**
85 * User-specified title shown in Logs Explorer.
86 */
87 displayName: string;
88 /**
89 * Human-readable description. Saved queries have no labels field, so
90 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
91 * from attributes.
92 */
93 description?: string;
94 /**
95 * Visibility / ownership of the query.
96 * @default "PRIVATE"
97 */
98 visibility?: "PRIVATE" | "SHARED" | "VISIBILITY_UNSPECIFIED";
99 /**
100 * Logs Explorer query. At least one of `loggingQuery` or
101 * `opsAnalyticsQuery` must be set.
102 */
103 loggingQuery?: BillingSavedQueryLoggingQuery;
104 /**
105 * Log Analytics SQL query. At least one of `loggingQuery` or
106 * `opsAnalyticsQuery` must be set.
107 */
108 opsAnalyticsQuery?: BillingSavedQueryOpsAnalyticsQuery;
109};
110
111export type BillingSavedQuery = Resource<
112 "GCP.Logging.BillingSavedQuery",
113 BillingSavedQueryProps,
114 {
115 /** Full resource name `billingAccounts/{billingAccount}/locations/{location}/savedQueries/{savedQueryId}`. */
116 name: string;
117 /** Saved query id (last path segment). */
118 savedQueryId: string;
119 /** Billing account id. */
120 billingAccountId: string;
121 /** Location of the saved query. */
122 location: string;
123 /** User-specified title. */
124 displayName: string;
125 /** User description with the Alchemy ownership prefix stripped. */
126 description: string | undefined;
127 /** Visibility / ownership. */
128 visibility: string | undefined;
129 /** Logs Explorer query, if set. */
130 loggingQuery: BillingSavedQueryLoggingQuery | undefined;
131 /** Log Analytics query, if set. */
132 opsAnalyticsQuery: BillingSavedQueryOpsAnalyticsQuery | undefined;
133 /** RFC3339 creation timestamp. */
134 createTime: string | undefined;
135 /** RFC3339 last-update timestamp. */
136 updateTime: string | undefined;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A Cloud Logging saved query owned by a billing account.
144 *
145 * Saved queries have no labels field, so Alchemy stamps ownership into
146 * the description for `list` / nuke. `savedQueryId`, `location`, and
147 * `billingAccountId` are identity — changing any replaces the query.
148 * Display name, description, visibility, and query text update in place.
149 *
150 * ### Creating a Billing Saved Query
151 * **Example:** Logs Explorer error query
152 * ```typescript
153 * const query = yield* GCP.Logging.BillingSavedQuery("Errors", {
154 * displayName: "billing errors",
155 * loggingQuery: { filter: "severity>=ERROR" },
156 * description: "error entries",
157 * });
158 * ```
159 *
160 * **Example:** Named shared query
161 * ```typescript
162 * const query = yield* GCP.Logging.BillingSavedQuery("Errors", {
163 * billingAccountId: "AAAAAA-BBBBBB-CCCCCC",
164 * savedQueryId: "billing-errors",
165 * displayName: "billing errors",
166 * visibility: "SHARED",
167 * loggingQuery: { filter: "severity>=ERROR" },
168 * });
169 * ```
170 *
171 * ### Updating a Billing Saved Query
172 * **Example:** Change the filter and title
173 * ```typescript
174 * const query = yield* GCP.Logging.BillingSavedQuery("Errors", {
175 * displayName: "billing warnings",
176 * loggingQuery: { filter: "severity>=WARNING" },
177 * description: "warnings and errors",
178 * });
179 * ```
180 *
181 * @resource
182 * @category Logging
183 */
184export const BillingSavedQuery = Resource<BillingSavedQuery>(
185 "GCP.Logging.BillingSavedQuery",
186);
187
188export class BillingSavedQueryNotResolved extends Data.TaggedError(
189 "GCP.Logging.BillingSavedQueryNotResolved",
190)<{
191 name: string;
192}> {}
193
194const resourceName = (
195 billingAccountId: string,
196 location: string,
197 savedQueryId: string,
198) =>
199 `${billingAccountParent(billingAccountId)}/locations/${location}/savedQueries/${savedQueryId}`;
200
201const parseQueryName = (name: string) => {
202 const match = name.match(
203 /^billingAccounts\/([^/]+)\/locations\/([^/]+)\/savedQueries\/([^/]+)$/,
204 );
205 if (!match) return undefined;
206 return {
207 billingAccountId: match[1]!,
208 location: match[2]!,
209 savedQueryId: match[3]!,
210 };
211};
212
213const toLoggingQuery = (
214 query: logging.LoggingQuery | undefined,
215): BillingSavedQueryLoggingQuery | undefined => {
216 if (query?.filter === undefined) return undefined;
217 return {
218 filter: query.filter,
219 summaryFieldStart: query.summaryFieldStart,
220 summaryFieldEnd: query.summaryFieldEnd,
221 summaryFields: query.summaryFields,
222 };
223};
224
225const toOpsQuery = (
226 query: logging.OpsAnalyticsQuery | undefined,
227): BillingSavedQueryOpsAnalyticsQuery | undefined => {
228 if (query?.sqlQueryText === undefined) return undefined;
229 return { sqlQueryText: query.sqlQueryText };
230};
231
232const toAttrs = (
233 query: logging.SavedQuery,
234 billingAccountId: string,
235 location: string,
236) => {
237 const parsedName = parseQueryName(query.name ?? "");
238 const savedQueryId =
239 parsedName?.savedQueryId ?? lastSegment(query.name ?? "");
240 const parsed = parseDescription(query.description);
241 const account = parsedName?.billingAccountId ?? billingAccountId;
242 const resolvedLocation = parsedName?.location ?? location;
243 return {
244 name:
245 query.name ??
246 (savedQueryId
247 ? resourceName(account, resolvedLocation, savedQueryId)
248 : ""),
249 savedQueryId,
250 billingAccountId: account,
251 location: resolvedLocation,
252 displayName: query.displayName ?? "",
253 description: parsed.description,
254 visibility: query.visibility,
255 loggingQuery: toLoggingQuery(query.loggingQuery),
256 opsAnalyticsQuery: toOpsQuery(query.opsAnalyticsQuery),
257 createTime: query.createTime,
258 updateTime: query.updateTime,
259 };
260};
261
262const getByName = (name: string) =>
263 logging
264 .getBillingAccountsLocationsSavedQueries({ name })
265 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
266
267const toBody = (
268 props: BillingSavedQueryProps,
269 description: string,
270): logging.SavedQuery => ({
271 displayName: props.displayName,
272 description,
273 visibility: props.visibility ?? "PRIVATE",
274 loggingQuery: props.loggingQuery
275 ? {
276 filter: props.loggingQuery.filter,
277 summaryFieldStart: props.loggingQuery.summaryFieldStart,
278 summaryFieldEnd: props.loggingQuery.summaryFieldEnd,
279 summaryFields: props.loggingQuery.summaryFields,
280 }
281 : undefined,
282 opsAnalyticsQuery: props.opsAnalyticsQuery
283 ? { sqlQueryText: props.opsAnalyticsQuery.sqlQueryText }
284 : undefined,
285});
286
287export const BillingSavedQueryProvider = () =>
288 Provider.succeed(BillingSavedQuery, {
289 stables: [
290 "name",
291 "savedQueryId",
292 "billingAccountId",
293 "location",
294 "createTime",
295 ],
296
297 diff: Effect.fn(function* ({ news, olds, output }) {
298 if (!isResolved(news)) return undefined;
299 const previousId = olds?.savedQueryId ?? output?.savedQueryId;
300 const idChanged =
301 previousId !== undefined &&
302 news.savedQueryId !== undefined &&
303 news.savedQueryId !== previousId;
304 const previousLocation = olds?.location ?? output?.location;
305 const locationChanged =
306 previousLocation !== undefined &&
307 news.location !== undefined &&
308 news.location !== previousLocation;
309 const previousAccount =
310 olds?.billingAccountId ?? output?.billingAccountId;
311 const accountChanged =
312 previousAccount !== undefined &&
313 news.billingAccountId !== undefined &&
314 billingAccountIdOf(news.billingAccountId) !==
315 billingAccountIdOf(previousAccount);
316 if (!idChanged && !locationChanged && !accountChanged) return undefined;
317 return { action: "replace" as const, deleteFirst: false };
318 }),
319
320 read: Effect.fn(function* ({ id, olds, output }) {
321 const billingAccountId = yield* resolveBillingAccountId(
322 olds?.billingAccountId,
323 output?.billingAccountId,
324 );
325 const location = olds?.location ?? output?.location ?? DEFAULT_LOCATION;
326 const savedQueryId = yield* toPhysicalId(
327 id,
328 olds?.savedQueryId,
329 output?.savedQueryId,
330 "q",
331 );
332 const name =
333 output?.name ?? resourceName(billingAccountId, location, savedQueryId);
334 const existing = yield* getByName(name);
335 if (existing === undefined) return undefined;
336 const attrs = toAttrs(existing, billingAccountId, location);
337 const { labels } = parseDescription(existing.description);
338 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
339 }),
340
341 list: () =>
342 Effect.gen(function* () {
343 const env = yield* GcpEnvironment.current;
344 const billingAccountId = yield* lookupProjectBillingAccountId(
345 env.project,
346 );
347 if (billingAccountId === undefined) return [];
348 return yield* logging.listBillingAccountsLocationsSavedQueries
349 .pages({
350 parent: `${billingAccountParent(billingAccountId)}/locations/-`,
351 pageSize: 1000,
352 })
353 .pipe(
354 Stream.flatMap((page) =>
355 Stream.fromIterable(page.savedQueries ?? []),
356 ),
357 Stream.filter((query) => hasOwnershipMarker(query.description)),
358 Stream.map((query) =>
359 toAttrs(query, billingAccountId, DEFAULT_LOCATION),
360 ),
361 Stream.runCollect,
362 Effect.map((chunk) => Array.from(chunk)),
363 Effect.catchTag("NotFound", () =>
364 Effect.succeed([] as BillingSavedQuery["Attributes"][]),
365 ),
366 );
367 }),
368
369 reconcile: Effect.fn(function* ({ id, news, output }) {
370 const billingAccountId = yield* resolveBillingAccountId(
371 news.billingAccountId,
372 output?.billingAccountId,
373 );
374 const location = news.location ?? output?.location ?? DEFAULT_LOCATION;
375 const savedQueryId = yield* toPhysicalId(
376 id,
377 news.savedQueryId,
378 output?.savedQueryId,
379 "q",
380 );
381 const name = resourceName(billingAccountId, location, savedQueryId);
382 const ownership = yield* createInternalLabels(id);
383 const desiredDescription = encodeDescription(ownership, news.description);
384 const desiredVisibility = news.visibility ?? "PRIVATE";
385
386 let current = yield* getByName(output?.name ?? name);
387
388 if (current === undefined) {
389 const created = yield* logging
390 .createBillingAccountsLocationsSavedQueries({
391 parent: `${billingAccountParent(billingAccountId)}/locations/${location}`,
392 savedQueryId,
393 body: toBody(news, desiredDescription),
394 })
395 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
396 current = created ?? undefined;
397 }
398
399 if (current === undefined) {
400 return yield* new BillingSavedQueryNotResolved({ name });
401 }
402
403 const descriptionChanged =
404 (current.description ?? "") !== desiredDescription;
405 const displayNameChanged =
406 (current.displayName ?? "") !== news.displayName;
407 const visibilityChanged =
408 (current.visibility ?? "PRIVATE") !== desiredVisibility;
409 const loggingChanged =
410 news.loggingQuery !== undefined &&
411 !jsonEqual(toLoggingQuery(current.loggingQuery), news.loggingQuery);
412 const opsChanged =
413 news.opsAnalyticsQuery !== undefined &&
414 !jsonEqual(
415 toOpsQuery(current.opsAnalyticsQuery),
416 news.opsAnalyticsQuery,
417 );
418
419 const updateMask = [
420 descriptionChanged ? "description" : undefined,
421 displayNameChanged ? "displayName" : undefined,
422 visibilityChanged ? "visibility" : undefined,
423 loggingChanged ? "loggingQuery" : undefined,
424 opsChanged ? "opsAnalyticsQuery" : undefined,
425 ].filter((field): field is string => field !== undefined);
426
427 if (updateMask.length > 0) {
428 current = yield* logging.patchBillingAccountsLocationsSavedQueries({
429 name: current.name ?? name,
430 updateMask: updateMask.join(","),
431 body: toBody(news, desiredDescription),
432 });
433 }
434
435 return toAttrs(current, billingAccountId, location);
436 }),
437
438 delete: Effect.fn(function* ({ output }) {
439 yield* logging
440 .deleteBillingAccountsLocationsSavedQueries({ name: output.name })
441 .pipe(Effect.catchTag("NotFound", () => Effect.void));
442 }),
443 });