1import * as xray from "@distilled.cloud/aws/xray";
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 {
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasAlchemyTags,
15} from "../../Tags.ts";
16import type { Providers } from "../Providers.ts";
17
18export interface GroupProps {
19 /**
20 * Name of the group (1-32 characters). `Default` is reserved by X-Ray for
21 * the built-in group that matches all traces.
22 *
23 * Changing the name replaces the group.
24 * @default ${app}-${stage}-${id}
25 */
26 groupName?: string;
27 /**
28 * The filter expression defining the criteria by which traces belong to
29 * the group, e.g. `service("my-api") AND responsetime > 2`.
30 */
31 filterExpression: string;
32 /**
33 * Whether to enable insights for the group. Insights detect anomalies in
34 * the group's traces.
35 * @default false
36 */
37 insightsEnabled?: boolean;
38 /**
39 * Whether insights should generate EventBridge notifications. Requires
40 * `insightsEnabled: true`.
41 * @default false
42 */
43 notificationsEnabled?: boolean;
44 /**
45 * Tags to apply to the group. Merged with internal Alchemy tags.
46 */
47 tags?: Record<string, string>;
48}
49
50export interface Group extends Resource<
51 "AWS.XRay.Group",
52 GroupProps,
53 {
54 /**
55 * Name of the group.
56 */
57 groupName: string;
58 /**
59 * ARN of the group.
60 */
61 groupArn: string;
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * An AWS X-Ray group that collects traces matching a filter expression, for
69 * focused service maps, analytics, and insights.
70 * ### Creating Groups
71 * **Example:** Group traces for one service
72 * ```typescript
73 * import * as XRay from "alchemy/AWS/XRay";
74 *
75 * const group = yield* XRay.Group("ApiGroup", {
76 * filterExpression: 'service("my-api")',
77 * });
78 * ```
79 *
80 * **Example:** Group slow requests with insights enabled
81 * ```typescript
82 * const group = yield* XRay.Group("SlowRequests", {
83 * filterExpression: "responsetime > 2",
84 * insightsEnabled: true,
85 * notificationsEnabled: true,
86 * });
87 * ```
88 *
89 * @resource
90 */
91export const Group = Resource<Group>("AWS.XRay.Group");
92
93/**
94 * Raised when a `Group` is configured with the reserved group name
95 * `Default`, which X-Ray uses for the built-in group matching all traces.
96 */
97export class XRayReservedGroupName extends Data.TaggedError(
98 "XRayReservedGroupName",
99)<{ message: string }> {}
100
101const validateGroupName = (props: Pick<GroupProps, "groupName">) =>
102 props.groupName === "Default"
103 ? Effect.fail(
104 new XRayReservedGroupName({
105 message:
106 '"Default" is reserved for the built-in X-Ray group — choose another groupName.',
107 }),
108 )
109 : Effect.void;
110
111export const GroupProvider = () =>
112 Provider.effect(
113 Group,
114 Effect.gen(function* () {
115 const createGroupName = Effect.fn(function* (
116 id: string,
117 props: Pick<GroupProps, "groupName">,
118 ) {
119 // X-Ray group names are limited to 32 characters.
120 return (
121 props.groupName ?? (yield* createPhysicalName({ id, maxLength: 32 }))
122 );
123 });
124
125 const desiredInsights = (props: GroupProps) => ({
126 InsightsEnabled: props.insightsEnabled ?? false,
127 NotificationsEnabled: props.notificationsEnabled ?? false,
128 });
129
130 const observeGroup = (groupName: string) =>
131 xray.getGroup({ GroupName: groupName }).pipe(
132 Effect.map((r) => r.Group),
133 // Typed synthetic tag for a missing group.
134 Effect.catchTag("GroupNotFound", () => Effect.succeed(undefined)),
135 );
136
137 const observedTags = (groupArn: string) =>
138 xray.listTagsForResource.items({ ResourceARN: groupArn }).pipe(
139 Stream.runCollect,
140 Effect.map((chunk) =>
141 Object.fromEntries(Array.from(chunk).map((t) => [t.Key, t.Value])),
142 ),
143 Effect.catchTag("ResourceNotFoundException", () =>
144 Effect.succeed({} as Record<string, string>),
145 ),
146 );
147
148 return Group.Provider.of({
149 stables: ["groupName", "groupArn"],
150 list: () =>
151 Effect.gen(function* () {
152 const groups = yield* xray.getGroups
153 .items({})
154 .pipe(Stream.runCollect);
155 return Array.from(groups).flatMap((group) =>
156 group.GroupName && group.GroupARN && group.GroupName !== "Default"
157 ? [{ groupName: group.GroupName, groupArn: group.GroupARN }]
158 : [],
159 );
160 }),
161 read: Effect.fn(function* ({ id, olds, output }) {
162 const groupName =
163 output?.groupName ?? (yield* createGroupName(id, olds ?? {}));
164 const found = yield* observeGroup(groupName);
165 if (!found?.GroupARN) return undefined;
166 const attrs = { groupName, groupArn: found.GroupARN };
167 const tags = yield* observedTags(found.GroupARN);
168 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
169 }),
170 diff: Effect.fn(function* ({ id, news, olds }) {
171 if (!isResolved(news)) return undefined;
172 yield* validateGroupName(news ?? {});
173 const oldName = yield* createGroupName(id, olds ?? {});
174 const newName = yield* createGroupName(id, news ?? {});
175 if (oldName !== newName) {
176 return { action: "replace" } as const;
177 }
178 // fall through: engine default update logic for mutable fields
179 }),
180 reconcile: Effect.fn(function* ({ id, news, output, session }) {
181 yield* validateGroupName(news);
182 const groupName =
183 output?.groupName ?? (yield* createGroupName(id, news));
184 const insights = desiredInsights(news);
185 const internalTags = yield* createInternalTags(id);
186 const desiredTags = { ...news.tags, ...internalTags };
187
188 // 1. OBSERVE — cloud state is authoritative.
189 let live = yield* observeGroup(groupName);
190
191 // 2. ENSURE — create when missing; a concurrent create surfaces as
192 // the typed GroupAlreadyExists tag, which we treat as a race
193 // and re-observe.
194 if (live === undefined) {
195 live = yield* xray
196 .createGroup({
197 GroupName: groupName,
198 FilterExpression: news.filterExpression,
199 InsightsConfiguration: insights,
200 Tags: createTagsList(desiredTags),
201 })
202 .pipe(
203 Effect.map((r) => r.Group),
204 Effect.catchTag("GroupAlreadyExists", () =>
205 observeGroup(groupName),
206 ),
207 );
208 }
209
210 // 3. SYNC — diff the OBSERVED filter expression and insights
211 // configuration against the desired state; update only on drift.
212 const observedInsights = live?.InsightsConfiguration ?? {};
213 const inSync =
214 live !== undefined &&
215 live.FilterExpression === news.filterExpression &&
216 (observedInsights.InsightsEnabled ?? false) ===
217 insights.InsightsEnabled &&
218 (observedInsights.NotificationsEnabled ?? false) ===
219 insights.NotificationsEnabled;
220 if (!inSync) {
221 live = yield* xray
222 .updateGroup({
223 GroupName: groupName,
224 FilterExpression: news.filterExpression,
225 InsightsConfiguration: insights,
226 })
227 .pipe(Effect.map((r) => r.Group));
228 }
229
230 const groupArn = live?.GroupARN ?? output?.groupArn;
231
232 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
233 // converges (create-time Tags only apply on first create).
234 if (groupArn !== undefined) {
235 const currentTags = yield* observedTags(groupArn);
236 const { upsert, removed } = diffTags(currentTags, desiredTags);
237 if (upsert.length > 0) {
238 yield* xray.tagResource({ ResourceARN: groupArn, Tags: upsert });
239 }
240 if (removed.length > 0) {
241 yield* xray.untagResource({
242 ResourceARN: groupArn,
243 TagKeys: removed,
244 });
245 }
246 }
247
248 yield* session.note(groupName);
249 return { groupName, groupArn: groupArn! };
250 }),
251 delete: Effect.fn(function* ({ output }) {
252 yield* xray.deleteGroup({ GroupName: output.groupName }).pipe(
253 // Typed synthetic tag for a missing group — idempotent delete.
254 Effect.catchTag("GroupNotFound", () => Effect.void),
255 );
256 }),
257 });
258 }),
259 );