1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as eventbridge from "@distilled.cloud/aws/eventbridge";
5import * as iam from "@distilled.cloud/aws/iam";
6import * as Lambda from "@distilled.cloud/aws/lambda";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import XRayTestFunctionLive, { XRayTestFunction } from "./handler";
14
15const testOptions = { providers: AWS.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "XRayBindings");
18
19// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
20// over 60s on a fresh deploy under parallel-suite load. Budget ~150s of
21// readiness polling.
22const readinessPolicy = Schedule.max([
23 Schedule.fixed("2 seconds"),
24 Schedule.recurs(75),
25]);
26
27let baseUrl: string;
28let functionName: string;
29let functionArn: string;
30let roleName: string;
31
32class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
33 readonly status: number;
34 readonly body: string;
35}> {}
36
37class FunctionStillExists extends Data.TaggedError("FunctionStillExists")<{
38 readonly functionName: string;
39}> {}
40
41// The shared Lambda fixture occasionally answers a transient 5xx under
42// parallel load (cold re-init, IAM propagation). Retry 5xx only; a genuine
43// 4xx/assertion failure surfaces immediately.
44const send = (request: HttpClientRequest.HttpClientRequest) =>
45 HttpClient.execute(request).pipe(
46 Effect.flatMap((response) =>
47 response.status >= 500
48 ? response.text.pipe(
49 Effect.flatMap((body) =>
50 Effect.fail(
51 new TransientUpstream({ status: response.status, body }),
52 ),
53 ),
54 )
55 : Effect.succeed(response),
56 ),
57 Effect.retry({
58 while: (e) => e._tag === "TransientUpstream",
59 schedule: Schedule.max([
60 Schedule.exponential("500 millis"),
61 Schedule.recurs(6),
62 ]),
63 }),
64 );
65
66describe(
67 "XRay Bindings",
68 {
69 tags: [
70 "provider:aws",
71 "provider:aws:batch",
72 "provider:aws:lambda",
73 "provider:aws:xray",
74 "live",
75 ],
76 },
77 () => {
78 beforeAll(
79 Effect.gen(function* () {
80 yield* Effect.logInfo("XRay test setup: destroying previous resources");
81 yield* sharedStack.destroy();
82
83 yield* Effect.logInfo("XRay test setup: deploying fixture");
84 const attrs = yield* sharedStack.deploy(
85 Effect.gen(function* () {
86 return yield* XRayTestFunction;
87 }).pipe(Effect.provide(XRayTestFunctionLive)),
88 );
89
90 expect(attrs.functionUrl).toBeTruthy();
91 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
92 functionName = attrs.functionName;
93 functionArn = attrs.functionArn;
94 roleName = attrs.roleName;
95
96 const readinessUrl = `${baseUrl}/ping`;
97 yield* Effect.logInfo(
98 `XRay test setup: probing readiness at ${readinessUrl}`,
99 );
100 yield* HttpClient.get(readinessUrl).pipe(
101 Effect.flatMap((response) =>
102 response.status === 200
103 ? Effect.succeed(response)
104 : Effect.fail(
105 new Error(`Function not ready: ${response.status}`),
106 ),
107 ),
108 Effect.tapError((error) =>
109 Effect.logWarning(
110 `XRay test setup: fixture not ready yet (${String(error)})`,
111 ),
112 ),
113 Effect.retry({ schedule: readinessPolicy }),
114 );
115 }),
116 { timeout: 240_000 },
117 );
118
119 afterAll(
120 Effect.gen(function* () {
121 yield* sharedStack.destroy();
122 // assert the fixture Lambda is really gone (typed not-found only)
123 if (functionName) {
124 yield* Core.withProviders(
125 Lambda.getFunctionConfiguration({
126 FunctionName: functionName,
127 }).pipe(
128 Effect.flatMap(() =>
129 Effect.fail(new FunctionStillExists({ functionName })),
130 ),
131 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
132 Effect.retry({
133 while: (e) => e._tag === "FunctionStillExists",
134 schedule: Schedule.max([
135 Schedule.exponential(500),
136 Schedule.recurs(8),
137 ]),
138 }),
139 ),
140 testOptions,
141 "XRayBindings",
142 );
143 }
144 }),
145 { timeout: 120_000 },
146 );
147
148 describe("Function tracing", { tags: ["provider:aws:iam"] }, () => {
149 test.provider(
150 "deployed function has Active tracing and the X-Ray write policy",
151 (_stack) =>
152 Effect.gen(function* () {
153 const config = yield* Lambda.getFunctionConfiguration({
154 FunctionName: functionName,
155 });
156 expect(config.TracingConfig?.Mode).toBe("Active");
157
158 const attached = yield* iam.listAttachedRolePolicies({
159 RoleName: roleName,
160 });
161 const arns = (attached.AttachedPolicies ?? []).map(
162 (policy) => policy.PolicyArn,
163 );
164 expect(arns).toContain(
165 "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess",
166 );
167 }),
168 );
169 });
170
171 describe("GetTraceSummaries", () => {
172 test.provider("queries trace summaries through the binding", (_stack) =>
173 Effect.gen(function* () {
174 // Proves the deploy-time IAM binding: the call succeeds (200) even
175 // when no traces have been ingested yet.
176 const response = yield* send(
177 HttpClientRequest.get(
178 `${baseUrl}/trace-summaries?service=${functionName}`,
179 ),
180 );
181 expect(response.status).toBe(200);
182 const body = (yield* response.json) as { traceIds: string[] };
183 expect(Array.isArray(body.traceIds)).toBe(true);
184 }),
185 );
186 });
187
188 describe("BatchGetTraces", () => {
189 test.provider("fetches traces by id through the binding", (_stack) =>
190 Effect.gen(function* () {
191 // A syntactically valid, recent, but non-existent trace id proves the
192 // IAM binding without depending on ingestion latency. The embedded
193 // timestamp must be current — X-Ray rejects ids outside its retention
194 // window.
195 const epochHex = yield* Effect.sync(() =>
196 Math.floor(Date.now() / 1000).toString(16),
197 );
198 const response = yield* send(
199 HttpClientRequest.get(
200 `${baseUrl}/batch-get-traces?ids=1-${epochHex}-abcdef0123456789abcdef01`,
201 ),
202 );
203 expect(response.status).toBe(200);
204 const body = (yield* response.json) as {
205 traces: Array<{ id: string; segments: number }>;
206 unprocessed: string[];
207 };
208 expect(body.traces).toEqual([]);
209 }),
210 );
211 });
212
213 describe("PutTraceSegments", () => {
214 test.provider("uploads a custom segment through the binding", (_stack) =>
215 Effect.gen(function* () {
216 const response = yield* send(
217 HttpClientRequest.post(`${baseUrl}/put-trace-segments`),
218 );
219 expect(response.status).toBe(200);
220 const body = (yield* response.json) as {
221 traceId: string;
222 unprocessed: unknown[];
223 };
224 expect(body.traceId).toMatch(/^1-[0-9a-f]{8}-[0-9a-f]{24}$/);
225 expect(body.unprocessed).toEqual([]);
226 }),
227 );
228 });
229
230 describe("PutTelemetryRecords", () => {
231 test.provider("uploads telemetry through the binding", (_stack) =>
232 Effect.gen(function* () {
233 const response = yield* send(
234 HttpClientRequest.post(`${baseUrl}/telemetry`),
235 );
236 expect(response.status).toBe(200);
237 const body = (yield* response.json) as { ok: boolean };
238 expect(body.ok).toBe(true);
239 }),
240 );
241 });
242
243 describe("Sampling (GetSamplingRules, GetSamplingTargets, GetSamplingStatisticSummaries)", () => {
244 test.provider("exercises the sampling protocol bindings", (_stack) =>
245 Effect.gen(function* () {
246 const response = yield* send(
247 HttpClientRequest.get(`${baseUrl}/sampling`),
248 );
249 expect(response.status).toBe(200);
250 const body = (yield* response.json) as {
251 ruleNames: string[];
252 statisticSummaries: number;
253 targets: { outcome: string; documents: number | null };
254 };
255 // The built-in fallback rule always exists.
256 expect(body.ruleNames).toContain("Default");
257 expect(body.statisticSummaries).toBeGreaterThanOrEqual(0);
258 // Reporting statistics for the Default rule yields its target.
259 expect(body.targets.outcome).toBe("ok");
260 expect(body.targets.documents).toBeGreaterThanOrEqual(1);
261 }),
262 );
263 });
264
265 describe("Service graphs (GetServiceGraph, GetTraceGraph, GetTimeSeriesServiceStatistics)", () => {
266 test.provider("queries the service graph bindings", (_stack) =>
267 Effect.gen(function* () {
268 // A typed error tag still proves the IAM grant went through — the
269 // typed set below excludes AccessDeniedException.
270 const graphTags = ["InvalidRequestException", "ValidationException"];
271
272 const graph = (yield* send(
273 HttpClientRequest.get(`${baseUrl}/service-graph`),
274 ).pipe(Effect.flatMap((r) => r.json))) as {
275 services: number | null;
276 error: string | null;
277 };
278 if (graph.error !== null) {
279 expect(graphTags).toContain(graph.error);
280 } else {
281 expect(graph.services).toBeGreaterThanOrEqual(0);
282 }
283
284 const epochHex = yield* Effect.sync(() =>
285 Math.floor(Date.now() / 1000).toString(16),
286 );
287 const traceGraph = (yield* send(
288 HttpClientRequest.get(
289 `${baseUrl}/trace-graph?ids=1-${epochHex}-abcdef0123456789abcdef01`,
290 ),
291 ).pipe(Effect.flatMap((r) => r.json))) as {
292 services: number | null;
293 error: string | null;
294 };
295 if (traceGraph.error !== null) {
296 expect(graphTags).toContain(traceGraph.error);
297 } else {
298 expect(traceGraph.services).toBe(0);
299 }
300
301 const timeSeries = (yield* send(
302 HttpClientRequest.get(
303 `${baseUrl}/time-series?service=${functionName}`,
304 ),
305 ).pipe(Effect.flatMap((r) => r.json))) as {
306 points: number | null;
307 error: string | null;
308 };
309 // A typed validation error (e.g. the entity selector needing
310 // Transaction Search) still proves the IAM grant.
311 if (timeSeries.error !== null) {
312 expect([
313 "InvalidRequestException",
314 "ValidationException",
315 ]).toContain(timeSeries.error);
316 } else {
317 expect(timeSeries.points).toBeGreaterThanOrEqual(0);
318 }
319 }),
320 );
321 });
322
323 describe("Insights (GetInsight, GetInsightEvents, GetInsightImpactGraph, GetInsightSummaries)", () => {
324 test.provider(
325 "queries insight summaries for the Default group",
326 (_stack) =>
327 Effect.gen(function* () {
328 const response = yield* send(
329 HttpClientRequest.get(
330 `${baseUrl}/insight-summaries?group=Default`,
331 ),
332 );
333 expect(response.status).toBe(200);
334 const body = (yield* response.json) as {
335 insights: number | null;
336 error: string | null;
337 };
338 // Insights may not be enabled on the Default group — a typed
339 // validation error still proves the IAM grant (an IAM failure
340 // would surface as AccessDeniedException).
341 if (body.error !== null) {
342 expect(body.error).toBe("InvalidRequestException");
343 } else {
344 expect(body.insights).toBeGreaterThanOrEqual(0);
345 }
346 }),
347 );
348
349 test.provider(
350 "insight lookups answer with typed validation errors (not access denials)",
351 (_stack) =>
352 Effect.gen(function* () {
353 // No insight can be provisioned on demand — each binding's IAM
354 // grant is proven by the API answering with its typed validation
355 // error for a nonexistent insight id.
356 const body = (yield* send(
357 HttpClientRequest.get(`${baseUrl}/insight`),
358 ).pipe(Effect.flatMap((r) => r.json))) as {
359 getInsight: string;
360 getInsightEvents: string;
361 getInsightImpactGraph: string;
362 };
363 for (const outcome of [
364 body.getInsight,
365 body.getInsightEvents,
366 body.getInsightImpactGraph,
367 ]) {
368 // ValidationException is a typed CommonErrors member — X-Ray
369 // answers with it for a nonexistent insight id.
370 expect([
371 "ok",
372 "InvalidRequestException",
373 "ValidationException",
374 ]).toContain(outcome);
375 }
376 }),
377 );
378 });
379
380 describe("Transaction Search (GetTraceSegmentDestination, StartTraceRetrieval, ListRetrievedTraces, GetRetrievedTracesGraph, CancelTraceRetrieval)", () => {
381 test.provider("exercises the trace retrieval bindings", (_stack) =>
382 Effect.gen(function* () {
383 const body = (yield* send(
384 HttpClientRequest.get(`${baseUrl}/trace-retrieval`),
385 ).pipe(Effect.flatMap((r) => r.json))) as {
386 destination: string;
387 startTraceRetrieval: string;
388 listRetrievedTraces: string;
389 getRetrievedTracesGraph: string;
390 cancelTraceRetrieval: string;
391 };
392 expect(["XRay", "CloudWatchLogs"]).toContain(body.destination);
393 // On the default X-Ray destination the retrieval workflow answers
394 // with typed errors; with Transaction Search enabled it succeeds.
395 // Either way each call went through its granted IAM action.
396 const accepted = [
397 "ok",
398 "InvalidRequestException",
399 "ResourceNotFoundException",
400 ];
401 expect(accepted).toContain(body.startTraceRetrieval);
402 expect(accepted).toContain(body.listRetrievedTraces);
403 expect(accepted).toContain(body.getRetrievedTracesGraph);
404 expect(accepted).toContain(body.cancelTraceRetrieval);
405 }),
406 );
407 });
408
409 describe(
410 "consumeInsightEvents",
411 { tags: ["provider:aws:eventbridge"] },
412 () => {
413 test.provider(
414 "the deploy created an EventBridge rule targeting the function",
415 (_stack) =>
416 Effect.gen(function* () {
417 // Out-of-band via distilled: the fixture's consumeInsightEvents
418 // must have materialized as a rule on the default bus with the
419 // Lambda as target.
420 const { RuleNames } = yield* eventbridge.listRuleNamesByTarget({
421 TargetArn: functionArn,
422 });
423 expect((RuleNames ?? []).length).toBeGreaterThanOrEqual(1);
424 }),
425 );
426 },
427 );
428
429 describe("Trace ingestion", () => {
430 // X-Ray trace ingestion typically lands within 5-30s of the invocation,
431 // but can occasionally exceed that under load. The poll below budgets
432 // ~90s (18 x 5s); a full traced round-trip (invoke -> summary -> batch
433 // fetch) is asserted once a trace id shows up.
434 test.provider(
435 "invoking the traced function produces a queryable trace",
436 (_stack) =>
437 Effect.gen(function* () {
438 // Generate a handful of sampled invocations.
439 yield* Effect.forEach(
440 [1, 2, 3],
441 () => send(HttpClientRequest.get(`${baseUrl}/ping`)),
442 { discard: true },
443 );
444
445 const traceIds = yield* send(
446 HttpClientRequest.get(
447 `${baseUrl}/trace-summaries?service=${functionName}`,
448 ),
449 ).pipe(
450 Effect.flatMap((response) => response.json),
451 Effect.map((body) => (body as { traceIds: string[] }).traceIds),
452 // A throttled poll iteration is an empty result, not a failure —
453 // the bounded repeat keeps polling.
454 Effect.catchTag("TransientUpstream", () =>
455 Effect.succeed([] as string[]),
456 ),
457 Effect.repeat({
458 schedule: Schedule.spaced("5 seconds"),
459 until: (ids) => ids.length > 0,
460 times: 18,
461 }),
462 );
463 expect(traceIds.length).toBeGreaterThan(0);
464
465 // Round-trip the discovered id through BatchGetTraces.
466 const batch = (yield* send(
467 HttpClientRequest.get(
468 `${baseUrl}/batch-get-traces?ids=${traceIds[0]}`,
469 ),
470 ).pipe(Effect.flatMap((response) => response.json))) as {
471 traces: Array<{ id: string; segments: number }>;
472 };
473 expect(batch.traces.length).toBe(1);
474 expect(batch.traces[0].id).toBe(traceIds[0]);
475 expect(batch.traces[0].segments).toBeGreaterThan(0);
476 }),
477 { timeout: 120_000 },
478 );
479 });
480 },
481);