| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as XRay from "@/AWS/XRay"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Result from "effect/Result"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as Stream from "effect/Stream"; |
| 9 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 10 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 11 | import path from "pathe"; |
| 12 | |
| 13 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 14 | |
| 15 | export class XRayTestFunction extends Lambda.Function<Lambda.Function>()( |
| 16 | "XRayTestFunction", |
| 17 | ) {} |
| 18 | |
| 19 | /** Collapse a typed API call to `"ok"` or its typed error tag. */ |
| 20 | const outcome = <A, E extends { _tag: string }>(effect: Effect.Effect<A, E>) => |
| 21 | Effect.result(effect).pipe( |
| 22 | Effect.map((result) => |
| 23 | Result.isFailure(result) ? result.failure._tag : "ok", |
| 24 | ), |
| 25 | ); |
| 26 | |
| 27 | export default XRayTestFunction.make( |
| 28 | { |
| 29 | main, |
| 30 | functionUrl: true, |
| 31 | // Active tracing: every sampled invocation of this function produces an |
| 32 | // X-Ray trace whose service name is the function name. |
| 33 | tracing: "Active", |
| 34 | timeout: Duration.seconds(30), |
| 35 | }, |
| 36 | Effect.gen(function* () { |
| 37 | // Event source: subscribe the host to X-Ray insight updates. The deploy |
| 38 | // proves the EventBridge rule + invoke permission wiring. |
| 39 | yield* XRay.consumeInsightEvents({ states: ["ACTIVE"] }, (events) => |
| 40 | Stream.runForEach(events, (event) => |
| 41 | Effect.log( |
| 42 | `xray insight: ${event.detail.InsightId} (${event.detail.State})`, |
| 43 | ), |
| 44 | ), |
| 45 | ); |
| 46 | |
| 47 | const getTraceSummaries = yield* XRay.GetTraceSummaries(); |
| 48 | const batchGetTraces = yield* XRay.BatchGetTraces(); |
| 49 | const putTraceSegments = yield* XRay.PutTraceSegments(); |
| 50 | const putTelemetryRecords = yield* XRay.PutTelemetryRecords(); |
| 51 | const getSamplingRules = yield* XRay.GetSamplingRules(); |
| 52 | const getSamplingTargets = yield* XRay.GetSamplingTargets(); |
| 53 | const getSamplingStatisticSummaries = |
| 54 | yield* XRay.GetSamplingStatisticSummaries(); |
| 55 | const getServiceGraph = yield* XRay.GetServiceGraph(); |
| 56 | const getTraceGraph = yield* XRay.GetTraceGraph(); |
| 57 | const getTimeSeriesServiceStatistics = |
| 58 | yield* XRay.GetTimeSeriesServiceStatistics(); |
| 59 | const getInsight = yield* XRay.GetInsight(); |
| 60 | const getInsightEvents = yield* XRay.GetInsightEvents(); |
| 61 | const getInsightImpactGraph = yield* XRay.GetInsightImpactGraph(); |
| 62 | const getInsightSummaries = yield* XRay.GetInsightSummaries(); |
| 63 | const getTraceSegmentDestination = yield* XRay.GetTraceSegmentDestination(); |
| 64 | const startTraceRetrieval = yield* XRay.StartTraceRetrieval(); |
| 65 | const listRetrievedTraces = yield* XRay.ListRetrievedTraces(); |
| 66 | const getRetrievedTracesGraph = yield* XRay.GetRetrievedTracesGraph(); |
| 67 | const cancelTraceRetrieval = yield* XRay.CancelTraceRetrieval(); |
| 68 | |
| 69 | // X-Ray read APIs have low TPS quotas; the test polls routes while |
| 70 | // sibling suites run, so absorb throttling in the fixture. |
| 71 | const throttleRetry = <A, E extends { _tag: string }>( |
| 72 | effect: Effect.Effect<A, E>, |
| 73 | ) => |
| 74 | effect.pipe( |
| 75 | Effect.retry({ |
| 76 | while: (error): boolean => error._tag === "ThrottledException", |
| 77 | schedule: Schedule.max([ |
| 78 | Schedule.exponential("500 millis"), |
| 79 | Schedule.recurs(5), |
| 80 | ]), |
| 81 | }), |
| 82 | ); |
| 83 | |
| 84 | return { |
| 85 | fetch: Effect.gen(function* () { |
| 86 | const request = yield* HttpServerRequest; |
| 87 | const url = new URL(request.originalUrl); |
| 88 | const pathname = url.pathname; |
| 89 | const now = yield* Effect.sync(() => Date.now()); |
| 90 | |
| 91 | if (request.method === "GET" && pathname === "/ping") { |
| 92 | const functionName = yield* Effect.sync( |
| 93 | () => process.env.AWS_LAMBDA_FUNCTION_NAME, |
| 94 | ); |
| 95 | return yield* HttpServerResponse.json({ ok: true, functionName }); |
| 96 | } |
| 97 | |
| 98 | if (request.method === "GET" && pathname === "/trace-summaries") { |
| 99 | const service = url.searchParams.get("service"); |
| 100 | const result = yield* throttleRetry( |
| 101 | getTraceSummaries({ |
| 102 | StartTime: new Date(now - 10 * 60 * 1000), |
| 103 | EndTime: new Date(now), |
| 104 | FilterExpression: service ? `service("${service}")` : undefined, |
| 105 | }), |
| 106 | ); |
| 107 | return yield* HttpServerResponse.json({ |
| 108 | traceIds: (result.TraceSummaries ?? []).flatMap((summary) => |
| 109 | summary.Id ? [summary.Id] : [], |
| 110 | ), |
| 111 | }); |
| 112 | } |
| 113 | |
| 114 | if (request.method === "GET" && pathname === "/batch-get-traces") { |
| 115 | const ids = url.searchParams.get("ids"); |
| 116 | const result = yield* throttleRetry( |
| 117 | batchGetTraces({ TraceIds: ids ? ids.split(",") : [] }), |
| 118 | ); |
| 119 | return yield* HttpServerResponse.json({ |
| 120 | traces: (result.Traces ?? []).map((trace) => ({ |
| 121 | id: trace.Id, |
| 122 | segments: trace.Segments?.length ?? 0, |
| 123 | })), |
| 124 | unprocessed: result.UnprocessedTraceIds ?? [], |
| 125 | }); |
| 126 | } |
| 127 | |
| 128 | if (request.method === "POST" && pathname === "/put-trace-segments") { |
| 129 | // A well-formed custom segment: epoch-anchored trace id, unique |
| 130 | // segment id, sub-second duration. |
| 131 | const epochHex = Math.floor(now / 1000).toString(16); |
| 132 | const random24 = Array.from({ length: 24 }, () => |
| 133 | Math.floor(Math.random() * 16).toString(16), |
| 134 | ).join(""); |
| 135 | const segmentId = Array.from({ length: 16 }, () => |
| 136 | Math.floor(Math.random() * 16).toString(16), |
| 137 | ).join(""); |
| 138 | const traceId = `1-${epochHex}-${random24}`; |
| 139 | const result = yield* throttleRetry( |
| 140 | putTraceSegments({ |
| 141 | TraceSegmentDocuments: [ |
| 142 | JSON.stringify({ |
| 143 | name: "alchemy-xray-binding-test", |
| 144 | id: segmentId, |
| 145 | trace_id: traceId, |
| 146 | start_time: now / 1000 - 0.5, |
| 147 | end_time: now / 1000, |
| 148 | }), |
| 149 | ], |
| 150 | }), |
| 151 | ); |
| 152 | return yield* HttpServerResponse.json({ |
| 153 | traceId, |
| 154 | unprocessed: result.UnprocessedTraceSegments ?? [], |
| 155 | }); |
| 156 | } |
| 157 | |
| 158 | if (request.method === "POST" && pathname === "/telemetry") { |
| 159 | yield* throttleRetry( |
| 160 | putTelemetryRecords({ |
| 161 | TelemetryRecords: [ |
| 162 | { |
| 163 | Timestamp: new Date(now), |
| 164 | SegmentsReceivedCount: 1, |
| 165 | SegmentsSentCount: 1, |
| 166 | }, |
| 167 | ], |
| 168 | }), |
| 169 | ); |
| 170 | return yield* HttpServerResponse.json({ ok: true }); |
| 171 | } |
| 172 | |
| 173 | if (request.method === "GET" && pathname === "/sampling") { |
| 174 | const rules = yield* throttleRetry(getSamplingRules()); |
| 175 | const summaries = yield* throttleRetry( |
| 176 | getSamplingStatisticSummaries(), |
| 177 | ); |
| 178 | // Report sampling statistics for the built-in Default rule — the |
| 179 | // real sampler protocol handshake. |
| 180 | const clientId = Array.from({ length: 24 }, () => |
| 181 | Math.floor(Math.random() * 16).toString(16), |
| 182 | ).join(""); |
| 183 | const targets = yield* Effect.result( |
| 184 | getSamplingTargets({ |
| 185 | SamplingStatisticsDocuments: [ |
| 186 | { |
| 187 | RuleName: "Default", |
| 188 | ClientID: clientId, |
| 189 | Timestamp: new Date(now), |
| 190 | RequestCount: 1, |
| 191 | SampledCount: 1, |
| 192 | }, |
| 193 | ], |
| 194 | }), |
| 195 | ); |
| 196 | return yield* HttpServerResponse.json({ |
| 197 | ruleNames: (rules.SamplingRuleRecords ?? []).flatMap((record) => |
| 198 | record.SamplingRule?.RuleName |
| 199 | ? [record.SamplingRule.RuleName] |
| 200 | : [], |
| 201 | ), |
| 202 | statisticSummaries: (summaries.SamplingStatisticSummaries ?? []) |
| 203 | .length, |
| 204 | targets: Result.isSuccess(targets) |
| 205 | ? { |
| 206 | outcome: "ok", |
| 207 | documents: (targets.success.SamplingTargetDocuments ?? []) |
| 208 | .length, |
| 209 | } |
| 210 | : { outcome: targets.failure._tag, documents: null }, |
| 211 | }); |
| 212 | } |
| 213 | |
| 214 | if (request.method === "GET" && pathname === "/service-graph") { |
| 215 | const graph = yield* Effect.result( |
| 216 | throttleRetry( |
| 217 | getServiceGraph({ |
| 218 | StartTime: new Date(now - 10 * 60 * 1000), |
| 219 | EndTime: new Date(now), |
| 220 | }), |
| 221 | ), |
| 222 | ); |
| 223 | return yield* HttpServerResponse.json( |
| 224 | Result.isSuccess(graph) |
| 225 | ? { services: (graph.success.Services ?? []).length, error: null } |
| 226 | : { services: null, error: graph.failure._tag }, |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | if (request.method === "GET" && pathname === "/trace-graph") { |
| 231 | const ids = url.searchParams.get("ids"); |
| 232 | const graph = yield* Effect.result( |
| 233 | throttleRetry( |
| 234 | getTraceGraph({ TraceIds: ids ? ids.split(",") : [] }), |
| 235 | ), |
| 236 | ); |
| 237 | return yield* HttpServerResponse.json( |
| 238 | Result.isSuccess(graph) |
| 239 | ? { services: (graph.success.Services ?? []).length, error: null } |
| 240 | : { services: null, error: graph.failure._tag }, |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | if (request.method === "GET" && pathname === "/time-series") { |
| 245 | const service = url.searchParams.get("service"); |
| 246 | const stats = yield* Effect.result( |
| 247 | getTimeSeriesServiceStatistics({ |
| 248 | StartTime: new Date(now - 10 * 60 * 1000), |
| 249 | EndTime: new Date(now), |
| 250 | EntitySelectorExpression: service |
| 251 | ? `service("${service}")` |
| 252 | : undefined, |
| 253 | Period: 60, |
| 254 | }), |
| 255 | ); |
| 256 | return yield* HttpServerResponse.json( |
| 257 | Result.isSuccess(stats) |
| 258 | ? { |
| 259 | points: (stats.success.TimeSeriesServiceStatistics ?? []) |
| 260 | .length, |
| 261 | error: null, |
| 262 | } |
| 263 | : { points: null, error: stats.failure._tag }, |
| 264 | ); |
| 265 | } |
| 266 | |
| 267 | if (request.method === "GET" && pathname === "/insight-summaries") { |
| 268 | // The group may not have insights enabled — report either the |
| 269 | // summary count or the API's typed error tag. |
| 270 | const group = url.searchParams.get("group") ?? "Default"; |
| 271 | const result = yield* Effect.result( |
| 272 | throttleRetry( |
| 273 | getInsightSummaries({ |
| 274 | GroupName: group, |
| 275 | StartTime: new Date(now - 24 * 60 * 60 * 1000), |
| 276 | EndTime: new Date(now), |
| 277 | }), |
| 278 | ), |
| 279 | ); |
| 280 | return yield* HttpServerResponse.json( |
| 281 | Result.isSuccess(result) |
| 282 | ? { |
| 283 | insights: (result.success.InsightSummaries ?? []).length, |
| 284 | error: null, |
| 285 | } |
| 286 | : { insights: null, error: result.failure._tag }, |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | if (request.method === "GET" && pathname === "/insight") { |
| 291 | // No insight can be provisioned on demand — prove each binding's |
| 292 | // IAM grant by observing the API's typed validation error for a |
| 293 | // syntactically-plausible but nonexistent insight id (an IAM |
| 294 | // failure would surface as AccessDeniedException instead). |
| 295 | const insightId = |
| 296 | url.searchParams.get("id") ?? |
| 297 | "00000000-0000-0000-0000-000000000000"; |
| 298 | return yield* HttpServerResponse.json({ |
| 299 | getInsight: yield* outcome(getInsight({ InsightId: insightId })), |
| 300 | getInsightEvents: yield* outcome( |
| 301 | getInsightEvents({ InsightId: insightId }), |
| 302 | ), |
| 303 | getInsightImpactGraph: yield* outcome( |
| 304 | getInsightImpactGraph({ |
| 305 | InsightId: insightId, |
| 306 | StartTime: new Date(now - 60 * 60 * 1000), |
| 307 | EndTime: new Date(now), |
| 308 | }), |
| 309 | ), |
| 310 | }); |
| 311 | } |
| 312 | |
| 313 | if (request.method === "GET" && pathname === "/trace-retrieval") { |
| 314 | // Transaction Search requires a CloudWatch Logs trace destination; |
| 315 | // on the default X-Ray destination the retrieval ops answer with |
| 316 | // typed errors — either way each call proves its IAM grant. |
| 317 | const destination = yield* throttleRetry( |
| 318 | getTraceSegmentDestination(), |
| 319 | ); |
| 320 | const epochHex = Math.floor(now / 1000).toString(16); |
| 321 | const start = yield* Effect.result( |
| 322 | startTraceRetrieval({ |
| 323 | TraceIds: [`1-${epochHex}-abcdef0123456789abcdef01`], |
| 324 | StartTime: new Date(now - 60 * 60 * 1000), |
| 325 | EndTime: new Date(now), |
| 326 | }), |
| 327 | ); |
| 328 | const retrievalToken = Result.isSuccess(start) |
| 329 | ? start.success.RetrievalToken |
| 330 | : undefined; |
| 331 | const bogusToken = retrievalToken ?? "alchemy-nonexistent-token"; |
| 332 | return yield* HttpServerResponse.json({ |
| 333 | destination: destination.Destination, |
| 334 | startTraceRetrieval: Result.isFailure(start) |
| 335 | ? start.failure._tag |
| 336 | : "ok", |
| 337 | listRetrievedTraces: yield* outcome( |
| 338 | listRetrievedTraces({ RetrievalToken: bogusToken }), |
| 339 | ), |
| 340 | getRetrievedTracesGraph: yield* outcome( |
| 341 | getRetrievedTracesGraph({ RetrievalToken: bogusToken }), |
| 342 | ), |
| 343 | cancelTraceRetrieval: yield* outcome( |
| 344 | cancelTraceRetrieval({ RetrievalToken: bogusToken }), |
| 345 | ), |
| 346 | }); |
| 347 | } |
| 348 | |
| 349 | return yield* HttpServerResponse.json( |
| 350 | { error: "Not found", method: request.method, pathname }, |
| 351 | { status: 404 }, |
| 352 | ); |
| 353 | }).pipe(Effect.orDie), |
| 354 | }; |
| 355 | }).pipe( |
| 356 | Effect.provide( |
| 357 | Layer.mergeAll( |
| 358 | Lambda.EventSource, |
| 359 | XRay.GetTraceSummariesHttp, |
| 360 | XRay.BatchGetTracesHttp, |
| 361 | XRay.PutTraceSegmentsHttp, |
| 362 | XRay.PutTelemetryRecordsHttp, |
| 363 | XRay.GetSamplingRulesHttp, |
| 364 | XRay.GetSamplingTargetsHttp, |
| 365 | XRay.GetSamplingStatisticSummariesHttp, |
| 366 | XRay.GetServiceGraphHttp, |
| 367 | XRay.GetTraceGraphHttp, |
| 368 | XRay.GetTimeSeriesServiceStatisticsHttp, |
| 369 | XRay.GetInsightHttp, |
| 370 | XRay.GetInsightEventsHttp, |
| 371 | XRay.GetInsightImpactGraphHttp, |
| 372 | XRay.GetInsightSummariesHttp, |
| 373 | XRay.GetTraceSegmentDestinationHttp, |
| 374 | XRay.StartTraceRetrievalHttp, |
| 375 | XRay.ListRetrievedTracesHttp, |
| 376 | XRay.GetRetrievedTracesGraphHttp, |
| 377 | XRay.CancelTraceRetrievalHttp, |
| 378 | ), |
| 379 | ), |
| 380 | ), |
| 381 | ); |