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