1import * as amp from "@distilled.cloud/aws/amp";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import { normalizeAmpLogGroupArn } from "./internal.ts";
9
10export interface ScraperComponentConfig {
11 /**
12 * The scraper component the logging applies to (e.g. `"SERVICE_DISCOVERY"`,
13 * `"COLLECTOR"`, `"EXPORTER"`).
14 */
15 type: string;
16 /**
17 * Component-specific options (e.g. `{ loggingLevel: "DEBUG" }`).
18 */
19 options?: Record<string, string>;
20}
21
22export interface ScraperLoggingConfigurationProps {
23 /**
24 * Id of the AMP scraper whose component logs are shipped. A scraper has
25 * at most one logging configuration. Changing the scraper replaces the
26 * configuration.
27 */
28 scraperId: string;
29 /**
30 * ARN of the CloudWatch Logs log group that receives the scraper's
31 * component logs. AMP expects the ARN with a trailing `:*` — one is
32 * appended automatically when missing.
33 */
34 logGroupArn: string;
35 /**
36 * Per-component logging configuration. If omitted, the service default
37 * component set is logged.
38 */
39 components?: ScraperComponentConfig[];
40}
41
42export interface ScraperLoggingConfiguration extends Resource<
43 "AWS.AMP.ScraperLoggingConfiguration",
44 ScraperLoggingConfigurationProps,
45 {
46 scraperId: string;
47 logGroupArn: string;
48 status: string;
49 },
50 never,
51 Providers
52> {}
53
54/**
55 * The logging configuration of an Amazon Managed Service for Prometheus
56 * scraper — ships the scraper's component logs (service discovery,
57 * collection, export) to a CloudWatch Logs log group. A scraper has at most
58 * one.
59 *
60 * ### Creating a Scraper Logging Configuration
61 * **Example:** Ship Scraper Logs to CloudWatch Logs
62 * ```typescript
63 * const logs = yield* Logs.LogGroup("ScraperLogs", {
64 * logGroupName: "/aws/vendedlogs/prometheus/scraper",
65 * });
66 * const logging = yield* AMP.ScraperLoggingConfiguration("ScraperLogging", {
67 * scraperId: scraper.scraperId,
68 * logGroupArn: logs.logGroupArn,
69 * });
70 * ```
71 *
72 * @resource
73 */
74export const ScraperLoggingConfiguration =
75 Resource<ScraperLoggingConfiguration>("AWS.AMP.ScraperLoggingConfiguration");
76
77export const ScraperLoggingConfigurationProvider = () =>
78 Provider.effect(
79 ScraperLoggingConfiguration,
80 Effect.gen(function* () {
81 /** Describe the configuration; typed not-found → undefined. */
82 const describe = Effect.fn(function* (scraperId: string) {
83 return yield* amp
84 .describeScraperLoggingConfiguration({ scraperId })
85 .pipe(
86 Effect.catchTag("ResourceNotFoundException", () =>
87 Effect.succeed(undefined),
88 ),
89 );
90 });
91
92 const toAttrs = (
93 config: amp.DescribeScraperLoggingConfigurationResponse,
94 ) => ({
95 scraperId: config.scraperId,
96 logGroupArn: config.loggingDestination.cloudWatchLogs.logGroupArn,
97 status: config.status.statusCode,
98 });
99
100 /** Canonical component list for observed-vs-desired comparison. */
101 const canonicalComponents = (
102 components: {
103 type: string;
104 config?: { options?: Record<string, string | undefined> };
105 }[],
106 ) =>
107 JSON.stringify(
108 components
109 .map((component) => ({
110 type: component.type,
111 options: Object.fromEntries(
112 Object.entries(component.config?.options ?? {})
113 .filter(
114 (kv): kv is [string, string] => typeof kv[1] === "string",
115 )
116 .sort(([a], [b]) => a.localeCompare(b)),
117 ),
118 }))
119 .sort((a, b) => a.type.localeCompare(b.type)),
120 );
121
122 const toWireComponents = (
123 components: ScraperComponentConfig[] | undefined,
124 ): amp.ScraperComponent[] | undefined =>
125 components?.map((component) => ({
126 type: component.type,
127 config:
128 component.options !== undefined
129 ? { options: component.options }
130 : undefined,
131 }));
132
133 return {
134 stables: ["scraperId"],
135
136 diff: Effect.fn(function* ({ olds, news }) {
137 if (!isResolved(news)) return undefined;
138 if (olds?.scraperId !== news.scraperId) {
139 return { action: "replace" } as const;
140 }
141 }),
142
143 read: Effect.fn(function* ({ olds, output }) {
144 const scraperId = output?.scraperId ?? olds?.scraperId;
145 if (!scraperId) return undefined;
146 const config = yield* describe(scraperId);
147 if (config === undefined) return undefined;
148 // Scraper logging configurations are not taggable — ownership is
149 // implied by the owned parent scraper.
150 return toAttrs(config);
151 }),
152
153 reconcile: Effect.fn(function* ({ news, session }) {
154 const scraperId = news!.scraperId;
155 const desiredArn = normalizeAmpLogGroupArn(news!.logGroupArn);
156 const desiredComponents = toWireComponents(news!.components);
157
158 // 1. Observe — the live configuration is authoritative.
159 const observed = yield* describe(scraperId);
160
161 // 2/3. Ensure + sync — `updateScraperLoggingConfiguration` is an
162 // upsert (there is no separate create operation); apply it only
163 // when the destination or components drift.
164 const drifts =
165 observed === undefined ||
166 observed.loggingDestination.cloudWatchLogs.logGroupArn !==
167 desiredArn ||
168 (desiredComponents !== undefined &&
169 canonicalComponents(desiredComponents) !==
170 canonicalComponents(observed.scraperComponents));
171
172 if (drifts) {
173 yield* amp
174 .updateScraperLoggingConfiguration({
175 scraperId,
176 loggingDestination: {
177 cloudWatchLogs: { logGroupArn: desiredArn },
178 },
179 scraperComponents: desiredComponents,
180 })
181 .pipe(
182 // The scraper (or a previous logging update) may still be
183 // transitioning — retry conflicts briefly.
184 Effect.retry({
185 while: (e) => e._tag === "ConflictException",
186 schedule: Schedule.max([
187 Schedule.fixed("6 seconds"),
188 Schedule.recurs(15),
189 ]),
190 }),
191 );
192 }
193
194 // Bounded best-effort wait toward ACTIVE — a still-transitioning
195 // configuration converges on a later reconcile.
196 const fresh = yield* amp
197 .describeScraperLoggingConfiguration({ scraperId })
198 .pipe(
199 Effect.repeat({
200 schedule: Schedule.max([
201 Schedule.fixed("3 seconds"),
202 Schedule.recurs(20),
203 ]),
204 until: (c): boolean => c.status.statusCode === "ACTIVE",
205 }),
206 );
207 yield* session.note(scraperId);
208 return toAttrs(fresh);
209 }),
210
211 delete: Effect.fn(function* ({ output }) {
212 yield* amp
213 .deleteScraperLoggingConfiguration({
214 scraperId: output.scraperId,
215 })
216 .pipe(
217 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
218 Effect.retry({
219 while: (e) => e._tag === "ConflictException",
220 schedule: Schedule.max([
221 Schedule.fixed("3 seconds"),
222 Schedule.recurs(20),
223 ]),
224 }),
225 );
226 }),
227
228 // Singleton sub-resource keyed by its parent scraper.
229 list: () => Effect.succeed([]),
230 };
231 }),
232 );