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 QueryLoggingDestination {
11 /**
12 * ARN of the CloudWatch Logs log group that receives query logs. AMP
13 * expects the ARN with a trailing `:*` — one is appended automatically
14 * when missing.
15 */
16 logGroupArn: string;
17 /**
18 * Only queries that spend at least this many Query Samples Processed
19 * (QSP) are logged. Use `0` to log every query.
20 * @default 0
21 */
22 qspThreshold?: number;
23}
24
25export interface QueryLoggingConfigurationProps {
26 /**
27 * Id of the AMP workspace whose queries are logged. A workspace has at
28 * most one query logging configuration. Changing the workspace replaces
29 * the configuration.
30 */
31 workspaceId: string;
32 /**
33 * Destinations that receive the query logs (currently CloudWatch Logs
34 * log groups, each with a QSP filter threshold). Updated in place.
35 */
36 destinations: QueryLoggingDestination[];
37}
38
39export interface QueryLoggingConfiguration extends Resource<
40 "AWS.AMP.QueryLoggingConfiguration",
41 QueryLoggingConfigurationProps,
42 {
43 workspaceId: string;
44 destinations: { logGroupArn: string; qspThreshold: number }[];
45 status: string;
46 },
47 never,
48 Providers
49> {}
50
51/**
52 * The query logging configuration of an Amazon Managed Service for
53 * Prometheus workspace — ships PromQL query logs (query text, QSP cost,
54 * response code) to CloudWatch Logs. A workspace has at most one.
55 *
56 * ### Creating a Query Logging Configuration
57 * **Example:** Log Expensive Queries to CloudWatch Logs
58 * ```typescript
59 * const workspace = yield* AMP.Workspace("Metrics", {});
60 * const logs = yield* Logs.LogGroup("QueryLogs", {
61 * logGroupName: "/aws/vendedlogs/prometheus/metrics-queries",
62 * });
63 * const queryLogging = yield* AMP.QueryLoggingConfiguration("QueryLogging", {
64 * workspaceId: workspace.workspaceId,
65 * destinations: [{ logGroupArn: logs.logGroupArn, qspThreshold: 1000 }],
66 * });
67 * ```
68 *
69 * @resource
70 */
71export const QueryLoggingConfiguration = Resource<QueryLoggingConfiguration>(
72 "AWS.AMP.QueryLoggingConfiguration",
73);
74
75export const QueryLoggingConfigurationProvider = () =>
76 Provider.effect(
77 QueryLoggingConfiguration,
78 Effect.gen(function* () {
79 /** Describe the configuration; typed not-found → undefined. */
80 const describe = Effect.fn(function* (workspaceId: string) {
81 const response = yield* amp
82 .describeQueryLoggingConfiguration({ workspaceId })
83 .pipe(
84 Effect.catchTag("ResourceNotFoundException", () =>
85 Effect.succeed(undefined),
86 ),
87 );
88 return response?.queryLoggingConfiguration;
89 });
90
91 /**
92 * Bounded wait toward ACTIVE. Returns the last observed metadata —
93 * a still-transitioning configuration converges on a later reconcile.
94 */
95 const waitActive = Effect.fn(function* (workspaceId: string) {
96 return yield* amp
97 .describeQueryLoggingConfiguration({ workspaceId })
98 .pipe(
99 Effect.map((r) => r.queryLoggingConfiguration),
100 Effect.repeat({
101 schedule: Schedule.max([
102 Schedule.fixed("2 seconds"),
103 Schedule.recurs(20),
104 ]),
105 until: (c) => c.status.statusCode === "ACTIVE",
106 }),
107 );
108 });
109
110 /** Desired props → wire destinations (normalized + defaulted). */
111 const toWireDestinations = (destinations: QueryLoggingDestination[]) =>
112 destinations.map((destination) => ({
113 cloudWatchLogs: {
114 logGroupArn: normalizeAmpLogGroupArn(destination.logGroupArn),
115 },
116 filters: { qspThreshold: destination.qspThreshold ?? 0 },
117 }));
118
119 /** Canonical form for observed-vs-desired comparison. */
120 const canonical = (
121 destinations: {
122 cloudWatchLogs: { logGroupArn: string };
123 filters: { qspThreshold: number };
124 }[],
125 ) =>
126 JSON.stringify(
127 destinations
128 .map((d) => ({
129 logGroupArn: d.cloudWatchLogs.logGroupArn,
130 qspThreshold: d.filters.qspThreshold,
131 }))
132 .sort((a, b) => a.logGroupArn.localeCompare(b.logGroupArn)),
133 );
134
135 const toAttrs = (meta: amp.QueryLoggingConfigurationMetadata) => ({
136 workspaceId: meta.workspace,
137 destinations: meta.destinations.map((d) => ({
138 logGroupArn: d.cloudWatchLogs.logGroupArn,
139 qspThreshold: d.filters.qspThreshold,
140 })),
141 status: meta.status.statusCode,
142 });
143
144 return {
145 stables: ["workspaceId"],
146
147 diff: Effect.fn(function* ({ olds, news }) {
148 if (!isResolved(news)) return undefined;
149 if (olds?.workspaceId !== news.workspaceId) {
150 return { action: "replace" } as const;
151 }
152 }),
153
154 read: Effect.fn(function* ({ olds, output }) {
155 const workspaceId = output?.workspaceId ?? olds?.workspaceId;
156 if (!workspaceId) return undefined;
157 const meta = yield* describe(workspaceId);
158 if (meta === undefined) return undefined;
159 // Query logging configurations are not taggable — ownership is
160 // implied by the owned parent workspace.
161 return toAttrs(meta);
162 }),
163
164 reconcile: Effect.fn(function* ({ news, session }) {
165 const workspaceId = news!.workspaceId;
166 const desired = toWireDestinations(news!.destinations);
167
168 // 1. Observe — the live configuration is authoritative.
169 const existing = yield* describe(workspaceId);
170
171 // 2/3. Ensure + sync — create when absent (tolerating a create
172 // race), update when the destinations drift.
173 if (existing === undefined) {
174 yield* amp
175 .createQueryLoggingConfiguration({
176 workspaceId,
177 destinations: desired,
178 })
179 .pipe(
180 Effect.catchTag("ConflictException", () =>
181 amp.updateQueryLoggingConfiguration({
182 workspaceId,
183 destinations: desired,
184 }),
185 ),
186 );
187 } else if (
188 canonical([...existing.destinations]) !== canonical(desired)
189 ) {
190 yield* amp.updateQueryLoggingConfiguration({
191 workspaceId,
192 destinations: desired,
193 });
194 }
195
196 const fresh = yield* waitActive(workspaceId);
197 yield* session.note(workspaceId);
198 return toAttrs(fresh);
199 }),
200
201 delete: Effect.fn(function* ({ output }) {
202 yield* amp
203 .deleteQueryLoggingConfiguration({
204 workspaceId: output.workspaceId,
205 })
206 .pipe(
207 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
208 Effect.retry({
209 while: (e) => e._tag === "ConflictException",
210 schedule: Schedule.max([
211 Schedule.fixed("3 seconds"),
212 Schedule.recurs(20),
213 ]),
214 }),
215 );
216 }),
217
218 // Singleton sub-resource keyed by its parent workspace.
219 list: () => Effect.succeed([]),
220 };
221 }),
222 );