| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as eventbridge from "@distilled.cloud/aws/eventbridge"; |
| 5 | import * as iam from "@distilled.cloud/aws/iam"; |
| 6 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as HttpClient from "effect/http/HttpClient"; |
| 12 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 13 | import XRayTestFunctionLive, { XRayTestFunction } from "./handler"; |
| 14 | |
| 15 | const testOptions = { providers: AWS.providers() }; |
| 16 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 17 | const 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. |
| 22 | const readinessPolicy = Schedule.max([ |
| 23 | Schedule.fixed("2 seconds"), |
| 24 | Schedule.recurs(75), |
| 25 | ]); |
| 26 | |
| 27 | let baseUrl: string; |
| 28 | let functionName: string; |
| 29 | let functionArn: string; |
| 30 | let roleName: string; |
| 31 | |
| 32 | class TransientUpstream extends Data.TaggedError("TransientUpstream")<{ |
| 33 | readonly status: number; |
| 34 | readonly body: string; |
| 35 | }> {} |
| 36 | |
| 37 | class 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. |
| 44 | const 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 | |
| 66 | describe( |
| 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 | ); |