1import * as cloudwatch from "@distilled.cloud/aws/cloudwatch";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import type { Input } from "../../Input.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment, type AccountID } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import type { RegionID } from "../Region.ts";
14import {
15 createName,
16 readResourceTags,
17 retryConcurrent,
18 updateResourceTags,
19} from "./common.ts";
20
21export type InsightRuleName = string;
22export type InsightRuleArn =
23 `arn:aws:cloudwatch:${RegionID}:${AccountID}:insight-rule/${string}`;
24
25export interface CloudWatchLogRuleFilter {
26 Match: string;
27 In: string[];
28}
29
30export interface CloudWatchLogRuleDefinition {
31 Schema: {
32 Name: "CloudWatchLogRule";
33 Version: 1;
34 };
35 LogGroupNames?: string[];
36 LogGroupARNs?: string[];
37 LogFormat: "JSON" | "CLF" | (string & {});
38 Contribution: {
39 Keys: string[];
40 ValueOf?: string;
41 Filters?: CloudWatchLogRuleFilter[];
42 };
43 AggregateOn: "Count" | "Sum" | (string & {});
44}
45
46export interface InsightRuleProps extends Omit<
47 cloudwatch.PutInsightRuleInput,
48 "RuleDefinition" | "RuleName" | "Tags"
49> {
50 /**
51 * Name of the insight rule. If omitted, a unique name is generated.
52 */
53 name?: InsightRuleName;
54 /**
55 * Optional tags to apply to the insight rule.
56 */
57 tags?: Record<string, string>;
58 /**
59 * Typed Contributor Insights rule definition. The provider serializes this
60 * object to the JSON string expected by the CloudWatch API.
61 */
62 RuleDefinition?: CloudWatchLogRuleDefinition;
63}
64
65export interface InsightRule extends Resource<
66 "AWS.CloudWatch.InsightRule",
67 InsightRuleProps,
68 {
69 /** Physical name of the insight rule. */
70 ruleName: InsightRuleName;
71 /** ARN of the insight rule. */
72 ruleArn: InsightRuleArn;
73 /** Current state of the rule (`ENABLED` or `DISABLED`). */
74 state: string | undefined;
75 /** The full InsightRule description as last read from CloudWatch. */
76 insightRule: cloudwatch.InsightRule;
77 /** Tags on the insight rule, including the internal Alchemy ownership tags. */
78 tags: Record<string, string>;
79 },
80 never,
81 Providers
82> {}
83
84/**
85 * A CloudWatch Contributor Insights rule — analyzes log group entries to
86 * surface the top-N contributors (IPs, user IDs, …) to a metric derived
87 * from structured logs.
88 * ### Creating Insight Rules
89 * **Example:** Rule Definition
90 * ```typescript
91 * const rule = yield* InsightRule("TopContributors", {
92 * RuleState: "ENABLED",
93 * RuleDefinition: {
94 * Schema: {
95 * Name: "CloudWatchLogRule",
96 * Version: 1,
97 * },
98 * LogGroupNames: ["/my-app/access-logs"],
99 * LogFormat: "JSON",
100 * Contribution: {
101 * Keys: ["$.ip"],
102 * },
103 * AggregateOn: "Count",
104 * },
105 * });
106 * ```
107 *
108 * ### Reading Reports at Runtime
109 * **Example:** Fetch the Rule's Top Contributors from a Function
110 * ```typescript
111 * // init — bind the rule to the function (see GetInsightRuleReport)
112 * const getInsightRuleReport = yield* AWS.CloudWatch.GetInsightRuleReport(rule);
113 *
114 * // runtime
115 * const now = yield* Effect.sync(() => Date.now());
116 * const report = yield* getInsightRuleReport({
117 * StartTime: new Date(now - 3_600_000),
118 * EndTime: new Date(now),
119 * Period: 300,
120 * });
121 * ```
122 *
123 * @resource
124 */
125export const InsightRule = Resource<InsightRule>("AWS.CloudWatch.InsightRule");
126
127const failureMessage = (failures: cloudwatch.PartialFailure[] | undefined) =>
128 (failures ?? [])
129 .map(
130 (failure) =>
131 `${failure.FailureResource ?? "unknown"}: ${failure.FailureCode ?? failure.ExceptionType ?? "failed"}`,
132 )
133 .join(", ");
134
135const toPutInsightRuleInput = ({
136 RuleDefinition,
137 ...input
138}: InsightRuleProps): cloudwatch.PutInsightRuleInput => ({
139 ...input,
140 RuleDefinition: RuleDefinition ? JSON.stringify(RuleDefinition) : undefined,
141});
142
143export const InsightRuleProvider = () =>
144 Provider.effect(
145 InsightRule,
146 Effect.gen(function* () {
147 const createRuleName = (id: string, props: { name?: string } = {}) =>
148 createName(id, props.name, 255);
149
150 const ruleArn = (name: string) =>
151 AWSEnvironment.current.pipe(
152 Effect.map(
153 (env) =>
154 `arn:aws:cloudwatch:${env.region}:${env.accountId}:insight-rule/${name}` as InsightRuleArn,
155 ),
156 );
157
158 const readInsightRule = Effect.fn(function* (name: string) {
159 const insightRule = yield* cloudwatch.describeInsightRules
160 .pages({})
161 .pipe(
162 Stream.mapEffect(
163 Effect.fn(function* (page) {
164 return page.InsightRules?.find(
165 (candidate) => candidate.Name === name,
166 );
167 }),
168 ),
169 Stream.filter((candidate) => candidate !== undefined),
170 Stream.runHead,
171 Effect.map(Option.getOrUndefined),
172 );
173
174 if (!insightRule?.Name) {
175 return undefined;
176 }
177
178 const arn = yield* ruleArn(insightRule.Name);
179 const tags = yield* readResourceTags(arn).pipe(
180 Effect.catchTag("ResourceNotFoundException", () =>
181 Effect.succeed({}),
182 ),
183 );
184
185 return {
186 ruleName: insightRule.Name,
187 ruleArn: arn,
188 state: insightRule.State,
189 insightRule,
190 tags,
191 };
192 });
193
194 return {
195 stables: ["ruleName", "ruleArn"],
196 diff: Effect.fn(function* ({
197 id,
198 olds = {},
199 news = {} as Input<InsightRuleProps>,
200 }) {
201 if (!isResolved(news)) return undefined;
202 const oldName = yield* createRuleName(id, olds);
203 const newName = yield* createRuleName(id, news);
204
205 if (oldName !== newName) {
206 return { action: "replace" } as const;
207 }
208 }),
209 read: Effect.fn(function* ({ id, olds, output }) {
210 const name =
211 output?.ruleName ?? (yield* createRuleName(id, olds ?? {}));
212 const state = yield* readInsightRule(name);
213 if (!state) return undefined;
214 return (yield* hasAlchemyTags(id, state.tags))
215 ? state
216 : Unowned(state);
217 }),
218 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
219 // Observe — pin the physical name from `output` if present;
220 // otherwise derive from desired props. Read whatever exists in
221 // CloudWatch so we have a baseline for tag-diffing on adoption.
222 const name = output?.ruleName ?? (yield* createRuleName(id, news));
223 const existing = yield* readInsightRule(name);
224
225 // Ensure — `putInsightRule` is an upsert; sending the full
226 // desired config every reconcile converges the cloud.
227 yield* retryConcurrent(
228 cloudwatch.putInsightRule({
229 ...toPutInsightRuleInput(news),
230 RuleName: name,
231 }),
232 );
233
234 // Sync tags — diff against `olds.tags` when we have prior state,
235 // otherwise fall back to what we observed. Adoption flows take
236 // the latter path.
237 const tags = yield* updateResourceTags({
238 id,
239 resourceArn: yield* ruleArn(name),
240 olds: olds?.tags ?? existing?.tags,
241 news: news.tags,
242 });
243
244 yield* session.note(yield* ruleArn(name));
245
246 const state = yield* readInsightRule(name);
247 if (!state) {
248 return yield* Effect.fail(
249 new Error(`failed to read reconciled insight rule '${name}'`),
250 );
251 }
252
253 return {
254 ...state,
255 tags,
256 };
257 }),
258 list: () =>
259 Effect.gen(function* () {
260 // Enumerate every Contributor Insights rule in the account/region
261 // by exhaustively paginating `describeInsightRules` (items live
262 // under the `InsightRules` field). ARNs are reconstructed from the
263 // ambient region/account since the API returns names only.
264 const { accountId, region } = yield* AWSEnvironment.current;
265 const rules = yield* cloudwatch.describeInsightRules.pages({}).pipe(
266 Stream.runCollect,
267 Effect.map((chunk) =>
268 Array.from(chunk).flatMap((page) =>
269 (page.InsightRules ?? []).filter(
270 (
271 candidate,
272 ): candidate is typeof candidate & {
273 Name: string;
274 } =>
275 candidate.Name != null &&
276 // Rules owned by another service reject
277 // DeleteInsightRules with AccessDenied. DynamoDB
278 // Contributor Insights rules are the pathological case:
279 // they report `ManagedRule: false` yet still can only
280 // be removed through DynamoDB (verified live), so match
281 // both the flag and the documented name prefix. Keep
282 // them out of enumeration for account-wide teardown
283 // (nuke).
284 candidate.ManagedRule !== true &&
285 !candidate.Name.startsWith(
286 "DynamoDBContributorInsights-",
287 ),
288 ),
289 ),
290 ),
291 );
292
293 const attrs: InsightRule["Attributes"][] = yield* Effect.forEach(
294 rules,
295 (insightRule) => {
296 const arn =
297 `arn:aws:cloudwatch:${region}:${accountId}:insight-rule/${insightRule.Name}` as InsightRuleArn;
298 return readResourceTags(arn).pipe(
299 Effect.catchTag("ResourceNotFoundException", () =>
300 Effect.succeed({}),
301 ),
302 Effect.map((tags) => ({
303 ruleName: insightRule.Name,
304 ruleArn: arn,
305 state: insightRule.State,
306 insightRule,
307 tags,
308 })),
309 );
310 },
311 { concurrency: 10 },
312 );
313 return attrs;
314 }),
315 delete: Effect.fn(function* ({ output }) {
316 const existing = yield* readInsightRule(output.ruleName);
317 if (!existing) {
318 return;
319 }
320
321 const response = yield* retryConcurrent(
322 cloudwatch.deleteInsightRules({
323 RuleNames: [output.ruleName],
324 }),
325 );
326
327 if ((response.Failures?.length ?? 0) > 0) {
328 return yield* Effect.fail(
329 new Error(
330 `failed to delete insight rule '${output.ruleName}': ${failureMessage(response.Failures)}`,
331 ),
332 );
333 }
334 }),
335 };
336 }),
337 );