1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags } from "../../Tags.ts";
12import { toWireDays } from "../../Util/Duration.ts";
13import type { AccountID } from "../Environment.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import type { Providers } from "../Providers.ts";
16import type { RegionID } from "../Region.ts";
17
18export type LogGroupName = string;
19export type LogGroupArn =
20 `arn:aws:logs:${RegionID}:${AccountID}:log-group:${LogGroupName}`;
21export type LogGroupClass = logs.LogGroupClass;
22
23export interface LogGroupProps {
24 /**
25 * Name of the log group. If omitted, a unique name is generated.
26 */
27 logGroupName?: string;
28 /**
29 * How long CloudWatch retains log events. If omitted, logs are kept
30 * indefinitely. Accepts any `Duration.Input` (e.g. `"7 days"`,
31 * `Duration.days(7)`; a bare number is milliseconds); the wire unit is
32 * whole days (`retentionInDays`) and must resolve to one of the retention
33 * values CloudWatch accepts (1, 3, 5, 7, 14, 30, ...).
34 */
35 retention?: Duration.Input;
36 /**
37 * Optional KMS key identifier used to encrypt the log group.
38 */
39 kmsKeyId?: string;
40 /**
41 * Log class for the log group. Changing this value replaces the log group.
42 * @default "STANDARD"
43 */
44 logGroupClass?: LogGroupClass;
45 /**
46 * Whether deletion protection is enabled for the log group.
47 * @default false
48 */
49 deletionProtectionEnabled?: boolean;
50 /**
51 * User-defined tags to apply to the log group.
52 */
53 tags?: Record<string, string>;
54}
55
56export interface LogGroup extends Resource<
57 "AWS.Logs.LogGroup",
58 LogGroupProps,
59 {
60 logGroupName: LogGroupName;
61 logGroupArn: LogGroupArn;
62 retentionInDays?: number;
63 kmsKeyId?: string;
64 logGroupClass: LogGroupClass;
65 deletionProtectionEnabled: boolean;
66 tags: Record<string, string>;
67 },
68 never,
69 Providers
70> {}
71
72/**
73 * A CloudWatch Logs log group — the container for log streams and the unit
74 * that retention, encryption, metric filters, and subscriptions attach to.
75 * ### Creating Log Groups
76 * **Example:** ECS Task Log Group
77 * ```typescript
78 * const logs = yield* LogGroup("TaskLogs", {
79 * retention: "7 days",
80 * });
81 * ```
82 *
83 * **Example:** Encrypted Log Group with Deletion Protection
84 * ```typescript
85 * const key = yield* AWS.KMS.Key("LogsKey");
86 * const logs = yield* LogGroup("AuditLogs", {
87 * retention: "30 days",
88 * kmsKeyId: key.keyArn,
89 * deletionProtectionEnabled: true,
90 * });
91 * ```
92 *
93 * ### Writing Custom Log Events
94 * Declare a `LogStream` and use the `PutLogEvents` binding inside a Lambda
95 * function (or the batching `LogEventSink` for high-volume streams).
96 *
97 * **Example:** Custom Audit Trail from a Lambda Function
98 * ```typescript
99 * // init
100 * const logGroup = yield* AWS.Logs.LogGroup("AuditLogs", {
101 * retention: "30 days",
102 * });
103 * const stream = yield* AWS.Logs.LogStream("AuditStream", {
104 * logGroupName: logGroup.logGroupName,
105 * logStreamName: "audit",
106 * });
107 * const putLogEvents = yield* AWS.Logs.PutLogEvents(logGroup);
108 *
109 * // runtime
110 * yield* putLogEvents({
111 * logStreamName: "audit",
112 * logEvents: [{ timestamp, message: "user.login id=123" }],
113 * });
114 * ```
115 *
116 * ### Consuming Log Events
117 * **Example:** React to Error Logs
118 * ```typescript
119 * // Subscribe a Lambda handler to matching events (creates the
120 * // subscription filter + invoke permission automatically).
121 * yield* AWS.Logs.consumeLogEvents(
122 * logGroup,
123 * { filterPattern: "?ERROR ?Error" },
124 * (events) =>
125 * Stream.runForEach(events, (event) =>
126 * Effect.log(`${event.logStream}: ${event.message}`),
127 * ),
128 * );
129 * ```
130 *
131 * ### Metrics
132 * **Example:** Count Errors with a Metric Filter
133 * ```typescript
134 * yield* AWS.Logs.MetricFilter("ErrorCount", {
135 * logGroupName: logGroup.logGroupName,
136 * filterPattern: '"ERROR"',
137 * metricTransformations: [{
138 * metricName: "ErrorCount",
139 * metricNamespace: "MyApp",
140 * metricValue: "1",
141 * }],
142 * });
143 * ```
144 *
145 * @resource
146 */
147export const LogGroup = Resource<LogGroup>("AWS.Logs.LogGroup");
148
149export const LogGroupProvider = () =>
150 Provider.effect(
151 LogGroup,
152 Effect.gen(function* () {
153 const toLogGroupName = (
154 id: string,
155 props: { logGroupName?: string } = {},
156 ) =>
157 props.logGroupName
158 ? Effect.succeed(props.logGroupName)
159 : createPhysicalName({ id, maxLength: 512 });
160 const toLogGroupClass = (
161 props: { logGroupClass?: LogGroupClass } = {},
162 ): LogGroupClass => props.logGroupClass ?? "STANDARD";
163 // describeLogGroups returns ARNs with a trailing `:*` while reconcile
164 // constructs them without — normalize so refresh/adoption never reports
165 // phantom drift and interpolated IAM ARNs (`${arn}:*`) stay correct.
166 const normalizeLogGroupArn = (arn: string): LogGroupArn =>
167 (arn.endsWith(":*") ? arn.slice(0, -2) : arn) as LogGroupArn;
168 const observe = Effect.fn(function* (logGroupName: string) {
169 return yield* logs.describeLogGroups
170 .items({ logGroupNamePrefix: logGroupName })
171 .pipe(
172 Stream.filter((group) => group.logGroupName === logGroupName),
173 Stream.runHead,
174 Effect.map(Option.getOrUndefined),
175 );
176 });
177
178 return {
179 stables: ["logGroupArn", "logGroupName"],
180 // AWS account/region collection: paginate `describeLogGroups`
181 // exhaustively, then hydrate each group's tags via
182 // `listTagsForResource` (bounded concurrency) into the exact `read`
183 // Attributes shape.
184 list: () =>
185 Effect.gen(function* () {
186 const { accountId, region } = yield* AWSEnvironment.current;
187 const groups = yield* logs.describeLogGroups.pages({}).pipe(
188 Stream.runCollect,
189 Effect.map((chunk) =>
190 Array.from(chunk).flatMap((page) => page.logGroups ?? []),
191 ),
192 );
193
194 return yield* Effect.forEach(
195 groups.filter(
196 (
197 group,
198 ): group is logs.LogGroup & {
199 logGroupName: string;
200 arn: string;
201 } => group.logGroupName != null && group.arn != null,
202 ),
203 (group) =>
204 Effect.gen(function* () {
205 const tagArn =
206 `arn:aws:logs:${region}:${accountId}:log-group:${group.logGroupName}` as LogGroupArn;
207 const tags = yield* logs
208 .listTagsForResource({ resourceArn: tagArn })
209 .pipe(
210 Effect.map((r): Record<string, string> =>
211 Object.fromEntries(
212 Object.entries(r.tags ?? {}).filter(
213 (entry): entry is [string, string] =>
214 typeof entry[1] === "string",
215 ),
216 ),
217 ),
218 Effect.catchTag("ResourceNotFoundException", () =>
219 Effect.succeed({} as Record<string, string>),
220 ),
221 );
222 return {
223 logGroupName: group.logGroupName,
224 logGroupArn: normalizeLogGroupArn(group.arn),
225 retentionInDays: group.retentionInDays,
226 kmsKeyId: group.kmsKeyId,
227 logGroupClass: group.logGroupClass ?? "STANDARD",
228 deletionProtectionEnabled:
229 group.deletionProtectionEnabled ?? false,
230 tags,
231 };
232 }),
233 { concurrency: 10 },
234 );
235 }),
236 diff: Effect.fn(function* ({ id, olds, news }) {
237 if (!isResolved(news)) return;
238 if (
239 (yield* toLogGroupName(id, olds ?? {})) !==
240 (yield* toLogGroupName(id, news ?? {}))
241 ) {
242 return { action: "replace" } as const;
243 }
244 if (toLogGroupClass(olds ?? {}) !== toLogGroupClass(news ?? {})) {
245 return { action: "replace" } as const;
246 }
247 }),
248 read: Effect.fn(function* ({ id, olds, output }) {
249 const logGroupName =
250 output?.logGroupName ?? (yield* toLogGroupName(id, olds ?? {}));
251 const match = yield* observe(logGroupName);
252 if (!match?.arn) {
253 return undefined;
254 }
255 return {
256 logGroupName,
257 logGroupArn: normalizeLogGroupArn(match.arn),
258 retentionInDays: match.retentionInDays,
259 kmsKeyId: match.kmsKeyId,
260 logGroupClass: match.logGroupClass ?? "STANDARD",
261 deletionProtectionEnabled: match.deletionProtectionEnabled ?? false,
262 tags: output?.tags ?? {},
263 };
264 }),
265 reconcile: Effect.fn(function* ({ id, news, output, session }) {
266 const { accountId, region } = yield* AWSEnvironment.current;
267 const logGroupName =
268 output?.logGroupName ?? (yield* toLogGroupName(id, news));
269 // `output.logGroupArn` may be state written by an older provider
270 // version (or anything else) that persisted `describeLogGroups`'
271 // trailing `:*` form — normalize before it reaches the tagging
272 // APIs, which reject that suffix with "Invalid resourceArn".
273 const arn = normalizeLogGroupArn(
274 (output?.logGroupArn ??
275 `arn:aws:logs:${region}:${accountId}:log-group:${logGroupName}`) as LogGroupArn,
276 );
277 const internalTags = yield* createInternalTags(id);
278 const desiredTags = { ...internalTags, ...news.tags };
279 // Wire unit is whole days (retentionInDays).
280 const desiredRetention = toWireDays(news.retention);
281
282 // Observe - fetch live state. `describeLogGroups` returns
283 // retention/kms info so we can diff against desired without
284 // trusting `olds` or `output`.
285 let observed = yield* observe(logGroupName);
286
287 // Ensure - create if missing. `createLogGroup` accepts tags and
288 // kmsKeyId on first create; tolerate `ResourceAlreadyExistsException`
289 // (race with peer reconciler) and re-read.
290 if (!observed?.arn) {
291 yield* logs
292 .createLogGroup({
293 logGroupName,
294 kmsKeyId: news.kmsKeyId,
295 tags: desiredTags,
296 logGroupClass: news.logGroupClass,
297 deletionProtectionEnabled: news.deletionProtectionEnabled,
298 })
299 .pipe(
300 Effect.catchTag(
301 "ResourceAlreadyExistsException",
302 () => Effect.void,
303 ),
304 );
305 observed = yield* observe(logGroupName);
306 }
307
308 // Sync KMS key - create accepts kmsKeyId, but updates require the
309 // association API. Disassociating only affects newly ingested events.
310 const observedKmsKeyId = observed?.kmsKeyId;
311 if (news.kmsKeyId !== observedKmsKeyId) {
312 if (news.kmsKeyId === undefined) {
313 if (observedKmsKeyId !== undefined) {
314 yield* logs
315 .disassociateKmsKey({ logGroupName })
316 .pipe(
317 Effect.catchTag(
318 "ResourceNotFoundException",
319 () => Effect.void,
320 ),
321 );
322 }
323 } else {
324 yield* logs.associateKmsKey({
325 logGroupName,
326 kmsKeyId: news.kmsKeyId,
327 });
328 }
329 }
330
331 // Sync deletion protection - destroy disables it before deletion, but
332 // normal deploys should still converge the live flag to desired state.
333 const desiredDeletionProtection =
334 news.deletionProtectionEnabled ?? false;
335 const observedDeletionProtection =
336 observed?.deletionProtectionEnabled ?? false;
337 if (desiredDeletionProtection !== observedDeletionProtection) {
338 yield* logs.putLogGroupDeletionProtection({
339 logGroupIdentifier: logGroupName,
340 deletionProtectionEnabled: desiredDeletionProtection,
341 });
342 }
343
344 // Sync retention - observed to desired.
345 const observedRetention = observed?.retentionInDays;
346 if (desiredRetention !== observedRetention) {
347 if (desiredRetention === undefined) {
348 yield* logs
349 .deleteRetentionPolicy({
350 logGroupName,
351 })
352 .pipe(
353 Effect.catchTag(
354 "ResourceNotFoundException",
355 () => Effect.void,
356 ),
357 );
358 } else {
359 yield* logs.putRetentionPolicy({
360 logGroupName,
361 retentionInDays: desiredRetention,
362 });
363 }
364 }
365
366 // Sync tags - list observed tags then diff against desired so
367 // adoption rewrites ownership tags correctly.
368 const observedTags = yield* logs
369 .listTagsForResource({ resourceArn: arn })
370 .pipe(
371 Effect.map((r): Record<string, string> =>
372 Object.fromEntries(
373 Object.entries(r.tags ?? {}).filter(
374 (entry): entry is [string, string] =>
375 typeof entry[1] === "string",
376 ),
377 ),
378 ),
379 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
380 );
381 const { removed, upsert } = diffTags(observedTags, desiredTags);
382 if (upsert.length > 0) {
383 yield* logs.tagResource({
384 resourceArn: arn,
385 tags: Object.fromEntries(
386 upsert.map((tag) => [tag.Key, tag.Value]),
387 ),
388 });
389 }
390 if (removed.length > 0) {
391 yield* logs.untagResource({
392 resourceArn: arn,
393 tagKeys: removed,
394 });
395 }
396
397 yield* session.note(arn);
398
399 return {
400 logGroupName,
401 logGroupArn: arn,
402 retentionInDays: desiredRetention,
403 kmsKeyId: news.kmsKeyId,
404 logGroupClass: observed?.logGroupClass ?? toLogGroupClass(news),
405 deletionProtectionEnabled: desiredDeletionProtection,
406 tags: desiredTags,
407 };
408 }),
409 delete: Effect.fn(function* ({ output }) {
410 yield* logs
411 .putLogGroupDeletionProtection({
412 logGroupIdentifier: output.logGroupName,
413 deletionProtectionEnabled: false,
414 })
415 .pipe(
416 Effect.retry({
417 while: (error) =>
418 error._tag === "OperationAbortedException" ||
419 error._tag === "ServiceUnavailableException",
420 schedule: Schedule.exponential(100),
421 times: 8,
422 }),
423 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
424 );
425 yield* logs
426 .deleteLogGroup({
427 logGroupName: output.logGroupName,
428 })
429 .pipe(
430 Effect.retry({
431 while: (error) =>
432 error._tag === "OperationAbortedException" ||
433 error._tag === "ServiceUnavailableException",
434 schedule: Schedule.exponential(100),
435 times: 8,
436 }),
437 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
438 );
439
440 const remaining = yield* Effect.repeat(
441 logs.describeLogGroups
442 .items({ logGroupNamePrefix: output.logGroupName })
443 .pipe(
444 Stream.filter(
445 (group) => group.logGroupName === output.logGroupName,
446 ),
447 Stream.runHead,
448 Effect.map(Option.isSome),
449 ),
450 {
451 schedule: Schedule.fixed("250 millis"),
452 until: (present) => !present,
453 times: 20,
454 },
455 );
456 if (remaining) {
457 yield* Effect.die(
458 new Error(
459 `CloudWatch log group ${output.logGroupName} remained observable after delete`,
460 ),
461 );
462 }
463 }),
464 };
465 }),
466 );