1import * as observability from "@distilled.cloud/gcp/observability_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { GcpEnvironment } from "../Environment.ts";
6import { alchemyLabelKeys } from "../Labels.ts";
7
8export const MAX_NAME_LENGTH = 100;
9export const DEFAULT_TRACE_LOCATION = "global";
10export const DEFAULT_BUCKET_ID = "_Trace";
11export const DEFAULT_DATASET_ID = "Spans";
12
13export const lastSegment = (value: string) => {
14 const trimmed = value.replace(/\/+$/, "");
15 const parts = trimmed.split("/");
16 return parts[parts.length - 1] || trimmed;
17};
18
19export const encodeDescription = (
20 labels: Record<string, string>,
21 description: string | undefined,
22): string => {
23 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
24 return description ? `${marker}\n${description}` : marker;
25};
26
27export const parseDescription = (
28 description: string | undefined,
29): {
30 labels: Record<string, string>;
31 description: string | undefined;
32} => {
33 if (!description?.startsWith("[alchemy ")) {
34 return { labels: {}, description };
35 }
36 const end = description.indexOf("]");
37 if (end < 0) return { labels: {}, description };
38 const labels: Record<string, string> = {};
39 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
40 const eq = part.indexOf("=");
41 if (eq > 0) {
42 labels[part.slice(0, eq)] = part.slice(eq + 1);
43 }
44 }
45 const rest = description.slice(end + 1).replace(/^\n/, "");
46 return { labels, description: rest.length > 0 ? rest : undefined };
47};
48
49export const hasOwnershipMarker = (description: string | undefined) =>
50 Object.keys(parseDescription(description).labels).some((key) =>
51 key.startsWith("alchemy-"),
52 );
53
54export const sortedStrings = (values: readonly string[] | undefined) =>
55 [...(values ?? [])].slice().sort();
56
57export const sameStringList = (
58 left: readonly string[] | undefined,
59 right: readonly string[] | undefined,
60) =>
61 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
62
63export const normalizeLocation = (
64 location: string | undefined,
65 fallback: string,
66) => lastSegment(location ?? fallback).toLowerCase();
67
68export const expandProjectName = (value: string, project: string) => {
69 const trimmed = value.replace(/\/+$/, "");
70 if (trimmed.startsWith("projects/")) return trimmed;
71 if (trimmed.length === 0) return `projects/${project}`;
72 return `projects/${lastSegment(trimmed)}`;
73};
74
75export const expandResourceNames = (
76 names: readonly string[] | undefined,
77 project: string,
78) =>
79 (names === undefined || names.length === 0
80 ? [`projects/${project}`]
81 : [...names]
82 ).map((name) => expandProjectName(name, project));
83
84export const toScopeId = (
85 id: string,
86 explicit: string | undefined,
87 existing?: string,
88) =>
89 Effect.gen(function* () {
90 if (explicit !== undefined) return explicit;
91 if (existing !== undefined) return existing;
92 const generated = yield* createPhysicalName({
93 id,
94 maxLength: MAX_NAME_LENGTH,
95 lowercase: true,
96 });
97 return /^[a-z0-9]/.test(generated)
98 ? generated
99 : `s${generated}`.slice(0, MAX_NAME_LENGTH);
100 });
101
102export const toLinkId = (
103 id: string,
104 explicit: string | undefined,
105 existing?: string,
106) =>
107 Effect.gen(function* () {
108 if (explicit !== undefined) return explicit;
109 if (existing !== undefined) return existing;
110 const generated = yield* createPhysicalName({
111 id,
112 maxLength: MAX_NAME_LENGTH,
113 lowercase: true,
114 delimiter: "_",
115 });
116 const cleaned = generated.replace(/-/g, "_").replace(/[^a-z0-9_]/g, "_");
117 return /^[a-z]/.test(cleaned)
118 ? cleaned
119 : `l${cleaned}`.slice(0, MAX_NAME_LENGTH);
120 });
121
122export type ParsedLinkName = {
123 project: string;
124 location: string;
125 bucketId: string;
126 datasetId: string;
127 linkId: string;
128};
129
130export const parseLinkName = (name: string): ParsedLinkName | undefined => {
131 const match = name.match(
132 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)\/datasets\/([^/]+)\/links\/([^/]+)$/,
133 );
134 if (!match) return undefined;
135 return {
136 project: match[1]!,
137 location: match[2]!,
138 bucketId: match[3]!,
139 datasetId: match[4]!,
140 linkId: match[5]!,
141 };
142};
143
144export const parseDatasetName = (name: string) => {
145 const match = name.match(
146 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)\/datasets\/([^/]+)$/,
147 );
148 if (!match) return undefined;
149 return {
150 name: match[0]!,
151 project: match[1]!,
152 location: match[2]!,
153 bucketId: match[3]!,
154 datasetId: match[4]!,
155 };
156};
157
158export const parseBucketName = (name: string) => {
159 const match = name.match(
160 /^projects\/([^/]+)\/locations\/([^/]+)\/buckets\/([^/]+)$/,
161 );
162 if (!match) return undefined;
163 return {
164 name: match[0]!,
165 project: match[1]!,
166 location: match[2]!,
167 bucketId: match[3]!,
168 };
169};
170
171export const parseScopeName = (name: string) => {
172 const match = name.match(
173 /^projects\/([^/]+)\/locations\/([^/]+)\/traceScopes\/([^/]+)$/,
174 );
175 if (!match) return undefined;
176 return {
177 project: match[1]!,
178 location: match[2]!,
179 traceScopeId: match[3]!,
180 };
181};
182
183export const datasetResourceName = (
184 project: string,
185 location: string,
186 bucketId: string,
187 datasetId: string,
188) =>
189 `projects/${project}/locations/${location}/buckets/${bucketId}/datasets/${datasetId}`;
190
191export const linkResourceName = (
192 project: string,
193 location: string,
194 bucketId: string,
195 datasetId: string,
196 linkId: string,
197) =>
198 `${datasetResourceName(project, location, bucketId, datasetId)}/links/${linkId}`;
199
200export const scopeResourceName = (
201 project: string,
202 location: string,
203 traceScopeId: string,
204) => `projects/${project}/locations/${location}/traceScopes/${traceScopeId}`;
205
206export const resolveDatasetParent = (
207 dataset: string,
208 bucket: string | undefined,
209 location: string,
210 project: string,
211) => {
212 const fromDataset = parseDatasetName(dataset);
213 if (fromDataset) return fromDataset;
214 const fromBucket = bucket ? parseBucketName(bucket) : undefined;
215 const bucketId =
216 fromBucket?.bucketId ??
217 (bucket !== undefined && bucket.length > 0
218 ? lastSegment(bucket)
219 : DEFAULT_BUCKET_ID);
220 const resolvedLocation = fromBucket?.location ?? location;
221 const resolvedProject = fromBucket?.project ?? project;
222 const datasetId =
223 dataset.length > 0 ? lastSegment(dataset) : DEFAULT_DATASET_ID;
224 return {
225 name: datasetResourceName(
226 resolvedProject,
227 resolvedLocation,
228 bucketId,
229 datasetId,
230 ),
231 project: resolvedProject,
232 location: resolvedLocation,
233 bucketId,
234 datasetId,
235 };
236};
237
238export const listLocationIds = (project: string, region: string) =>
239 observability.listProjectsLocations
240 .pages({
241 name: `projects/${project}`,
242 pageSize: 200,
243 })
244 .pipe(
245 Stream.flatMap((page) => Stream.fromIterable(page.locations ?? [])),
246 Stream.map(
247 (location) => location.locationId ?? lastSegment(location.name ?? ""),
248 ),
249 Stream.filter((id) => id.length > 0),
250 Stream.runCollect,
251 Effect.map((chunk) => {
252 const ids = new Set(Array.from(chunk));
253 ids.add(DEFAULT_TRACE_LOCATION);
254 ids.add(region);
255 return [...ids];
256 }),
257 Effect.catchTag("NotFound", () =>
258 Effect.succeed([DEFAULT_TRACE_LOCATION, region]),
259 ),
260 );
261
262const listBucketsAt = (parent: string) =>
263 observability.listProjectsLocationsBuckets
264 .pages({
265 parent,
266 pageSize: 1000,
267 })
268 .pipe(
269 Stream.flatMap((page) => Stream.fromIterable(page.buckets ?? [])),
270 Stream.filter((bucket) => (bucket.name ?? "").length > 0),
271 Stream.runCollect,
272 Effect.map((chunk) => Array.from(chunk)),
273 Effect.catchTag("NotFound", () =>
274 Effect.succeed([] as observability.Bucket[]),
275 ),
276 );
277
278export const listProjectBuckets = () =>
279 Effect.gen(function* () {
280 const env = yield* GcpEnvironment.current;
281 const wildcard = yield* listBucketsAt(
282 `projects/${env.project}/locations/-`,
283 );
284 if (wildcard.length > 0) return wildcard;
285 const locations = yield* listLocationIds(env.project, env.region);
286 const pages = yield* Effect.forEach(
287 locations,
288 (location) =>
289 listBucketsAt(`projects/${env.project}/locations/${location}`),
290 { concurrency: 4 },
291 );
292 const byName = new Map<string, observability.Bucket>();
293 for (const bucket of pages.flat()) {
294 if (bucket.name) byName.set(bucket.name, bucket);
295 }
296 return [...byName.values()];
297 });
298
299const listDatasetsAt = (parent: string) =>
300 observability.listProjectsLocationsBucketsDatasets
301 .pages({
302 parent,
303 pageSize: 1000,
304 })
305 .pipe(
306 Stream.flatMap((page) => Stream.fromIterable(page.datasets ?? [])),
307 Stream.filter((dataset) => (dataset.name ?? "").length > 0),
308 Stream.runCollect,
309 Effect.map((chunk) => Array.from(chunk)),
310 Effect.catchTag("NotFound", () =>
311 Effect.succeed([] as observability.Dataset[]),
312 ),
313 );
314
315export const listProjectDatasets = () =>
316 Effect.gen(function* () {
317 const buckets = yield* listProjectBuckets();
318 const pages = yield* Effect.forEach(
319 buckets,
320 (bucket) => listDatasetsAt(bucket.name!),
321 { concurrency: 4 },
322 );
323 return pages.flat();
324 });
325
326export const listLinksAt = (parent: string) =>
327 observability.listProjectsLocationsBucketsDatasetsLinks
328 .pages({
329 parent,
330 pageSize: 1000,
331 })
332 .pipe(
333 Stream.flatMap((page) => Stream.fromIterable(page.links ?? [])),
334 Stream.runCollect,
335 Effect.map((chunk) => Array.from(chunk)),
336 Effect.catchTag("NotFound", () =>
337 Effect.succeed([] as observability.Link[]),
338 ),
339 );