1import * as appsignals from "@distilled.cloud/aws/application-signals";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import {
8 createInternalTags,
9 createTagsList,
10 diffTags,
11 hasAlchemyTags,
12} from "../../Tags.ts";
13import type { Providers } from "../Providers.ts";
14
15export interface InstrumentationConfigurationProps {
16 /**
17 * The kind of dynamic instrumentation: `PROBE` or `BREAKPOINT`.
18 * Configurations are immutable — changing the type replaces the
19 * configuration.
20 */
21 instrumentationType: appsignals.InstrumentationType;
22 /**
23 * Name of the Application Signals service the instrumented code belongs
24 * to. Changing it replaces the configuration.
25 */
26 service: string;
27 /**
28 * The service's environment (e.g. `eks:prod`, `lambda:default`).
29 * Changing it replaces the configuration.
30 */
31 environment: string;
32 /**
33 * The signal produced by the instrumentation (currently `SNAPSHOT`).
34 * Changing it replaces the configuration.
35 */
36 signalType: appsignals.DynamicInstrumentationSignalType;
37 /**
38 * The code location to instrument (`Language`, `FilePath`, and — as the
39 * language requires — `CodeUnit`, `ClassName`, `MethodName`,
40 * `LineNumber`). Changing the location replaces the configuration.
41 */
42 location: appsignals.CodeLocation;
43 /**
44 * What the instrumentation captures at the location: arguments, return
45 * value, stack trace, locals, and the mandatory `CaptureLimits`.
46 * Configurations are immutable — changing this replaces it.
47 */
48 captureConfiguration: appsignals.CodeCaptureConfiguration;
49 /**
50 * A human-readable description of the configuration.
51 */
52 description?: string;
53 /**
54 * When the configuration expires and stops instrumenting. Accepts a
55 * `Date` or an ISO timestamp string.
56 */
57 expiresAt?: Date | string;
58 /**
59 * Attribute filter groups — the instrumentation only fires when a
60 * request matches all attributes within one of the groups.
61 */
62 attributeFilters?: { [key: string]: string | undefined }[];
63 /**
64 * Tags to apply to the configuration. Merged with internal Alchemy tags.
65 */
66 tags?: Record<string, string>;
67}
68
69export interface InstrumentationConfiguration extends Resource<
70 "AWS.ApplicationSignals.InstrumentationConfiguration",
71 InstrumentationConfigurationProps,
72 {
73 /**
74 * ARN of the instrumentation configuration.
75 */
76 arn: string;
77 /**
78 * Server-computed hash uniquely identifying the instrumented location
79 * within the service/environment/type/signal scope.
80 */
81 locationHash: string;
82 /**
83 * The instrumentation type (`PROBE` or `BREAKPOINT`).
84 */
85 instrumentationType: appsignals.InstrumentationType;
86 /**
87 * The Application Signals service the configuration belongs to.
88 */
89 service: string;
90 /**
91 * The service environment the configuration belongs to.
92 */
93 environment: string;
94 /**
95 * The signal type produced by the instrumentation.
96 */
97 signalType: appsignals.DynamicInstrumentationSignalType;
98 /**
99 * When the configuration was created (ISO timestamp).
100 */
101 createdAt: string;
102 },
103 never,
104 Providers
105> {}
106
107/**
108 * A CloudWatch Application Signals dynamic instrumentation configuration —
109 * instructs instrumented SDK agents to capture a snapshot (arguments,
110 * locals, return value, stack trace) at a specific code location of a
111 * discovered service, without redeploying the application.
112 *
113 * Configurations are immutable after creation: every change except tags
114 * replaces the configuration. Tags remain mutable through the standard
115 * tagging APIs.
116 *
117 * ### Creating an Instrumentation Configuration
118 * **Example:** Snapshot Probe on a Python Method
119 * ```typescript
120 * import * as AWS from "alchemy/AWS";
121 *
122 * const probe = yield* AWS.ApplicationSignals.InstrumentationConfiguration(
123 * "CheckoutProbe",
124 * {
125 * instrumentationType: "PROBE",
126 * service: "checkout-service",
127 * environment: "eks:prod",
128 * signalType: "SNAPSHOT",
129 * location: {
130 * Language: "Python",
131 * CodeUnit: "app.checkout",
132 * MethodName: "process_order",
133 * FilePath: "app/checkout.py",
134 * LineNumber: 42,
135 * },
136 * captureConfiguration: {
137 * CaptureLocals: ["order_id", "total"],
138 * CaptureLimits: { MaxHits: 100 },
139 * },
140 * },
141 * );
142 * ```
143 *
144 * **Example:** Expiring Probe with Attribute Filters
145 * ```typescript
146 * const probe = yield* AWS.ApplicationSignals.InstrumentationConfiguration(
147 * "DebugProbe",
148 * {
149 * instrumentationType: "PROBE",
150 * service: "checkout-service",
151 * environment: "eks:prod",
152 * signalType: "SNAPSHOT",
153 * location: {
154 * Language: "Java",
155 * ClassName: "com.example.Checkout",
156 * MethodName: "processOrder",
157 * FilePath: "src/main/java/com/example/Checkout.java",
158 * },
159 * captureConfiguration: {
160 * CaptureArguments: ["order"],
161 * CaptureLimits: { MaxHits: 10 },
162 * },
163 * expiresAt: new Date(Date.now() + 24 * 3600 * 1000),
164 * attributeFilters: [{ "aws.local.operation": "POST /checkout" }],
165 * },
166 * );
167 * ```
168 *
169 * @resource
170 */
171export const InstrumentationConfiguration =
172 Resource<InstrumentationConfiguration>(
173 "AWS.ApplicationSignals.InstrumentationConfiguration",
174 );
175
176/**
177 * Normalize `expiresAt` (which the engine's state serialization flattens to
178 * an ISO string) back to a `Date` for the wire.
179 */
180const toExpiresAt = (value: Date | string | undefined): Date | undefined =>
181 value === undefined ? undefined : new Date(value);
182
183/**
184 * The identity + payload of a configuration in a canonical, comparable
185 * shape. Everything here is create-only: a change replaces the resource.
186 */
187const immutableFingerprint = (props: InstrumentationConfigurationProps) =>
188 JSON.stringify({
189 instrumentationType: props.instrumentationType,
190 service: props.service,
191 environment: props.environment,
192 signalType: props.signalType,
193 location: props.location,
194 captureConfiguration: props.captureConfiguration,
195 description: props.description,
196 expiresAt: toExpiresAt(props.expiresAt)?.getTime(),
197 attributeFilters: props.attributeFilters,
198 });
199
200export const InstrumentationConfigurationProvider = () =>
201 Provider.effect(
202 InstrumentationConfiguration,
203 Effect.gen(function* () {
204 /** The identity quadruple shared by get/create/delete requests. */
205 const identity = (
206 props: Pick<
207 InstrumentationConfigurationProps,
208 "instrumentationType" | "service" | "environment" | "signalType"
209 >,
210 ) => ({
211 InstrumentationType: props.instrumentationType,
212 Service: props.service,
213 Environment: props.environment,
214 SignalType: props.signalType,
215 });
216
217 /** Observe by location hash (preferred) or code location. */
218 const observe = Effect.fn(function* (
219 props: InstrumentationConfigurationProps,
220 locationHash: string | undefined,
221 ) {
222 const response = yield* appsignals
223 .getInstrumentationConfiguration({
224 ...identity(props),
225 LocationIdentifier: locationHash
226 ? { LocationHash: locationHash }
227 : { CodeLocation: props.location },
228 })
229 .pipe(
230 Effect.catchTag("ResourceNotFoundException", () =>
231 Effect.succeed(undefined),
232 ),
233 );
234 return response?.Configuration;
235 });
236
237 const observedTags = (arn: string) =>
238 appsignals.listTagsForResource({ ResourceArn: arn }).pipe(
239 Effect.map((response) =>
240 Object.fromEntries(
241 (response.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
242 ),
243 ),
244 Effect.catchTag("ResourceNotFoundException", () =>
245 Effect.succeed({} as Record<string, string>),
246 ),
247 );
248
249 const toAttrs = (
250 configuration: appsignals.InstrumentationConfiguration,
251 ) => ({
252 arn: configuration.ARN,
253 locationHash: configuration.LocationHash,
254 instrumentationType: configuration.InstrumentationType,
255 service: configuration.Service,
256 environment: configuration.Environment,
257 signalType: configuration.SignalType,
258 createdAt: configuration.CreatedAt.toISOString(),
259 });
260
261 return {
262 stables: [
263 "arn",
264 "locationHash",
265 "instrumentationType",
266 "service",
267 "environment",
268 "signalType",
269 ],
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const props = olds;
273 if (props === undefined) return undefined;
274 const found = yield* observe(props, output?.locationHash);
275 if (found === undefined) return undefined;
276 const attrs = toAttrs(found);
277 const tags = yield* observedTags(found.ARN);
278 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
279 }),
280
281 diff: Effect.fn(function* ({ news, olds }) {
282 if (!isResolved(news)) return undefined;
283 // Configurations are immutable after creation — any change other
284 // than tags replaces. Tags fall through to the default update.
285 if (
286 olds !== undefined &&
287 immutableFingerprint(news) !== immutableFingerprint(olds)
288 ) {
289 return { action: "replace" } as const;
290 }
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output, session }) {
294 const internalTags = yield* createInternalTags(id);
295 const desiredTags = { ...news.tags, ...internalTags };
296
297 // 1. Observe — cloud state is authoritative; `output` only caches
298 // the location hash.
299 let live = yield* observe(news, output?.locationHash);
300
301 if (live === undefined) {
302 // 2. Ensure — create when missing; a concurrent create of the
303 // same location surfaces as ConflictException, which is a
304 // race: re-observe and continue.
305 live = yield* appsignals
306 .createInstrumentationConfiguration({
307 ...identity(news),
308 Location: { CodeLocation: news.location },
309 CaptureConfiguration: {
310 CodeCapture: news.captureConfiguration,
311 },
312 Description: news.description,
313 ExpiresAt: toExpiresAt(news.expiresAt),
314 AttributeFilters: news.attributeFilters,
315 Tags: createTagsList(desiredTags),
316 })
317 .pipe(
318 Effect.catchTag("ConflictException", () =>
319 observe(news, undefined).pipe(
320 Effect.flatMap((observed) =>
321 observed === undefined
322 ? Effect.fail(
323 new Error(
324 `instrumentation configuration for '${news.service}' conflicted but was not observable`,
325 ),
326 )
327 : Effect.succeed(observed),
328 ),
329 ),
330 ),
331 );
332 }
333 // 3. There is no update API — the diff replaces on any change to
334 // the immutable payload, so an observed configuration is
335 // already converged apart from tags.
336
337 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
338 // converges (create-time Tags only apply on first create).
339 const currentTags = yield* observedTags(live.ARN);
340 const { upsert, removed } = diffTags(currentTags, desiredTags);
341 if (upsert.length > 0) {
342 yield* appsignals.tagResource({
343 ResourceArn: live.ARN,
344 Tags: upsert,
345 });
346 }
347 if (removed.length > 0) {
348 yield* appsignals.untagResource({
349 ResourceArn: live.ARN,
350 TagKeys: removed,
351 });
352 }
353
354 yield* session.note(live.LocationHash);
355 return toAttrs(live);
356 }),
357
358 delete: Effect.fn(function* ({ output }) {
359 yield* appsignals
360 .deleteInstrumentationConfiguration({
361 InstrumentationType: output.instrumentationType,
362 Service: output.service,
363 Environment: output.environment,
364 SignalType: output.signalType,
365 LocationIdentifier: { LocationHash: output.locationHash },
366 })
367 .pipe(
368 // Idempotent delete — already-gone is success.
369 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
370 );
371 }),
372
373 // No account-wide enumeration API (listing requires a service +
374 // environment + type scope).
375 list: () => Effect.succeed([]),
376 };
377 }),
378 );