1import * as observability from "@distilled.cloud/gcp/observability_v1";
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_TRACE_LOCATION,
14 encodeDescription,
15 expandResourceNames,
16 hasOwnershipMarker,
17 parseDescription,
18 parseScopeName,
19 sameStringList,
20 scopeResourceName,
21 toScopeId,
22} from "./internal.ts";
23
24export type TraceScopeProps = {
25 /**
26 * Trace scope id (the `{traceScope}` segment of
27 * `projects/{project}/locations/{location}/traceScopes/{traceScope}`).
28 * If omitted, a unique name is generated from the stack, stage, and
29 * logical id. Limited to 100 characters: letters, digits, underscores,
30 * hyphens, periods; first character must be alphanumeric. Immutable —
31 * changing it replaces the scope. The system-managed `_Default` scope
32 * cannot be deleted.
33 */
34 traceScopeId?: string;
35 /**
36 * Location. Trace scopes are only available in `global`. Immutable —
37 * changing it replaces the scope.
38 * @default "global"
39 */
40 location?: string;
41 /**
42 * Projects whose traces this scope queries together. Each entry is
43 * `projects/{project}`. At most 20 projects. Bare project ids are
44 * expanded. If omitted, the current project is used.
45 */
46 resourceNames?: string[];
47 /**
48 * Human-readable description (max 8000 characters). Trace scopes have
49 * no labels field, so Alchemy ownership is stored in a `[alchemy …]`
50 * prefix and stripped from attributes.
51 */
52 description?: string;
53};
54
55export type TraceScope = Resource<
56 "GCP.Observability.TraceScope",
57 TraceScopeProps,
58 {
59 /** Full resource name `projects/{project}/locations/{location}/traceScopes/{traceScopeId}`. */
60 name: string;
61 /** Trace scope id (last path segment). */
62 traceScopeId: string;
63 /** Project id. */
64 project: string;
65 /** Location (`global`). */
66 location: string;
67 /** Covered projects (`projects/{project}`). */
68 resourceNames: string[];
69 /** User description with the Alchemy ownership prefix stripped. */
70 description: string | undefined;
71 /** RFC3339 creation timestamp. */
72 createTime: string | undefined;
73 /** RFC3339 last-update timestamp. */
74 updateTime: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Cloud Observability trace scope — a named collection of projects
82 * whose traces are queried together in Trace Explorer.
83 *
84 * Trace scopes exist only in the `global` location. They have no labels
85 * field, so Alchemy stamps ownership into the description for `list` /
86 * nuke. `traceScopeId` and `location` are identity — changing either
87 * replaces the scope. `resourceNames` and description update in place.
88 * The system-managed `_Default` scope cannot be deleted.
89 *
90 * ### Creating a Trace Scope
91 * **Example:** Generated name covering this project
92 * ```typescript
93 * const scope = yield* GCP.Observability.TraceScope("App", {
94 * resourceNames: ["projects/my-project"],
95 * description: "application traces",
96 * });
97 * ```
98 *
99 * **Example:** Named scope
100 * ```typescript
101 * const scope = yield* GCP.Observability.TraceScope("App", {
102 * traceScopeId: "app-traces",
103 * resourceNames: ["projects/my-project"],
104 * });
105 * ```
106 *
107 * ### Updating a Trace Scope
108 * Change props on the same logical id; the engine keeps the physical id.
109 *
110 * **Example:** Change the description
111 * ```typescript
112 * const scope = yield* GCP.Observability.TraceScope("App", {
113 * resourceNames: ["projects/my-project"],
114 * description: "all application traces",
115 * });
116 * ```
117 *
118 * @resource
119 * @category Observability
120 */
121export const TraceScope = Resource<TraceScope>("GCP.Observability.TraceScope");
122
123export class TraceScopeNotResolved extends Data.TaggedError(
124 "GCP.Observability.TraceScopeNotResolved",
125)<{
126 name: string;
127}> {}
128
129const traceScopeIdOf = (scope: observability.TraceScope, fallback?: string) => {
130 const parsed = parseScopeName(scope.name ?? "");
131 return (
132 parsed?.traceScopeId ??
133 fallback ??
134 (scope.name ?? "").split("/").pop() ??
135 ""
136 );
137};
138
139const toAttrs = (
140 scope: observability.TraceScope,
141 project: string,
142 location: string,
143) => {
144 const traceScopeId = traceScopeIdOf(scope);
145 const parsed = parseDescription(scope.description);
146 const parsedName = parseScopeName(scope.name ?? "");
147 const resolvedLocation = parsedName?.location ?? location;
148 const resolvedProject = parsedName?.project ?? project;
149 return {
150 name:
151 scope.name ??
152 (traceScopeId
153 ? scopeResourceName(resolvedProject, resolvedLocation, traceScopeId)
154 : ""),
155 traceScopeId,
156 project: resolvedProject,
157 location: resolvedLocation,
158 resourceNames: [...(scope.resourceNames ?? [])],
159 description: parsed.description,
160 createTime: scope.createTime,
161 updateTime: scope.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 observability
167 .getProjectsLocationsTraceScopes({ name })
168 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
169
170export const TraceScopeProvider = () =>
171 Provider.succeed(TraceScope, {
172 stables: ["name", "traceScopeId", "project", "location", "createTime"],
173
174 diff: Effect.fn(function* ({ news, olds, output }) {
175 if (!isResolved(news)) return undefined;
176 const previousId = olds?.traceScopeId ?? output?.traceScopeId;
177 const idChanged =
178 previousId !== undefined &&
179 news.traceScopeId !== undefined &&
180 news.traceScopeId !== previousId;
181 const previousLocation = olds?.location ?? output?.location;
182 const locationChanged =
183 previousLocation !== undefined &&
184 news.location !== undefined &&
185 news.location !== previousLocation;
186 if (!idChanged && !locationChanged) return undefined;
187 return { action: "replace" as const, deleteFirst: false };
188 }),
189
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const env = yield* GcpEnvironment.current;
192 const location =
193 olds?.location ?? output?.location ?? DEFAULT_TRACE_LOCATION;
194 const traceScopeId = yield* toScopeId(
195 id,
196 olds?.traceScopeId,
197 output?.traceScopeId,
198 );
199 const name =
200 output?.name ?? scopeResourceName(env.project, location, traceScopeId);
201 const existing = yield* getByName(name);
202 if (existing === undefined) return undefined;
203 const attrs = toAttrs(existing, env.project, location);
204 const { labels } = parseDescription(existing.description);
205 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
206 }),
207
208 list: () =>
209 Effect.gen(function* () {
210 const env = yield* GcpEnvironment.current;
211 return yield* observability.listProjectsLocationsTraceScopes
212 .pages({
213 parent: `projects/${env.project}/locations/${DEFAULT_TRACE_LOCATION}`,
214 pageSize: 1000,
215 })
216 .pipe(
217 Stream.flatMap((page) =>
218 Stream.fromIterable(page.traceScopes ?? []),
219 ),
220 Stream.filter((scope) => hasOwnershipMarker(scope.description)),
221 Stream.map((scope) =>
222 toAttrs(scope, env.project, DEFAULT_TRACE_LOCATION),
223 ),
224 Stream.runCollect,
225 Effect.map((chunk) => Array.from(chunk)),
226 Effect.catchTag("NotFound", () =>
227 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
228 ),
229 );
230 }),
231
232 reconcile: Effect.fn(function* ({ id, news, output }) {
233 const env = yield* GcpEnvironment.current;
234 const location =
235 news.location ?? output?.location ?? DEFAULT_TRACE_LOCATION;
236 const traceScopeId = yield* toScopeId(
237 id,
238 news.traceScopeId,
239 output?.traceScopeId,
240 );
241 const name = scopeResourceName(env.project, location, traceScopeId);
242 const ownership = yield* createInternalLabels(id);
243 const desiredDescription = encodeDescription(ownership, news.description);
244 const desiredResources = expandResourceNames(
245 news.resourceNames,
246 env.project,
247 );
248
249 let current = yield* getByName(output?.name ?? name);
250
251 if (current === undefined) {
252 const created = yield* observability
253 .createProjectsLocationsTraceScopes({
254 parent: `projects/${env.project}/locations/${location}`,
255 traceScopeId,
256 body: {
257 resourceNames: desiredResources,
258 description: desiredDescription,
259 },
260 })
261 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
262 current = created ?? undefined;
263 }
264
265 if (current === undefined) {
266 return yield* new TraceScopeNotResolved({ name });
267 }
268
269 const descriptionChanged =
270 (current.description ?? "") !== desiredDescription;
271 const resourcesChanged = !sameStringList(
272 current.resourceNames,
273 desiredResources,
274 );
275 const updateMask = [
276 descriptionChanged ? "description" : undefined,
277 resourcesChanged ? "resourceNames" : undefined,
278 ].filter((field): field is string => field !== undefined);
279
280 if (updateMask.length > 0) {
281 current = yield* observability.patchProjectsLocationsTraceScopes({
282 name: current.name ?? name,
283 updateMask: updateMask.join(","),
284 body: {
285 resourceNames: desiredResources,
286 description: desiredDescription,
287 },
288 });
289 }
290
291 return toAttrs(current, env.project, location);
292 }),
293
294 delete: Effect.fn(function* ({ output }) {
295 if (output.traceScopeId === "_Default") return;
296 yield* observability
297 .deleteProjectsLocationsTraceScopes({ name: output.name })
298 .pipe(Effect.catchTag("NotFound", () => Effect.void));
299 }),
300 });