1import * as apprunner from "@distilled.cloud/aws/apprunner";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 isActiveStatus,
14 readAppRunnerTags,
15 syncAppRunnerTags,
16 toWireTags,
17} from "./internal.ts";
18
19/**
20 * Tracing configuration for an observability configuration.
21 */
22export interface ObservabilityTraceConfiguration {
23 /**
24 * The tracing vendor. `AWSXRAY` is the only supported vendor.
25 */
26 vendor: "AWSXRAY";
27}
28
29export interface ObservabilityConfigurationProps {
30 /**
31 * Name of the observability configuration. Must be 4-32 characters
32 * (letters, digits, hyphens, underscores). If omitted, a deterministic
33 * physical name is generated. Changing the name replaces the
34 * configuration (all revisions of the old name are deleted).
35 */
36 observabilityConfigurationName?: string;
37 /**
38 * Tracing configuration. Omit to create a configuration with tracing
39 * disabled (services referencing it emit no traces).
40 */
41 traceConfiguration?: ObservabilityTraceConfiguration;
42 /**
43 * User-defined tags for the configuration.
44 */
45 tags?: Record<string, string>;
46}
47
48export interface ObservabilityConfiguration extends Resource<
49 "AWS.AppRunner.ObservabilityConfiguration",
50 ObservabilityConfigurationProps,
51 {
52 /**
53 * Name of the observability configuration.
54 */
55 observabilityConfigurationName: string;
56 /**
57 * ARN of this observability configuration revision.
58 */
59 observabilityConfigurationArn: string;
60 /**
61 * Revision number of the configuration (revisions are immutable).
62 */
63 observabilityConfigurationRevision: number;
64 /**
65 * The configured tracing vendor, if tracing is enabled.
66 */
67 traceVendor: string | undefined;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * An AWS App Runner observability configuration — enables AWS X-Ray tracing
75 * for the App Runner services that reference it.
76 *
77 * Observability configurations are immutable revisions: changing
78 * `traceConfiguration` creates a new revision under the same name (the ARN
79 * and revision attributes change). A configuration can be shared across
80 * multiple App Runner services.
81 * ### Creating an Observability Configuration
82 * **Example:** X-Ray Tracing Configuration
83 * ```typescript
84 * const observability = yield* AppRunner.ObservabilityConfiguration("Tracing", {
85 * traceConfiguration: { vendor: "AWSXRAY" },
86 * });
87 * ```
88 *
89 * ### Using with an App Runner Service
90 * **Example:** Attach to a Service
91 * ```typescript
92 * const service = yield* AppRunner.Service("Api", {
93 * imageRepository: {
94 * imageIdentifier: "public.ecr.aws/aws-containers/hello-app-runner:latest",
95 * imageRepositoryType: "ECR_PUBLIC",
96 * port: "8000",
97 * },
98 * observabilityConfiguration: {
99 * observabilityEnabled: true,
100 * observabilityConfigurationArn:
101 * observability.observabilityConfigurationArn,
102 * },
103 * });
104 * ```
105 *
106 * @resource
107 */
108export const ObservabilityConfiguration = Resource<ObservabilityConfiguration>(
109 "AWS.AppRunner.ObservabilityConfiguration",
110);
111
112export const ObservabilityConfigurationProvider = () =>
113 Provider.effect(
114 ObservabilityConfiguration,
115 Effect.gen(function* () {
116 const toName = (id: string, props: ObservabilityConfigurationProps) =>
117 props.observabilityConfigurationName
118 ? Effect.succeed(props.observabilityConfigurationName)
119 : createPhysicalName({ id, maxLength: 32 });
120
121 /** Find the latest ACTIVE revision of a configuration by name. */
122 const findLatest = Effect.fn(function* (name: string) {
123 const page = yield* apprunner.listObservabilityConfigurations({
124 ObservabilityConfigurationName: name,
125 LatestOnly: true,
126 });
127 const summary = (page.ObservabilityConfigurationSummaryList ?? []).find(
128 (s) =>
129 s.ObservabilityConfigurationName === name &&
130 s.ObservabilityConfigurationArn !== undefined,
131 );
132 if (!summary?.ObservabilityConfigurationArn) return undefined;
133 const described = yield* apprunner
134 .describeObservabilityConfiguration({
135 ObservabilityConfigurationArn:
136 summary.ObservabilityConfigurationArn,
137 })
138 .pipe(
139 Effect.catchTag("ResourceNotFoundException", () =>
140 Effect.succeed(undefined),
141 ),
142 );
143 const config = described?.ObservabilityConfiguration;
144 return config !== undefined && isActiveStatus(config.Status)
145 ? config
146 : undefined;
147 });
148
149 const toAttrs = Effect.fn(function* (
150 config: apprunner.ObservabilityConfiguration,
151 ) {
152 if (
153 !config.ObservabilityConfigurationName ||
154 !config.ObservabilityConfigurationArn ||
155 config.ObservabilityConfigurationRevision === undefined
156 ) {
157 return yield* Effect.fail(
158 new Error(
159 "App Runner observability configuration is missing its name, ARN, or revision",
160 ),
161 );
162 }
163 return {
164 observabilityConfigurationName: config.ObservabilityConfigurationName,
165 observabilityConfigurationArn: config.ObservabilityConfigurationArn,
166 observabilityConfigurationRevision:
167 config.ObservabilityConfigurationRevision,
168 traceVendor: config.TraceConfiguration?.Vendor,
169 };
170 });
171
172 /**
173 * Delete every ACTIVE revision that shares the configuration name.
174 * `DeleteObservabilityConfiguration` has no `DeleteAllRevisions`
175 * flag (unlike auto scaling configurations), so enumerate and delete
176 * each revision. A revision still referenced by a service rejects
177 * with InvalidRequestException — retry through that window (bounded).
178 */
179 const deleteAllRevisions = Effect.fn(function* (name: string) {
180 const pages = yield* apprunner.listObservabilityConfigurations
181 .pages({
182 ObservabilityConfigurationName: name,
183 LatestOnly: false,
184 })
185 .pipe(Stream.runCollect);
186 const arns = Array.from(pages).flatMap((page) =>
187 (page.ObservabilityConfigurationSummaryList ?? [])
188 .filter((s) => s.ObservabilityConfigurationName === name)
189 .flatMap((s) =>
190 s.ObservabilityConfigurationArn !== undefined
191 ? [s.ObservabilityConfigurationArn]
192 : [],
193 ),
194 );
195 for (const arn of arns) {
196 yield* apprunner
197 .deleteObservabilityConfiguration({
198 ObservabilityConfigurationArn: arn,
199 })
200 .pipe(
201 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
202 Effect.retry({
203 while: (e) => e._tag === "InvalidRequestException",
204 schedule: Schedule.max([
205 Schedule.fixed("5 seconds"),
206 Schedule.recurs(24),
207 ]),
208 }),
209 );
210 }
211 });
212
213 return {
214 stables: ["observabilityConfigurationName"],
215
216 diff: Effect.fn(function* ({ id, olds, news }) {
217 if (!isResolved(news)) return undefined;
218 if (
219 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
220 ) {
221 return { action: "replace" } as const;
222 }
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const name =
227 output?.observabilityConfigurationName ??
228 (yield* toName(id, olds ?? {}));
229 const config = yield* findLatest(name);
230 if (config === undefined) return undefined;
231 const attrs = yield* toAttrs(config);
232 const tags = yield* readAppRunnerTags(
233 attrs.observabilityConfigurationArn,
234 );
235 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
236 }),
237
238 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
239 const name =
240 output?.observabilityConfigurationName ?? (yield* toName(id, news));
241 const internalTags = yield* createInternalTags(id);
242 const desiredTags = { ...internalTags, ...news.tags };
243
244 // 1. Observe — the latest ACTIVE revision is authoritative.
245 let observed = yield* findLatest(name);
246
247 // 2/3. Ensure + sync — trace settings live on immutable
248 // revisions, so a drift on the user-specified vendor is converged
249 // by creating a new revision under the same name. Only compare
250 // what the user actually specified.
251 const drifted =
252 observed !== undefined &&
253 news.traceConfiguration !== undefined &&
254 news.traceConfiguration.vendor !==
255 observed.TraceConfiguration?.Vendor;
256
257 if (observed === undefined || drifted) {
258 const created = yield* apprunner.createObservabilityConfiguration({
259 ObservabilityConfigurationName: name,
260 TraceConfiguration: news.traceConfiguration
261 ? { Vendor: news.traceConfiguration.vendor }
262 : undefined,
263 Tags: toWireTags(desiredTags),
264 });
265 observed = created.ObservabilityConfiguration;
266 }
267
268 // 3b. Sync tags on the latest revision ARN — diff against
269 // OBSERVED cloud tags.
270 if (observed.ObservabilityConfigurationArn) {
271 yield* syncAppRunnerTags(
272 observed.ObservabilityConfigurationArn,
273 desiredTags,
274 );
275 }
276
277 // 4. Return fresh attributes.
278 yield* session.note(name);
279 return yield* toAttrs(observed);
280 }),
281
282 delete: Effect.fn(function* ({ output }) {
283 yield* deleteAllRevisions(output.observabilityConfigurationName);
284 }),
285
286 list: () =>
287 apprunner.listObservabilityConfigurations
288 .pages({ LatestOnly: true })
289 .pipe(
290 Stream.runCollect,
291 Effect.map((chunk) =>
292 Array.from(chunk).flatMap((page) =>
293 (page.ObservabilityConfigurationSummaryList ?? []).flatMap(
294 (s) =>
295 s.ObservabilityConfigurationArn !== undefined &&
296 // App Runner ships an AWS-managed `DefaultConfiguration`
297 // revision that always exists and can never be deleted
298 // — keep it out of enumeration for account-wide
299 // teardown (nuke).
300 s.ObservabilityConfigurationName !==
301 "DefaultConfiguration"
302 ? [s.ObservabilityConfigurationArn]
303 : [],
304 ),
305 ),
306 ),
307 Effect.flatMap(
308 Effect.forEach(
309 (arn) =>
310 apprunner
311 .describeObservabilityConfiguration({
312 ObservabilityConfigurationArn: arn,
313 })
314 .pipe(
315 Effect.flatMap((r) =>
316 isActiveStatus(r.ObservabilityConfiguration.Status)
317 ? Effect.map(
318 toAttrs(r.ObservabilityConfiguration),
319 (attrs) =>
320 attrs as
321 | ObservabilityConfiguration["Attributes"]
322 | undefined,
323 )
324 : Effect.succeed(undefined),
325 ),
326 // Tolerate a delete race — drop the item.
327 Effect.catchTag("ResourceNotFoundException", () =>
328 Effect.succeed(undefined),
329 ),
330 ),
331 { concurrency: 4 },
332 ),
333 ),
334 Effect.map((items) => items.filter((item) => item !== undefined)),
335 ),
336 };
337 }),
338 );