@gugananuvem/aws-local-simulator 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +235 -11
  2. package/package.json +12 -2
  3. package/src/config/default-config.js +1 -0
  4. package/src/index.js +18 -2
  5. package/src/server.js +36 -32
  6. package/src/services/apigateway/index.js +5 -0
  7. package/src/services/apigateway/server.js +20 -0
  8. package/src/services/apigateway/simulator.js +13 -3
  9. package/src/services/athena/index.js +75 -0
  10. package/src/services/athena/server.js +101 -0
  11. package/src/services/athena/simulador.js +998 -0
  12. package/src/services/athena/simulator.js +346 -0
  13. package/src/services/cloudformation/index.js +106 -0
  14. package/src/services/cloudformation/server.js +417 -0
  15. package/src/services/cloudformation/simulador.js +1045 -0
  16. package/src/services/cloudtrail/index.js +84 -0
  17. package/src/services/cloudtrail/server.js +235 -0
  18. package/src/services/cloudtrail/simulador.js +719 -0
  19. package/src/services/cloudwatch/index.js +84 -0
  20. package/src/services/cloudwatch/server.js +366 -0
  21. package/src/services/cloudwatch/simulador.js +1173 -0
  22. package/src/services/cognito/index.js +5 -0
  23. package/src/services/cognito/simulator.js +4 -0
  24. package/src/services/config/index.js +96 -0
  25. package/src/services/config/server.js +215 -0
  26. package/src/services/config/simulador.js +1260 -0
  27. package/src/services/dynamodb/index.js +7 -3
  28. package/src/services/dynamodb/server.js +4 -2
  29. package/src/services/dynamodb/simulator.js +39 -29
  30. package/src/services/eventbridge/index.js +55 -51
  31. package/src/services/eventbridge/server.js +209 -0
  32. package/src/services/eventbridge/simulator.js +684 -0
  33. package/src/services/index.js +30 -4
  34. package/src/services/kms/index.js +75 -0
  35. package/src/services/kms/server.js +67 -0
  36. package/src/services/kms/simulator.js +324 -0
  37. package/src/services/lambda/index.js +5 -0
  38. package/src/services/lambda/simulator.js +48 -38
  39. package/src/services/parameter-store/index.js +80 -0
  40. package/src/services/parameter-store/server.js +50 -0
  41. package/src/services/parameter-store/simulator.js +201 -0
  42. package/src/services/s3/index.js +7 -3
  43. package/src/services/s3/server.js +20 -13
  44. package/src/services/s3/simulator.js +163 -407
  45. package/src/services/secret-manager/index.js +80 -0
  46. package/src/services/secret-manager/server.js +50 -0
  47. package/src/services/secret-manager/simulator.js +171 -0
  48. package/src/services/sns/index.js +55 -42
  49. package/src/services/sns/server.js +580 -0
  50. package/src/services/sns/simulator.js +1482 -0
  51. package/src/services/sqs/index.js +2 -4
  52. package/src/services/sqs/server.js +4 -2
  53. package/src/services/xray/index.js +83 -0
  54. package/src/services/xray/server.js +308 -0
  55. package/src/services/xray/simulador.js +994 -0
  56. package/src/utils/cloudtrail-audit.js +129 -0
  57. package/src/utils/local-store.js +18 -2
@@ -0,0 +1,994 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview X-Ray Simulator
5
+ *
6
+ * Suporta:
7
+ * Segments / Traces:
8
+ * - PutTraceSegments → recebe segmentos e subsegmentos
9
+ * - BatchGetTraces → recupera traces por IDs
10
+ * - GetTraceSummaries → lista summaries com filtro e paginação
11
+ * - GetTraceGraph → grafo de serviços para um trace específico
12
+ *
13
+ * Service Graph:
14
+ * - GetServiceGraph → grafo de serviços por janela de tempo
15
+ *
16
+ * Groups:
17
+ * - CreateGroup / UpdateGroup / DeleteGroup / GetGroup / GetGroups
18
+ *
19
+ * Sampling Rules:
20
+ * - CreateSamplingRule / UpdateSamplingRule / DeleteSamplingRule
21
+ * - GetSamplingRules / GetSamplingStatisticSummaries / GetSamplingTargets
22
+ *
23
+ * Encryption:
24
+ * - PutEncryptionConfig / GetEncryptionConfig
25
+ *
26
+ * Tags:
27
+ * - TagResource / UntagResource / ListTagsForResource
28
+ *
29
+ * Insights:
30
+ * - GetInsight / GetInsightSummaries / GetInsightEvents / GetInsightImpactGraph
31
+ *
32
+ * Persistência via LocalStore
33
+ */
34
+
35
+ const { randomUUID } = require('crypto');
36
+
37
+ // ─── Erros tipados ────────────────────────────────────────────────────────────
38
+
39
+ class XRayError extends Error {
40
+ constructor(code, message, statusCode = 400) {
41
+ super(message);
42
+ this.code = code;
43
+ this.statusCode = statusCode;
44
+ }
45
+ }
46
+
47
+ const Errors = {
48
+ InvalidRequest: (msg) =>
49
+ new XRayError('InvalidRequestException', msg, 400),
50
+ TraceNotFound: (id) =>
51
+ new XRayError('TraceNotFoundException', `Trace not found: ${id}`, 404),
52
+ GroupNotFound: (name) =>
53
+ new XRayError('InvalidRequestException', `Group not found: ${name}`, 404),
54
+ GroupAlreadyExists: (name) =>
55
+ new XRayError('InvalidRequestException', `Group already exists: ${name}`, 400),
56
+ SamplingRuleNotFound: (name) =>
57
+ new XRayError('InvalidRequestException', `Sampling rule not found: ${name}`, 404),
58
+ SamplingRuleAlreadyExists: (name) =>
59
+ new XRayError('InvalidRequestException', `Sampling rule already exists: ${name}`, 400),
60
+ ThrottledException: () =>
61
+ new XRayError('ThrottledException', 'Rate exceeded', 429),
62
+ ResourceNotFound: (arn) =>
63
+ new XRayError('ResourceNotFoundException', `Resource not found: ${arn}`, 404),
64
+ };
65
+
66
+ // ─── Constantes ───────────────────────────────────────────────────────────────
67
+
68
+ const REGION = 'us-east-1';
69
+ const ACCOUNT_ID = '000000000000';
70
+ const MAX_TRACES_PER_REQUEST = 5;
71
+ const MAX_RESULTS_DEFAULT = 1000;
72
+ const TRACE_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 dias
73
+
74
+ // ─── Utilitários ──────────────────────────────────────────────────────────────
75
+
76
+ function groupArn(groupName) {
77
+ return `arn:aws:xray:${REGION}:${ACCOUNT_ID}:group/${groupName}`;
78
+ }
79
+
80
+ function samplingRuleArn(ruleName) {
81
+ return `arn:aws:xray:${REGION}:${ACCOUNT_ID}:sampling-rule/${ruleName}`;
82
+ }
83
+
84
+ function generateTraceId() {
85
+ const epoch = Math.floor(Date.now() / 1000).toString(16);
86
+ const unique = randomUUID().replace(/-/g, '').substring(0, 24);
87
+ return `1-${epoch}-${unique}`;
88
+ }
89
+
90
+ function parseSegmentDocument(doc) {
91
+ try {
92
+ return typeof doc === 'string' ? JSON.parse(doc) : doc;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ function nowIso() {
99
+ return new Date().toISOString();
100
+ }
101
+
102
+ function extractServiceFromSegment(segment) {
103
+ return {
104
+ Name: segment.name || 'unknown',
105
+ Type: segment.origin || 'AWS::Other',
106
+ AccountId: ACCOUNT_ID,
107
+ State: { ok: true },
108
+ StartTime: segment.start_time ? new Date(segment.start_time * 1000).toISOString() : nowIso(),
109
+ EndTime: segment.end_time ? new Date(segment.end_time * 1000).toISOString() : nowIso(),
110
+ Edges: [],
111
+ SummaryStatistics: {
112
+ OkCount: segment.error ? 0 : 1,
113
+ ErrorStatistics: {
114
+ ThrottleCount: segment.throttle ? 1 : 0,
115
+ OtherCount: segment.error ? 1 : 0,
116
+ TotalCount: segment.error ? 1 : 0,
117
+ },
118
+ FaultStatistics: {
119
+ OtherCount: segment.fault ? 1 : 0,
120
+ TotalCount: segment.fault ? 1 : 0,
121
+ },
122
+ TotalCount: 1,
123
+ TotalResponseTime: segment.end_time && segment.start_time
124
+ ? segment.end_time - segment.start_time
125
+ : 0,
126
+ },
127
+ };
128
+ }
129
+
130
+ function buildTraceSummary(trace) {
131
+ const rootSegment = trace.segments[0] || {};
132
+ const seg = parseSegmentDocument(rootSegment.Document || rootSegment) || {};
133
+
134
+ const hasError = trace.segments.some(s => {
135
+ const d = parseSegmentDocument(s.Document || s) || {};
136
+ return d.error || d.fault;
137
+ });
138
+
139
+ return {
140
+ Id: trace.id,
141
+ Duration: trace.duration || 0,
142
+ ResponseTime: trace.duration || 0,
143
+ HasFault: trace.segments.some(s => {
144
+ const d = parseSegmentDocument(s.Document || s) || {};
145
+ return !!d.fault;
146
+ }),
147
+ HasError: hasError,
148
+ HasThrottle: trace.segments.some(s => {
149
+ const d = parseSegmentDocument(s.Document || s) || {};
150
+ return !!d.throttle;
151
+ }),
152
+ IsPartial: trace.isPartial || false,
153
+ Http: seg.http ? {
154
+ HttpURL: { Value: seg.http.request?.url || '' },
155
+ HttpStatus: { Value: seg.http.response?.status || 0 },
156
+ HttpMethod: { Value: seg.http.request?.method || '' },
157
+ UserAgent: { Value: seg.http.request?.user_agent || '' },
158
+ ClientIp: { Value: seg.http.request?.client_ip || '' },
159
+ } : undefined,
160
+ Annotations: seg.annotations || {},
161
+ Users: seg.user ? [{ UserName: seg.user, ServiceIds: [] }] : [],
162
+ ServiceIds: [{
163
+ Name: seg.name || 'unknown',
164
+ AccountId: ACCOUNT_ID,
165
+ Type: seg.origin || 'AWS::Other',
166
+ }],
167
+ ResourceARNs: [],
168
+ InstanceIds: [],
169
+ AvailabilityZones: [{ Name: 'us-east-1a' }],
170
+ EntryPoint: {
171
+ Name: seg.name || 'unknown',
172
+ AccountId: ACCOUNT_ID,
173
+ Type: seg.origin || 'AWS::Other',
174
+ },
175
+ MatchedEventTime: trace.createdAt,
176
+ Revision: trace.revision || 0,
177
+ };
178
+ }
179
+
180
+ function applyFilterExpression(traces, filterExpression) {
181
+ if (!filterExpression) return traces;
182
+
183
+ return traces.filter(trace => {
184
+ const summary = buildTraceSummary(trace);
185
+
186
+ // service("name")
187
+ const serviceMatch = filterExpression.match(/service\("([^"]+)"\)/);
188
+ if (serviceMatch) {
189
+ const svcName = serviceMatch[1].toLowerCase();
190
+ return summary.ServiceIds.some(s => s.Name.toLowerCase().includes(svcName));
191
+ }
192
+
193
+ // annotation.key = "value"
194
+ const annotationMatch = filterExpression.match(/annotation\.(\w+)\s*=\s*"([^"]+)"/);
195
+ if (annotationMatch) {
196
+ const key = annotationMatch[1];
197
+ const val = annotationMatch[2];
198
+ return summary.Annotations[key] === val;
199
+ }
200
+
201
+ // http.status = 500
202
+ const statusMatch = filterExpression.match(/http\.status\s*=\s*(\d+)/);
203
+ if (statusMatch) {
204
+ const status = parseInt(statusMatch[1]);
205
+ return summary.Http?.HttpStatus?.Value === status;
206
+ }
207
+
208
+ // fault
209
+ if (filterExpression.includes('fault')) return summary.HasFault;
210
+
211
+ // error
212
+ if (filterExpression.includes('error')) return summary.HasError;
213
+
214
+ // responsetime > X
215
+ const rtMatch = filterExpression.match(/responsetime\s*([><=!]+)\s*([\d.]+)/);
216
+ if (rtMatch) {
217
+ const op = rtMatch[1];
218
+ const val = parseFloat(rtMatch[2]);
219
+ const rt = summary.ResponseTime;
220
+ if (op === '>') return rt > val;
221
+ if (op === '<') return rt < val;
222
+ if (op === '>=') return rt >= val;
223
+ if (op === '<=') return rt <= val;
224
+ if (op === '=') return rt === val;
225
+ }
226
+
227
+ return true;
228
+ });
229
+ }
230
+
231
+ // ─── XRaySimulator ────────────────────────────────────────────────────────────
232
+
233
+ class XRaySimulator {
234
+ constructor(config, store, logger) {
235
+ this.config = config || {};
236
+ this.store = store;
237
+ this.logger = logger || console;
238
+
239
+ // Estado em memória
240
+ this._traces = new Map(); // traceId → { id, segments, createdAt, duration, ... }
241
+ this._groups = new Map(); // groupName → group object
242
+ this._samplingRules = new Map(); // ruleName → rule object
243
+ this._encryptionConfig = {
244
+ Type: 'NONE',
245
+ Status: 'ACTIVE',
246
+ };
247
+ this._insights = new Map(); // insightId → insight object
248
+ this._tags = new Map(); // arn → { key: value }
249
+
250
+ // Injetado externamente
251
+ this.cloudwatchSimulator = null;
252
+ this.cloudtrailSimulator = null;
253
+ }
254
+
255
+ // ─── Persistência ───────────────────────────────────────────────────────────
256
+
257
+ async load() {
258
+ try {
259
+ const data = await this.store.load('xray');
260
+ if (data) {
261
+ if (data.traces) {
262
+ this._traces = new Map(Object.entries(data.traces));
263
+ }
264
+ if (data.groups) {
265
+ this._groups = new Map(Object.entries(data.groups));
266
+ }
267
+ if (data.samplingRules) {
268
+ this._samplingRules = new Map(Object.entries(data.samplingRules));
269
+ }
270
+ if (data.encryptionConfig) {
271
+ this._encryptionConfig = data.encryptionConfig;
272
+ }
273
+ if (data.tags) {
274
+ this._tags = new Map(Object.entries(data.tags));
275
+ }
276
+ this.logger.info(`[XRay] Loaded ${this._traces.size} traces, ${this._groups.size} groups`);
277
+ }
278
+
279
+ // Garante grupo e sampling rule padrão
280
+ this._ensureDefaults();
281
+ } catch (err) {
282
+ this.logger.warn('[XRay] No persisted data found, starting fresh');
283
+ this._ensureDefaults();
284
+ }
285
+ }
286
+
287
+ async save() {
288
+ try {
289
+ const data = {
290
+ traces: Object.fromEntries(this._traces),
291
+ groups: Object.fromEntries(this._groups),
292
+ samplingRules: Object.fromEntries(this._samplingRules),
293
+ encryptionConfig: this._encryptionConfig,
294
+ tags: Object.fromEntries(this._tags),
295
+ };
296
+ await this.store.save('xray', data);
297
+ } catch (err) {
298
+ this.logger.error('[XRay] Failed to save data:', err.message);
299
+ }
300
+ }
301
+
302
+ _ensureDefaults() {
303
+ // Grupo padrão "Default"
304
+ if (!this._groups.has('Default')) {
305
+ this._groups.set('Default', {
306
+ GroupName: 'Default',
307
+ GroupARN: groupArn('Default'),
308
+ FilterExpression: '',
309
+ InsightsConfiguration: {
310
+ InsightsEnabled: false,
311
+ NotificationsEnabled: false,
312
+ },
313
+ Tags: {},
314
+ });
315
+ }
316
+
317
+ // Sampling rule padrão
318
+ if (!this._samplingRules.has('Default')) {
319
+ this._samplingRules.set('Default', {
320
+ SamplingRule: {
321
+ RuleName: 'Default',
322
+ RuleARN: samplingRuleArn('Default'),
323
+ ResourceARN: '*',
324
+ Priority: 10000,
325
+ FixedRate: 0.05,
326
+ ReservoirSize: 1,
327
+ ServiceName: '*',
328
+ ServiceType: '*',
329
+ Host: '*',
330
+ HTTPMethod: '*',
331
+ URLPath: '*',
332
+ Version: 1,
333
+ Attributes: {},
334
+ },
335
+ CreatedAt: nowIso(),
336
+ ModifiedAt: nowIso(),
337
+ });
338
+ }
339
+ }
340
+
341
+ // ─── PutTraceSegments ────────────────────────────────────────────────────────
342
+
343
+ async putTraceSegments({ TraceSegmentDocuments }) {
344
+ if (!Array.isArray(TraceSegmentDocuments) || TraceSegmentDocuments.length === 0) {
345
+ throw Errors.InvalidRequest('TraceSegmentDocuments is required and must be non-empty');
346
+ }
347
+
348
+ const unprocessedTraceSegments = [];
349
+
350
+ for (const docStr of TraceSegmentDocuments) {
351
+ try {
352
+ const segment = parseSegmentDocument(docStr);
353
+ if (!segment) {
354
+ unprocessedTraceSegments.push({
355
+ Id: 'unknown',
356
+ ErrorCode: 'ParseError',
357
+ Message: 'Failed to parse segment document',
358
+ });
359
+ continue;
360
+ }
361
+
362
+ const traceId = segment.trace_id || generateTraceId();
363
+ const segmentId = segment.id || randomUUID().replace(/-/g, '').substring(0, 16);
364
+
365
+ let trace = this._traces.get(traceId);
366
+ if (!trace) {
367
+ trace = {
368
+ id: traceId,
369
+ segments: [],
370
+ createdAt: nowIso(),
371
+ duration: 0,
372
+ isPartial: false,
373
+ revision: 0,
374
+ };
375
+ this._traces.set(traceId, trace);
376
+ }
377
+
378
+ // Verifica se segmento já existe (pelo id) e substitui ou adiciona
379
+ const existingIdx = trace.segments.findIndex(s => {
380
+ const d = parseSegmentDocument(s.Document || s) || {};
381
+ return d.id === segmentId;
382
+ });
383
+
384
+ const segmentEntry = {
385
+ Id: segmentId,
386
+ Document: docStr,
387
+ };
388
+
389
+ if (existingIdx >= 0) {
390
+ trace.segments[existingIdx] = segmentEntry;
391
+ } else {
392
+ trace.segments.push(segmentEntry);
393
+ }
394
+
395
+ // Atualiza duração do trace
396
+ if (segment.start_time && segment.end_time) {
397
+ const duration = segment.end_time - segment.start_time;
398
+ if (duration > trace.duration) {
399
+ trace.duration = parseFloat(duration.toFixed(6));
400
+ }
401
+ }
402
+
403
+ // Marca como parcial se não tem end_time
404
+ trace.isPartial = !segment.end_time;
405
+ trace.revision = (trace.revision || 0) + 1;
406
+
407
+ this.logger.debug(`[XRay] Stored segment ${segmentId} for trace ${traceId}`);
408
+ } catch (err) {
409
+ unprocessedTraceSegments.push({
410
+ Id: 'unknown',
411
+ ErrorCode: 'InternalError',
412
+ Message: err.message,
413
+ });
414
+ }
415
+ }
416
+
417
+ await this.save();
418
+
419
+ return { UnprocessedTraceSegments: unprocessedTraceSegments };
420
+ }
421
+
422
+ // ─── BatchGetTraces ──────────────────────────────────────────────────────────
423
+
424
+ async batchGetTraces({ TraceIds, NextToken }) {
425
+ if (!Array.isArray(TraceIds) || TraceIds.length === 0) {
426
+ throw Errors.InvalidRequest('TraceIds is required and must be non-empty');
427
+ }
428
+
429
+ if (TraceIds.length > MAX_TRACES_PER_REQUEST) {
430
+ throw Errors.InvalidRequest(`Maximum of ${MAX_TRACES_PER_REQUEST} trace IDs per request`);
431
+ }
432
+
433
+ const traces = [];
434
+ const unprocessedTraceIds = [];
435
+
436
+ for (const traceId of TraceIds) {
437
+ const trace = this._traces.get(traceId);
438
+ if (trace) {
439
+ traces.push({
440
+ Id: trace.id,
441
+ Duration: trace.duration,
442
+ LimitExceeded: false,
443
+ Segments: trace.segments,
444
+ });
445
+ } else {
446
+ unprocessedTraceIds.push(traceId);
447
+ }
448
+ }
449
+
450
+ return {
451
+ Traces: traces,
452
+ UnprocessedTraceIds: unprocessedTraceIds,
453
+ NextToken: null,
454
+ };
455
+ }
456
+
457
+ // ─── GetTraceSummaries ───────────────────────────────────────────────────────
458
+
459
+ async getTraceSummaries({ StartTime, EndTime, TimeRangeType, Sampling, FilterExpression, NextToken }) {
460
+ if (!StartTime || !EndTime) {
461
+ throw Errors.InvalidRequest('StartTime and EndTime are required');
462
+ }
463
+
464
+ const startMs = new Date(StartTime * 1000 || StartTime).getTime();
465
+ const endMs = new Date(EndTime * 1000 || EndTime).getTime();
466
+
467
+ let traces = Array.from(this._traces.values()).filter(trace => {
468
+ const createdMs = new Date(trace.createdAt).getTime();
469
+ return createdMs >= startMs && createdMs <= endMs;
470
+ });
471
+
472
+ // Aplica filtro
473
+ if (FilterExpression) {
474
+ traces = applyFilterExpression(traces, FilterExpression);
475
+ }
476
+
477
+ // Paginação simples
478
+ let startIndex = 0;
479
+ if (NextToken) {
480
+ try {
481
+ startIndex = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'));
482
+ } catch {
483
+ startIndex = 0;
484
+ }
485
+ }
486
+
487
+ const pageSize = 100;
488
+ const pageTraces = traces.slice(startIndex, startIndex + pageSize);
489
+ const newNextToken = startIndex + pageSize < traces.length
490
+ ? Buffer.from(String(startIndex + pageSize)).toString('base64')
491
+ : null;
492
+
493
+ const summaries = pageTraces.map(buildTraceSummary);
494
+
495
+ return {
496
+ TraceSummaries: summaries,
497
+ ApproximateTime: new Date().toISOString(),
498
+ TracesProcessedCount: traces.length,
499
+ NextToken: newNextToken,
500
+ };
501
+ }
502
+
503
+ // ─── GetTraceGraph ───────────────────────────────────────────────────────────
504
+
505
+ async getTraceGraph({ TraceIds, NextToken }) {
506
+ if (!Array.isArray(TraceIds) || TraceIds.length === 0) {
507
+ throw Errors.InvalidRequest('TraceIds is required');
508
+ }
509
+
510
+ const services = [];
511
+ const seenServices = new Set();
512
+
513
+ for (const traceId of TraceIds) {
514
+ const trace = this._traces.get(traceId);
515
+ if (!trace) continue;
516
+
517
+ for (const seg of trace.segments) {
518
+ const doc = parseSegmentDocument(seg.Document || seg) || {};
519
+ const serviceKey = `${doc.name}:${doc.origin || 'AWS::Other'}`;
520
+
521
+ if (!seenServices.has(serviceKey)) {
522
+ seenServices.add(serviceKey);
523
+ services.push(extractServiceFromSegment(doc));
524
+ }
525
+
526
+ // Subsegmentos como edges
527
+ if (doc.subsegments) {
528
+ for (const sub of doc.subsegments) {
529
+ const subKey = `${sub.name}:${sub.namespace || 'remote'}`;
530
+ if (!seenServices.has(subKey)) {
531
+ seenServices.add(subKey);
532
+ services.push({
533
+ Name: sub.name || 'unknown',
534
+ Type: sub.namespace === 'aws' ? 'AWS::Lambda::Function' : 'remote',
535
+ AccountId: ACCOUNT_ID,
536
+ State: { ok: true },
537
+ StartTime: sub.start_time ? new Date(sub.start_time * 1000).toISOString() : nowIso(),
538
+ EndTime: sub.end_time ? new Date(sub.end_time * 1000).toISOString() : nowIso(),
539
+ Edges: [],
540
+ SummaryStatistics: {
541
+ OkCount: 1,
542
+ ErrorStatistics: { ThrottleCount: 0, OtherCount: 0, TotalCount: 0 },
543
+ FaultStatistics: { OtherCount: 0, TotalCount: 0 },
544
+ TotalCount: 1,
545
+ TotalResponseTime: sub.end_time && sub.start_time
546
+ ? sub.end_time - sub.start_time
547
+ : 0,
548
+ },
549
+ });
550
+ }
551
+ }
552
+ }
553
+ }
554
+ }
555
+
556
+ return {
557
+ Services: services,
558
+ NextToken: null,
559
+ };
560
+ }
561
+
562
+ // ─── GetServiceGraph ─────────────────────────────────────────────────────────
563
+
564
+ async getServiceGraph({ StartTime, EndTime, GroupName, GroupARN, NextToken }) {
565
+ if (!StartTime || !EndTime) {
566
+ throw Errors.InvalidRequest('StartTime and EndTime are required');
567
+ }
568
+
569
+ const startMs = new Date(StartTime * 1000 || StartTime).getTime();
570
+ const endMs = new Date(EndTime * 1000 || EndTime).getTime();
571
+
572
+ const traces = Array.from(this._traces.values()).filter(trace => {
573
+ const createdMs = new Date(trace.createdAt).getTime();
574
+ return createdMs >= startMs && createdMs <= endMs;
575
+ });
576
+
577
+ // Agrega serviços únicos de todos os traces na janela de tempo
578
+ const servicesMap = new Map();
579
+
580
+ for (const trace of traces) {
581
+ for (const seg of trace.segments) {
582
+ const doc = parseSegmentDocument(seg.Document || seg) || {};
583
+ const key = doc.name || 'unknown';
584
+
585
+ if (!servicesMap.has(key)) {
586
+ servicesMap.set(key, extractServiceFromSegment(doc));
587
+ } else {
588
+ // Agrega estatísticas
589
+ const existing = servicesMap.get(key);
590
+ existing.SummaryStatistics.TotalCount += 1;
591
+ if (!doc.error && !doc.fault) existing.SummaryStatistics.OkCount += 1;
592
+ if (doc.error) existing.SummaryStatistics.ErrorStatistics.TotalCount += 1;
593
+ if (doc.fault) existing.SummaryStatistics.FaultStatistics.TotalCount += 1;
594
+ const dur = doc.end_time && doc.start_time ? doc.end_time - doc.start_time : 0;
595
+ existing.SummaryStatistics.TotalResponseTime += dur;
596
+ }
597
+ }
598
+ }
599
+
600
+ return {
601
+ Services: Array.from(servicesMap.values()),
602
+ StartTime: new Date(startMs).toISOString(),
603
+ EndTime: new Date(endMs).toISOString(),
604
+ ContainsOldGroupVersions: false,
605
+ NextToken: null,
606
+ };
607
+ }
608
+
609
+ // ─── Groups ──────────────────────────────────────────────────────────────────
610
+
611
+ async createGroup({ GroupName, FilterExpression, InsightsConfiguration, Tags }) {
612
+ if (!GroupName) throw Errors.InvalidRequest('GroupName is required');
613
+ if (this._groups.has(GroupName)) throw Errors.GroupAlreadyExists(GroupName);
614
+
615
+ const group = {
616
+ GroupName,
617
+ GroupARN: groupArn(GroupName),
618
+ FilterExpression: FilterExpression || '',
619
+ InsightsConfiguration: InsightsConfiguration || {
620
+ InsightsEnabled: false,
621
+ NotificationsEnabled: false,
622
+ },
623
+ Tags: Tags || {},
624
+ };
625
+
626
+ this._groups.set(GroupName, group);
627
+ if (Tags) this._tags.set(groupArn(GroupName), Tags);
628
+ await this.save();
629
+
630
+ return { Group: group };
631
+ }
632
+
633
+ async updateGroup({ GroupName, GroupARN, FilterExpression, InsightsConfiguration }) {
634
+ const name = GroupName || (GroupARN && GroupARN.split('/').pop());
635
+ if (!name) throw Errors.InvalidRequest('GroupName or GroupARN is required');
636
+
637
+ const group = this._groups.get(name);
638
+ if (!group) throw Errors.GroupNotFound(name);
639
+
640
+ if (FilterExpression !== undefined) group.FilterExpression = FilterExpression;
641
+ if (InsightsConfiguration !== undefined) group.InsightsConfiguration = InsightsConfiguration;
642
+
643
+ await this.save();
644
+ return { Group: group };
645
+ }
646
+
647
+ async deleteGroup({ GroupName, GroupARN }) {
648
+ const name = GroupName || (GroupARN && GroupARN.split('/').pop());
649
+ if (!name) throw Errors.InvalidRequest('GroupName or GroupARN is required');
650
+ if (name === 'Default') throw Errors.InvalidRequest('Cannot delete Default group');
651
+
652
+ const group = this._groups.get(name);
653
+ if (!group) throw Errors.GroupNotFound(name);
654
+
655
+ this._groups.delete(name);
656
+ this._tags.delete(groupArn(name));
657
+ await this.save();
658
+
659
+ return {};
660
+ }
661
+
662
+ async getGroup({ GroupName, GroupARN }) {
663
+ const name = GroupName || (GroupARN && GroupARN.split('/').pop());
664
+ if (!name) throw Errors.InvalidRequest('GroupName or GroupARN is required');
665
+
666
+ const group = this._groups.get(name);
667
+ if (!group) throw Errors.GroupNotFound(name);
668
+
669
+ return { Group: group };
670
+ }
671
+
672
+ async getGroups({ NextToken }) {
673
+ const groups = Array.from(this._groups.values());
674
+
675
+ let startIndex = 0;
676
+ if (NextToken) {
677
+ try {
678
+ startIndex = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'));
679
+ } catch { startIndex = 0; }
680
+ }
681
+
682
+ const page = groups.slice(startIndex, startIndex + 25);
683
+ const newNextToken = startIndex + 25 < groups.length
684
+ ? Buffer.from(String(startIndex + 25)).toString('base64')
685
+ : null;
686
+
687
+ return {
688
+ Groups: page,
689
+ NextToken: newNextToken,
690
+ };
691
+ }
692
+
693
+ // ─── Sampling Rules ──────────────────────────────────────────────────────────
694
+
695
+ async createSamplingRule({ SamplingRule, Tags }) {
696
+ if (!SamplingRule || !SamplingRule.RuleName) {
697
+ throw Errors.InvalidRequest('SamplingRule.RuleName is required');
698
+ }
699
+
700
+ const { RuleName } = SamplingRule;
701
+ if (this._samplingRules.has(RuleName)) {
702
+ throw Errors.SamplingRuleAlreadyExists(RuleName);
703
+ }
704
+
705
+ const rule = {
706
+ SamplingRule: {
707
+ ...SamplingRule,
708
+ RuleARN: samplingRuleArn(RuleName),
709
+ Version: SamplingRule.Version || 1,
710
+ Attributes: SamplingRule.Attributes || {},
711
+ },
712
+ CreatedAt: nowIso(),
713
+ ModifiedAt: nowIso(),
714
+ };
715
+
716
+ this._samplingRules.set(RuleName, rule);
717
+ if (Tags) this._tags.set(samplingRuleArn(RuleName), Tags);
718
+ await this.save();
719
+
720
+ return { SamplingRuleRecord: rule };
721
+ }
722
+
723
+ async updateSamplingRule({ SamplingRuleUpdate }) {
724
+ if (!SamplingRuleUpdate) throw Errors.InvalidRequest('SamplingRuleUpdate is required');
725
+
726
+ const name = SamplingRuleUpdate.RuleName ||
727
+ (SamplingRuleUpdate.RuleARN && SamplingRuleUpdate.RuleARN.split('/').pop());
728
+ if (!name) throw Errors.InvalidRequest('RuleName or RuleARN is required');
729
+
730
+ const existing = this._samplingRules.get(name);
731
+ if (!existing) throw Errors.SamplingRuleNotFound(name);
732
+
733
+ Object.assign(existing.SamplingRule, SamplingRuleUpdate);
734
+ existing.ModifiedAt = nowIso();
735
+
736
+ await this.save();
737
+ return { SamplingRuleRecord: existing };
738
+ }
739
+
740
+ async deleteSamplingRule({ RuleName, RuleARN }) {
741
+ const name = RuleName || (RuleARN && RuleARN.split('/').pop());
742
+ if (!name) throw Errors.InvalidRequest('RuleName or RuleARN is required');
743
+ if (name === 'Default') throw Errors.InvalidRequest('Cannot delete Default sampling rule');
744
+
745
+ const rule = this._samplingRules.get(name);
746
+ if (!rule) throw Errors.SamplingRuleNotFound(name);
747
+
748
+ this._samplingRules.delete(name);
749
+ this._tags.delete(samplingRuleArn(name));
750
+ await this.save();
751
+
752
+ return { SamplingRuleRecord: rule };
753
+ }
754
+
755
+ async getSamplingRules({ NextToken }) {
756
+ const rules = Array.from(this._samplingRules.values());
757
+
758
+ let startIndex = 0;
759
+ if (NextToken) {
760
+ try {
761
+ startIndex = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'));
762
+ } catch { startIndex = 0; }
763
+ }
764
+
765
+ const page = rules.slice(startIndex, startIndex + 25);
766
+ const newNextToken = startIndex + 25 < rules.length
767
+ ? Buffer.from(String(startIndex + 25)).toString('base64')
768
+ : null;
769
+
770
+ return {
771
+ SamplingRuleRecords: page,
772
+ NextToken: newNextToken,
773
+ };
774
+ }
775
+
776
+ async getSamplingStatisticSummaries({ SamplingStatisticsDocuments }) {
777
+ // Simula resposta com targets baseados nas regras existentes
778
+ const targets = (SamplingStatisticsDocuments || []).map(doc => ({
779
+ RuleName: doc.RuleName,
780
+ FixedRate: this._samplingRules.get(doc.RuleName)?.SamplingRule?.FixedRate || 0.05,
781
+ ReservoirQuota: this._samplingRules.get(doc.RuleName)?.SamplingRule?.ReservoirSize || 1,
782
+ ReservoirQuotaTTL: Math.floor(Date.now() / 1000) + 10,
783
+ Interval: 10,
784
+ }));
785
+
786
+ return {
787
+ SamplingStatisticSummaries: [],
788
+ NextToken: null,
789
+ };
790
+ }
791
+
792
+ async getSamplingTargets({ SamplingStatisticsDocuments }) {
793
+ if (!Array.isArray(SamplingStatisticsDocuments)) {
794
+ throw Errors.InvalidRequest('SamplingStatisticsDocuments is required');
795
+ }
796
+
797
+ const targets = SamplingStatisticsDocuments.map(doc => {
798
+ const rule = this._samplingRules.get(doc.RuleName);
799
+ return {
800
+ RuleName: doc.RuleName,
801
+ FixedRate: rule?.SamplingRule?.FixedRate || 0.05,
802
+ ReservoirQuota: rule?.SamplingRule?.ReservoirSize || 1,
803
+ ReservoirQuotaTTL: Math.floor(Date.now() / 1000) + 10,
804
+ Interval: 10,
805
+ };
806
+ });
807
+
808
+ return {
809
+ SamplingTargetDocuments: targets,
810
+ LastRuleModification: nowIso(),
811
+ UnprocessedStatistics: [],
812
+ };
813
+ }
814
+
815
+ // ─── Encryption Config ───────────────────────────────────────────────────────
816
+
817
+ async putEncryptionConfig({ Type, KeyId }) {
818
+ if (!Type) throw Errors.InvalidRequest('Type is required');
819
+ if (!['NONE', 'KMS'].includes(Type)) {
820
+ throw Errors.InvalidRequest('Type must be NONE or KMS');
821
+ }
822
+ if (Type === 'KMS' && !KeyId) {
823
+ throw Errors.InvalidRequest('KeyId is required when Type is KMS');
824
+ }
825
+
826
+ this._encryptionConfig = {
827
+ KeyId: Type === 'KMS' ? KeyId : undefined,
828
+ Status: 'ACTIVE',
829
+ Type,
830
+ };
831
+
832
+ await this.save();
833
+ return { EncryptionConfig: this._encryptionConfig };
834
+ }
835
+
836
+ async getEncryptionConfig() {
837
+ return { EncryptionConfig: this._encryptionConfig };
838
+ }
839
+
840
+ // ─── Tags ────────────────────────────────────────────────────────────────────
841
+
842
+ async tagResource({ ResourceARN, Tags }) {
843
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
844
+ if (!Tags || typeof Tags !== 'object') throw Errors.InvalidRequest('Tags is required');
845
+
846
+ const existing = this._tags.get(ResourceARN) || {};
847
+ this._tags.set(ResourceARN, { ...existing, ...Tags });
848
+ await this.save();
849
+
850
+ return {};
851
+ }
852
+
853
+ async untagResource({ ResourceARN, TagKeys }) {
854
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
855
+ if (!Array.isArray(TagKeys)) throw Errors.InvalidRequest('TagKeys is required');
856
+
857
+ const existing = this._tags.get(ResourceARN) || {};
858
+ for (const key of TagKeys) {
859
+ delete existing[key];
860
+ }
861
+ this._tags.set(ResourceARN, existing);
862
+ await this.save();
863
+
864
+ return {};
865
+ }
866
+
867
+ async listTagsForResource({ ResourceARN }) {
868
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
869
+ const tags = this._tags.get(ResourceARN) || {};
870
+ return { Tags: tags };
871
+ }
872
+
873
+ // ─── Insights ────────────────────────────────────────────────────────────────
874
+
875
+ async getInsight({ InsightId }) {
876
+ if (!InsightId) throw Errors.InvalidRequest('InsightId is required');
877
+ const insight = this._insights.get(InsightId);
878
+ if (!insight) {
879
+ return {
880
+ Insight: {
881
+ InsightId,
882
+ State: 'CLOSED',
883
+ Summary: 'No insight found',
884
+ },
885
+ };
886
+ }
887
+ return { Insight: insight };
888
+ }
889
+
890
+ async getInsightSummaries({ States, GroupARN, GroupName, StartTime, EndTime, MaxResults, NextToken }) {
891
+ const insights = Array.from(this._insights.values());
892
+ return {
893
+ InsightSummaries: insights,
894
+ NextToken: null,
895
+ };
896
+ }
897
+
898
+ async getInsightEvents({ InsightId, MaxResults, NextToken }) {
899
+ if (!InsightId) throw Errors.InvalidRequest('InsightId is required');
900
+ return {
901
+ InsightEvents: [],
902
+ NextToken: null,
903
+ };
904
+ }
905
+
906
+ async getInsightImpactGraph({ InsightId, StartTime, EndTime, NextToken }) {
907
+ if (!InsightId) throw Errors.InvalidRequest('InsightId is required');
908
+ return {
909
+ InsightId,
910
+ ServiceGraphStartTime: StartTime || nowIso(),
911
+ ServiceGraphEndTime: EndTime || nowIso(),
912
+ Services: [],
913
+ NextToken: null,
914
+ };
915
+ }
916
+
917
+ // ─── Método interno: record trace de outros serviços ─────────────────────────
918
+
919
+ recordServiceCall({ serviceName, operationName, traceId, startTime, endTime, statusCode, error }) {
920
+ const tId = traceId || generateTraceId();
921
+ const segmentId = randomUUID().replace(/-/g, '').substring(0, 16);
922
+
923
+ const now = Date.now() / 1000;
924
+ const start = startTime || now;
925
+ const end = endTime || now + 0.001;
926
+
927
+ const segment = {
928
+ name: serviceName || 'unknown',
929
+ id: segmentId,
930
+ trace_id: tId,
931
+ start_time: start,
932
+ end_time: end,
933
+ origin: `AWS::${serviceName}`,
934
+ http: {
935
+ response: { status: statusCode || 200 },
936
+ },
937
+ error: !!error,
938
+ fault: statusCode >= 500,
939
+ annotations: {
940
+ operation: operationName || '',
941
+ },
942
+ };
943
+
944
+ const docStr = JSON.stringify(segment);
945
+
946
+ let trace = this._traces.get(tId);
947
+ if (!trace) {
948
+ trace = {
949
+ id: tId,
950
+ segments: [],
951
+ createdAt: nowIso(),
952
+ duration: end - start,
953
+ isPartial: false,
954
+ revision: 0,
955
+ };
956
+ this._traces.set(tId, trace);
957
+ }
958
+
959
+ trace.segments.push({ Id: segmentId, Document: docStr });
960
+ trace.revision = (trace.revision || 0) + 1;
961
+
962
+ // Salva de forma assíncrona (não bloqueia)
963
+ this.save().catch(() => {});
964
+
965
+ return tId;
966
+ }
967
+
968
+ // ─── Reset ───────────────────────────────────────────────────────────────────
969
+
970
+ async reset() {
971
+ this._traces.clear();
972
+ this._groups.clear();
973
+ this._samplingRules.clear();
974
+ this._insights.clear();
975
+ this._tags.clear();
976
+ this._encryptionConfig = { Type: 'NONE', Status: 'ACTIVE' };
977
+ this._ensureDefaults();
978
+ await this.save();
979
+ this.logger.info('[XRay] Reset complete');
980
+ }
981
+
982
+ // ─── Status ──────────────────────────────────────────────────────────────────
983
+
984
+ getStatus() {
985
+ return {
986
+ traces: this._traces.size,
987
+ groups: this._groups.size,
988
+ samplingRules: this._samplingRules.size,
989
+ encryptionType: this._encryptionConfig.Type,
990
+ };
991
+ }
992
+ }
993
+
994
+ module.exports = { XRaySimulator };