@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.
- package/README.md +235 -11
- package/package.json +12 -2
- package/src/config/default-config.js +1 -0
- package/src/index.js +18 -2
- package/src/server.js +36 -32
- package/src/services/apigateway/index.js +5 -0
- package/src/services/apigateway/server.js +20 -0
- package/src/services/apigateway/simulator.js +13 -3
- package/src/services/athena/index.js +75 -0
- package/src/services/athena/server.js +101 -0
- package/src/services/athena/simulador.js +998 -0
- package/src/services/athena/simulator.js +346 -0
- package/src/services/cloudformation/index.js +106 -0
- package/src/services/cloudformation/server.js +417 -0
- package/src/services/cloudformation/simulador.js +1045 -0
- package/src/services/cloudtrail/index.js +84 -0
- package/src/services/cloudtrail/server.js +235 -0
- package/src/services/cloudtrail/simulador.js +719 -0
- package/src/services/cloudwatch/index.js +84 -0
- package/src/services/cloudwatch/server.js +366 -0
- package/src/services/cloudwatch/simulador.js +1173 -0
- package/src/services/cognito/index.js +5 -0
- package/src/services/cognito/simulator.js +4 -0
- package/src/services/config/index.js +96 -0
- package/src/services/config/server.js +215 -0
- package/src/services/config/simulador.js +1260 -0
- package/src/services/dynamodb/index.js +7 -3
- package/src/services/dynamodb/server.js +4 -2
- package/src/services/dynamodb/simulator.js +39 -29
- package/src/services/eventbridge/index.js +55 -51
- package/src/services/eventbridge/server.js +209 -0
- package/src/services/eventbridge/simulator.js +684 -0
- package/src/services/index.js +30 -4
- package/src/services/kms/index.js +75 -0
- package/src/services/kms/server.js +67 -0
- package/src/services/kms/simulator.js +324 -0
- package/src/services/lambda/index.js +5 -0
- package/src/services/lambda/simulator.js +48 -38
- package/src/services/parameter-store/index.js +80 -0
- package/src/services/parameter-store/server.js +50 -0
- package/src/services/parameter-store/simulator.js +201 -0
- package/src/services/s3/index.js +7 -3
- package/src/services/s3/server.js +20 -13
- package/src/services/s3/simulator.js +163 -407
- package/src/services/secret-manager/index.js +80 -0
- package/src/services/secret-manager/server.js +50 -0
- package/src/services/secret-manager/simulator.js +171 -0
- package/src/services/sns/index.js +55 -42
- package/src/services/sns/server.js +580 -0
- package/src/services/sns/simulator.js +1482 -0
- package/src/services/sqs/index.js +2 -4
- package/src/services/sqs/server.js +4 -2
- package/src/services/xray/index.js +83 -0
- package/src/services/xray/server.js +308 -0
- package/src/services/xray/simulador.js +994 -0
- package/src/utils/cloudtrail-audit.js +129 -0
- package/src/utils/local-store.js +18 -2
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview CloudWatch Simulator
|
|
5
|
+
*
|
|
6
|
+
* Suporta:
|
|
7
|
+
* Logs:
|
|
8
|
+
* - CreateLogGroup / DeleteLogGroup / DescribeLogGroups
|
|
9
|
+
* - CreateLogStream / DeleteLogStream / DescribeLogStreams
|
|
10
|
+
* - PutLogEvents / GetLogEvents / FilterLogEvents
|
|
11
|
+
* - PutRetentionPolicy / DeleteRetentionPolicy
|
|
12
|
+
* - PutSubscriptionFilter / DeleteSubscriptionFilter / DescribeSubscriptionFilters
|
|
13
|
+
* - Integração com Lambda (recebe logs automaticamente)
|
|
14
|
+
*
|
|
15
|
+
* Metrics:
|
|
16
|
+
* - PutMetricData
|
|
17
|
+
* - GetMetricStatistics
|
|
18
|
+
* - ListMetrics
|
|
19
|
+
*
|
|
20
|
+
* Alarms:
|
|
21
|
+
* - PutMetricAlarm / DeleteAlarms / DescribeAlarms
|
|
22
|
+
* - SetAlarmState
|
|
23
|
+
* - DescribeAlarmsForMetric
|
|
24
|
+
* - Ações SNS ao mudar de estado
|
|
25
|
+
*
|
|
26
|
+
* Persistência via LocalStore
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const { randomUUID } = require('crypto');
|
|
30
|
+
|
|
31
|
+
// ─── Erros tipados ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
class CloudWatchError extends Error {
|
|
34
|
+
constructor(code, message, statusCode = 400) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.statusCode = statusCode;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Errors = {
|
|
42
|
+
LogGroupNotFound: (name) =>
|
|
43
|
+
new CloudWatchError('ResourceNotFoundException', `The specified log group does not exist: ${name}`, 404),
|
|
44
|
+
LogStreamNotFound: (name) =>
|
|
45
|
+
new CloudWatchError('ResourceNotFoundException', `The specified log stream does not exist: ${name}`, 404),
|
|
46
|
+
LogGroupAlreadyExists: (name) =>
|
|
47
|
+
new CloudWatchError('ResourceAlreadyExistsException', `The specified log group already exists: ${name}`, 400),
|
|
48
|
+
LogStreamAlreadyExists: (name) =>
|
|
49
|
+
new CloudWatchError('ResourceAlreadyExistsException', `The specified log stream already exists: ${name}`, 400),
|
|
50
|
+
AlarmNotFound: (name) =>
|
|
51
|
+
new CloudWatchError('ResourceNotFoundException', `Alarm [${name}] does not exist`, 404),
|
|
52
|
+
InvalidParameter: (msg) =>
|
|
53
|
+
new CloudWatchError('InvalidParameterValue', msg, 400),
|
|
54
|
+
InvalidToken: () =>
|
|
55
|
+
new CloudWatchError('InvalidParameterException', 'The specified sequence token is invalid', 400),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ─── Constantes ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
const REGION = 'us-east-1';
|
|
61
|
+
const ACCOUNT = '000000000000';
|
|
62
|
+
|
|
63
|
+
const AlarmState = {
|
|
64
|
+
OK: 'OK',
|
|
65
|
+
ALARM: 'ALARM',
|
|
66
|
+
INSUFFICIENT_DATA: 'INSUFFICIENT_DATA',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ComparisonOperator = {
|
|
70
|
+
GreaterThanOrEqualToThreshold: 'GreaterThanOrEqualToThreshold',
|
|
71
|
+
GreaterThanThreshold: 'GreaterThanThreshold',
|
|
72
|
+
LessThanThreshold: 'LessThanThreshold',
|
|
73
|
+
LessThanOrEqualToThreshold: 'LessThanOrEqualToThreshold',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const Statistic = {
|
|
77
|
+
SampleCount: 'SampleCount',
|
|
78
|
+
Average: 'Average',
|
|
79
|
+
Sum: 'Sum',
|
|
80
|
+
Minimum: 'Minimum',
|
|
81
|
+
Maximum: 'Maximum',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ─── Utilitários ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function nowIso() {
|
|
87
|
+
return new Date().toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function nowMs() {
|
|
91
|
+
return Date.now();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function logGroupArn(name) {
|
|
95
|
+
return `arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${name}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function logStreamArn(groupName, streamName) {
|
|
99
|
+
return `arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${groupName}:log-stream:${streamName}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function alarmArn(name) {
|
|
103
|
+
return `arn:aws:cloudwatch:${REGION}:${ACCOUNT}:alarm:${name}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── CloudWatch Simulator ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
class CloudWatchSimulator {
|
|
109
|
+
/**
|
|
110
|
+
* @param {Object} config - Global config
|
|
111
|
+
* @param {Object} store - LocalStore
|
|
112
|
+
* @param {Object} logger - Logger
|
|
113
|
+
*/
|
|
114
|
+
constructor(config, store, logger) {
|
|
115
|
+
this.config = config;
|
|
116
|
+
this.store = store;
|
|
117
|
+
this.logger = logger;
|
|
118
|
+
|
|
119
|
+
// Logs
|
|
120
|
+
/** @type {Map<string, Object>} name -> logGroup */
|
|
121
|
+
this.logGroups = new Map();
|
|
122
|
+
/** @type {Map<string, Map<string, Object>>} groupName -> streamName -> logStream */
|
|
123
|
+
this.logStreams = new Map();
|
|
124
|
+
/** @type {Map<string, Array>} `groupName/streamName` -> events[] */
|
|
125
|
+
this.logEvents = new Map();
|
|
126
|
+
/** @type {Map<string, Array>} groupName -> subscriptionFilters[] */
|
|
127
|
+
this.subscriptionFilters = new Map();
|
|
128
|
+
|
|
129
|
+
// Metrics
|
|
130
|
+
/** @type {Array} all metric data points */
|
|
131
|
+
this.metricData = [];
|
|
132
|
+
|
|
133
|
+
// Alarms
|
|
134
|
+
/** @type {Map<string, Object>} name -> alarm */
|
|
135
|
+
this.alarms = new Map();
|
|
136
|
+
|
|
137
|
+
// Referência ao simulador de SNS (injetado depois)
|
|
138
|
+
this.snsSimulator = null;
|
|
139
|
+
this.lambdaSimulator = null;
|
|
140
|
+
|
|
141
|
+
// Config CloudWatch
|
|
142
|
+
this.cwConfig = config?.cloudwatch || {};
|
|
143
|
+
this.retentionDefault = this.cwConfig.retentionInDays || 7;
|
|
144
|
+
this.lambdaLogGroup = this.cwConfig.lambdaLogGroup || '/aws/lambda';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Persistência ─────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
async load() {
|
|
150
|
+
try {
|
|
151
|
+
const data = await this.store.load('cloudwatch');
|
|
152
|
+
if (data) {
|
|
153
|
+
if (data.logGroups) {
|
|
154
|
+
this.logGroups = new Map(Object.entries(data.logGroups));
|
|
155
|
+
}
|
|
156
|
+
if (data.logStreams) {
|
|
157
|
+
this.logStreams = new Map(
|
|
158
|
+
Object.entries(data.logStreams).map(([g, streams]) => [
|
|
159
|
+
g,
|
|
160
|
+
new Map(Object.entries(streams)),
|
|
161
|
+
])
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (data.logEvents) {
|
|
165
|
+
this.logEvents = new Map(Object.entries(data.logEvents));
|
|
166
|
+
}
|
|
167
|
+
if (data.subscriptionFilters) {
|
|
168
|
+
this.subscriptionFilters = new Map(Object.entries(data.subscriptionFilters));
|
|
169
|
+
}
|
|
170
|
+
if (data.metricData) {
|
|
171
|
+
this.metricData = data.metricData;
|
|
172
|
+
}
|
|
173
|
+
if (data.alarms) {
|
|
174
|
+
this.alarms = new Map(Object.entries(data.alarms));
|
|
175
|
+
}
|
|
176
|
+
this.logger.info('[CloudWatch] Data loaded from store');
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
this.logger.warn('[CloudWatch] No persisted data found, starting fresh');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async save() {
|
|
184
|
+
try {
|
|
185
|
+
const logStreamsObj = {};
|
|
186
|
+
for (const [g, streams] of this.logStreams.entries()) {
|
|
187
|
+
logStreamsObj[g] = Object.fromEntries(streams);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const data = {
|
|
191
|
+
logGroups: Object.fromEntries(this.logGroups),
|
|
192
|
+
logStreams: logStreamsObj,
|
|
193
|
+
logEvents: Object.fromEntries(this.logEvents),
|
|
194
|
+
subscriptionFilters: Object.fromEntries(this.subscriptionFilters),
|
|
195
|
+
metricData: this.metricData,
|
|
196
|
+
alarms: Object.fromEntries(this.alarms),
|
|
197
|
+
};
|
|
198
|
+
await this.store.save('cloudwatch', data);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.logger.error('[CloudWatch] Failed to persist data:', err.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
reset() {
|
|
205
|
+
this.logGroups.clear();
|
|
206
|
+
this.logStreams.clear();
|
|
207
|
+
this.logEvents.clear();
|
|
208
|
+
this.subscriptionFilters.clear();
|
|
209
|
+
this.metricData = [];
|
|
210
|
+
this.alarms.clear();
|
|
211
|
+
this.logger.info('[CloudWatch] State reset');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Log Groups ───────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* CreateLogGroup
|
|
218
|
+
*/
|
|
219
|
+
createLogGroup({ logGroupName, retentionInDays, tags }) {
|
|
220
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
221
|
+
if (this.logGroups.has(logGroupName)) throw Errors.LogGroupAlreadyExists(logGroupName);
|
|
222
|
+
|
|
223
|
+
const group = {
|
|
224
|
+
logGroupName,
|
|
225
|
+
arn: logGroupArn(logGroupName),
|
|
226
|
+
creationTime: nowMs(),
|
|
227
|
+
retentionInDays: retentionInDays || this.retentionDefault,
|
|
228
|
+
metricFilterCount: 0,
|
|
229
|
+
storedBytes: 0,
|
|
230
|
+
tags: tags || {},
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
this.logGroups.set(logGroupName, group);
|
|
234
|
+
this.logStreams.set(logGroupName, new Map());
|
|
235
|
+
this.subscriptionFilters.set(logGroupName, []);
|
|
236
|
+
|
|
237
|
+
this.logger.info(`[CloudWatch] Log group created: ${logGroupName}`);
|
|
238
|
+
this.save();
|
|
239
|
+
return group;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* DeleteLogGroup
|
|
244
|
+
*/
|
|
245
|
+
deleteLogGroup({ logGroupName }) {
|
|
246
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
247
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
248
|
+
|
|
249
|
+
this.logGroups.delete(logGroupName);
|
|
250
|
+
|
|
251
|
+
// Remove streams and events
|
|
252
|
+
const streams = this.logStreams.get(logGroupName);
|
|
253
|
+
if (streams) {
|
|
254
|
+
for (const streamName of streams.keys()) {
|
|
255
|
+
this.logEvents.delete(`${logGroupName}/${streamName}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
this.logStreams.delete(logGroupName);
|
|
259
|
+
this.subscriptionFilters.delete(logGroupName);
|
|
260
|
+
|
|
261
|
+
this.logger.info(`[CloudWatch] Log group deleted: ${logGroupName}`);
|
|
262
|
+
this.save();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* DescribeLogGroups
|
|
267
|
+
*/
|
|
268
|
+
describeLogGroups({ logGroupNamePrefix, logGroupNamePattern, limit = 50, nextToken } = {}) {
|
|
269
|
+
let groups = [...this.logGroups.values()];
|
|
270
|
+
|
|
271
|
+
if (logGroupNamePrefix) {
|
|
272
|
+
groups = groups.filter(g => g.logGroupName.startsWith(logGroupNamePrefix));
|
|
273
|
+
}
|
|
274
|
+
if (logGroupNamePattern) {
|
|
275
|
+
const re = new RegExp(logGroupNamePattern);
|
|
276
|
+
groups = groups.filter(g => re.test(g.logGroupName));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Pagination
|
|
280
|
+
let startIdx = 0;
|
|
281
|
+
if (nextToken) {
|
|
282
|
+
startIdx = parseInt(nextToken, 10) || 0;
|
|
283
|
+
}
|
|
284
|
+
const page = groups.slice(startIdx, startIdx + limit);
|
|
285
|
+
const newNextToken = startIdx + limit < groups.length ? String(startIdx + limit) : null;
|
|
286
|
+
|
|
287
|
+
return { logGroups: page, nextToken: newNextToken };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* PutRetentionPolicy
|
|
292
|
+
*/
|
|
293
|
+
putRetentionPolicy({ logGroupName, retentionInDays }) {
|
|
294
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
295
|
+
const group = this.logGroups.get(logGroupName);
|
|
296
|
+
if (!group) throw Errors.LogGroupNotFound(logGroupName);
|
|
297
|
+
|
|
298
|
+
group.retentionInDays = retentionInDays;
|
|
299
|
+
this.logger.info(`[CloudWatch] Retention policy set: ${logGroupName} = ${retentionInDays} days`);
|
|
300
|
+
this.save();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* DeleteRetentionPolicy
|
|
305
|
+
*/
|
|
306
|
+
deleteRetentionPolicy({ logGroupName }) {
|
|
307
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
308
|
+
const group = this.logGroups.get(logGroupName);
|
|
309
|
+
if (!group) throw Errors.LogGroupNotFound(logGroupName);
|
|
310
|
+
|
|
311
|
+
delete group.retentionInDays;
|
|
312
|
+
this.logger.info(`[CloudWatch] Retention policy deleted: ${logGroupName}`);
|
|
313
|
+
this.save();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Log Streams ──────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* CreateLogStream
|
|
320
|
+
*/
|
|
321
|
+
createLogStream({ logGroupName, logStreamName }) {
|
|
322
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
323
|
+
if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
|
|
324
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
325
|
+
|
|
326
|
+
const streams = this.logStreams.get(logGroupName);
|
|
327
|
+
if (streams.has(logStreamName)) throw Errors.LogStreamAlreadyExists(logStreamName);
|
|
328
|
+
|
|
329
|
+
const stream = {
|
|
330
|
+
logStreamName,
|
|
331
|
+
arn: logStreamArn(logGroupName, logStreamName),
|
|
332
|
+
creationTime: nowMs(),
|
|
333
|
+
firstEventTimestamp: null,
|
|
334
|
+
lastEventTimestamp: null,
|
|
335
|
+
lastIngestionTime: null,
|
|
336
|
+
uploadSequenceToken: '1',
|
|
337
|
+
storedBytes: 0,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
streams.set(logStreamName, stream);
|
|
341
|
+
this.logEvents.set(`${logGroupName}/${logStreamName}`, []);
|
|
342
|
+
|
|
343
|
+
this.logger.info(`[CloudWatch] Log stream created: ${logGroupName}/${logStreamName}`);
|
|
344
|
+
this.save();
|
|
345
|
+
return stream;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* DeleteLogStream
|
|
350
|
+
*/
|
|
351
|
+
deleteLogStream({ logGroupName, logStreamName }) {
|
|
352
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
353
|
+
if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
|
|
354
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
355
|
+
|
|
356
|
+
const streams = this.logStreams.get(logGroupName);
|
|
357
|
+
if (!streams || !streams.has(logStreamName)) throw Errors.LogStreamNotFound(logStreamName);
|
|
358
|
+
|
|
359
|
+
streams.delete(logStreamName);
|
|
360
|
+
this.logEvents.delete(`${logGroupName}/${logStreamName}`);
|
|
361
|
+
|
|
362
|
+
this.logger.info(`[CloudWatch] Log stream deleted: ${logGroupName}/${logStreamName}`);
|
|
363
|
+
this.save();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* DescribeLogStreams
|
|
368
|
+
*/
|
|
369
|
+
describeLogStreams({ logGroupName, logStreamNamePrefix, orderBy = 'LogStreamName', descending = false, limit = 50, nextToken } = {}) {
|
|
370
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
371
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
372
|
+
|
|
373
|
+
const streams = this.logStreams.get(logGroupName);
|
|
374
|
+
let result = streams ? [...streams.values()] : [];
|
|
375
|
+
|
|
376
|
+
if (logStreamNamePrefix) {
|
|
377
|
+
result = result.filter(s => s.logStreamName.startsWith(logStreamNamePrefix));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Ordenação
|
|
381
|
+
result.sort((a, b) => {
|
|
382
|
+
let va, vb;
|
|
383
|
+
if (orderBy === 'LastEventTime') {
|
|
384
|
+
va = a.lastEventTimestamp || 0;
|
|
385
|
+
vb = b.lastEventTimestamp || 0;
|
|
386
|
+
} else {
|
|
387
|
+
va = a.logStreamName;
|
|
388
|
+
vb = b.logStreamName;
|
|
389
|
+
}
|
|
390
|
+
if (va < vb) return descending ? 1 : -1;
|
|
391
|
+
if (va > vb) return descending ? -1 : 1;
|
|
392
|
+
return 0;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Pagination
|
|
396
|
+
let startIdx = 0;
|
|
397
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
398
|
+
const page = result.slice(startIdx, startIdx + limit);
|
|
399
|
+
const newNextToken = startIdx + limit < result.length ? String(startIdx + limit) : null;
|
|
400
|
+
|
|
401
|
+
return { logStreams: page, nextToken: newNextToken };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ─── Log Events ───────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* PutLogEvents
|
|
408
|
+
*/
|
|
409
|
+
async putLogEvents({ logGroupName, logStreamName, logEvents, sequenceToken }) {
|
|
410
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
411
|
+
if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
|
|
412
|
+
if (!logEvents || !Array.isArray(logEvents)) throw Errors.InvalidParameter('logEvents must be an array');
|
|
413
|
+
|
|
414
|
+
// Auto-create group/stream se não existirem (comportamento permissivo)
|
|
415
|
+
if (!this.logGroups.has(logGroupName)) {
|
|
416
|
+
this.createLogGroup({ logGroupName });
|
|
417
|
+
}
|
|
418
|
+
const streams = this.logStreams.get(logGroupName);
|
|
419
|
+
if (!streams.has(logStreamName)) {
|
|
420
|
+
this.createLogStream({ logGroupName, logStreamName });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const stream = streams.get(logStreamName);
|
|
424
|
+
const key = `${logGroupName}/${logStreamName}`;
|
|
425
|
+
const existing = this.logEvents.get(key) || [];
|
|
426
|
+
|
|
427
|
+
const now = nowMs();
|
|
428
|
+
const newEvents = logEvents.map(e => ({
|
|
429
|
+
timestamp: e.timestamp || now,
|
|
430
|
+
message: e.message || '',
|
|
431
|
+
ingestionTime: now,
|
|
432
|
+
}));
|
|
433
|
+
|
|
434
|
+
// Ordena por timestamp
|
|
435
|
+
newEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
436
|
+
const all = [...existing, ...newEvents].sort((a, b) => a.timestamp - b.timestamp);
|
|
437
|
+
|
|
438
|
+
// Aplica retenção (remove eventos mais antigos que retentionInDays)
|
|
439
|
+
const group = this.logGroups.get(logGroupName);
|
|
440
|
+
if (group && group.retentionInDays) {
|
|
441
|
+
const cutoff = now - group.retentionInDays * 24 * 60 * 60 * 1000;
|
|
442
|
+
const filtered = all.filter(e => e.timestamp >= cutoff);
|
|
443
|
+
this.logEvents.set(key, filtered);
|
|
444
|
+
} else {
|
|
445
|
+
this.logEvents.set(key, all);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Atualiza metadados do stream
|
|
449
|
+
const timestamps = newEvents.map(e => e.timestamp);
|
|
450
|
+
const minTs = Math.min(...timestamps);
|
|
451
|
+
const maxTs = Math.max(...timestamps);
|
|
452
|
+
|
|
453
|
+
if (!stream.firstEventTimestamp || minTs < stream.firstEventTimestamp) {
|
|
454
|
+
stream.firstEventTimestamp = minTs;
|
|
455
|
+
}
|
|
456
|
+
if (!stream.lastEventTimestamp || maxTs > stream.lastEventTimestamp) {
|
|
457
|
+
stream.lastEventTimestamp = maxTs;
|
|
458
|
+
}
|
|
459
|
+
stream.lastIngestionTime = now;
|
|
460
|
+
stream.uploadSequenceToken = String(parseInt(stream.uploadSequenceToken || '0', 10) + newEvents.length);
|
|
461
|
+
|
|
462
|
+
// Atualiza storedBytes do grupo
|
|
463
|
+
const bytes = newEvents.reduce((acc, e) => acc + Buffer.byteLength(e.message, 'utf8'), 0);
|
|
464
|
+
group.storedBytes = (group.storedBytes || 0) + bytes;
|
|
465
|
+
|
|
466
|
+
this.logger.debug(`[CloudWatch] PutLogEvents: ${logGroupName}/${logStreamName} +${newEvents.length} events`);
|
|
467
|
+
|
|
468
|
+
// Envia para subscription filters
|
|
469
|
+
await this._deliverToSubscriptionFilters(logGroupName, logStreamName, newEvents);
|
|
470
|
+
|
|
471
|
+
this.save();
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
nextSequenceToken: stream.uploadSequenceToken,
|
|
475
|
+
rejectedLogEventsInfo: null,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* GetLogEvents
|
|
481
|
+
*/
|
|
482
|
+
getLogEvents({ logGroupName, logStreamName, startTime, endTime, nextToken, limit = 10000, startFromHead = false }) {
|
|
483
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
484
|
+
if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
|
|
485
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
486
|
+
|
|
487
|
+
const streams = this.logStreams.get(logGroupName);
|
|
488
|
+
if (!streams || !streams.has(logStreamName)) throw Errors.LogStreamNotFound(logStreamName);
|
|
489
|
+
|
|
490
|
+
const key = `${logGroupName}/${logStreamName}`;
|
|
491
|
+
let events = this.logEvents.get(key) || [];
|
|
492
|
+
|
|
493
|
+
if (startTime) events = events.filter(e => e.timestamp >= startTime);
|
|
494
|
+
if (endTime) events = events.filter(e => e.timestamp <= endTime);
|
|
495
|
+
|
|
496
|
+
if (!startFromHead) events = [...events].reverse();
|
|
497
|
+
|
|
498
|
+
// Pagination
|
|
499
|
+
let startIdx = 0;
|
|
500
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
501
|
+
const page = events.slice(startIdx, startIdx + limit);
|
|
502
|
+
const newNextToken = startIdx + limit < events.length ? String(startIdx + limit) : null;
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
events: page,
|
|
506
|
+
nextForwardToken: startFromHead ? newNextToken : null,
|
|
507
|
+
nextBackwardToken: !startFromHead ? newNextToken : null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* FilterLogEvents
|
|
513
|
+
*/
|
|
514
|
+
filterLogEvents({ logGroupName, logStreamNames, startTime, endTime, filterPattern, nextToken, limit = 10000 }) {
|
|
515
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
516
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
517
|
+
|
|
518
|
+
const streams = this.logStreams.get(logGroupName);
|
|
519
|
+
if (!streams) return { events: [], searchedLogStreams: [], nextToken: null };
|
|
520
|
+
|
|
521
|
+
let matchingStreams = [...streams.keys()];
|
|
522
|
+
if (logStreamNames && logStreamNames.length > 0) {
|
|
523
|
+
matchingStreams = matchingStreams.filter(s => logStreamNames.includes(s));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let allEvents = [];
|
|
527
|
+
for (const streamName of matchingStreams) {
|
|
528
|
+
const key = `${logGroupName}/${streamName}`;
|
|
529
|
+
const events = (this.logEvents.get(key) || []).map(e => ({ ...e, logStreamName: streamName }));
|
|
530
|
+
allEvents = allEvents.concat(events);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Filtro por tempo
|
|
534
|
+
if (startTime) allEvents = allEvents.filter(e => e.timestamp >= startTime);
|
|
535
|
+
if (endTime) allEvents = allEvents.filter(e => e.timestamp <= endTime);
|
|
536
|
+
|
|
537
|
+
// Filtro por padrão (simples — verifica se a mensagem contém o padrão)
|
|
538
|
+
if (filterPattern) {
|
|
539
|
+
try {
|
|
540
|
+
// Suporta padrões simples: termos literais e "?" (negação)
|
|
541
|
+
const terms = filterPattern.split(/\s+/);
|
|
542
|
+
allEvents = allEvents.filter(e => {
|
|
543
|
+
return terms.every(term => {
|
|
544
|
+
if (term.startsWith('?')) {
|
|
545
|
+
return true; // termo opcional
|
|
546
|
+
}
|
|
547
|
+
return e.message.includes(term);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
} catch (_) {
|
|
551
|
+
// ignora padrões inválidos
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Ordena por timestamp
|
|
556
|
+
allEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
557
|
+
|
|
558
|
+
// Pagination
|
|
559
|
+
let startIdx = 0;
|
|
560
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
561
|
+
const page = allEvents.slice(startIdx, startIdx + limit);
|
|
562
|
+
const newNextToken = startIdx + limit < allEvents.length ? String(startIdx + limit) : null;
|
|
563
|
+
|
|
564
|
+
const searchedLogStreams = matchingStreams.map(s => ({
|
|
565
|
+
logStreamName: s,
|
|
566
|
+
searchedCompletely: true,
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
return { events: page, searchedLogStreams, nextToken: newNextToken };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ─── Subscription Filters ─────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* PutSubscriptionFilter
|
|
576
|
+
*/
|
|
577
|
+
putSubscriptionFilter({ logGroupName, filterName, filterPattern, destinationArn, distribution }) {
|
|
578
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
579
|
+
if (!filterName) throw Errors.InvalidParameter('filterName is required');
|
|
580
|
+
if (!destinationArn) throw Errors.InvalidParameter('destinationArn is required');
|
|
581
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
582
|
+
|
|
583
|
+
const filters = this.subscriptionFilters.get(logGroupName) || [];
|
|
584
|
+
|
|
585
|
+
// Remove filter existente com mesmo nome
|
|
586
|
+
const idx = filters.findIndex(f => f.filterName === filterName);
|
|
587
|
+
if (idx !== -1) filters.splice(idx, 1);
|
|
588
|
+
|
|
589
|
+
// Máximo 2 subscription filters por grupo
|
|
590
|
+
if (filters.length >= 2) {
|
|
591
|
+
throw Errors.InvalidParameter('A log group can have at most 2 subscription filters');
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
filters.push({
|
|
595
|
+
filterName,
|
|
596
|
+
filterPattern: filterPattern || '',
|
|
597
|
+
destinationArn,
|
|
598
|
+
distribution: distribution || 'ByLogStream',
|
|
599
|
+
creationTime: nowMs(),
|
|
600
|
+
logGroupName,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
this.subscriptionFilters.set(logGroupName, filters);
|
|
604
|
+
this.logger.info(`[CloudWatch] Subscription filter set: ${logGroupName} -> ${filterName}`);
|
|
605
|
+
this.save();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* DeleteSubscriptionFilter
|
|
610
|
+
*/
|
|
611
|
+
deleteSubscriptionFilter({ logGroupName, filterName }) {
|
|
612
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
613
|
+
if (!filterName) throw Errors.InvalidParameter('filterName is required');
|
|
614
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
615
|
+
|
|
616
|
+
const filters = this.subscriptionFilters.get(logGroupName) || [];
|
|
617
|
+
const idx = filters.findIndex(f => f.filterName === filterName);
|
|
618
|
+
if (idx === -1) throw new CloudWatchError('ResourceNotFoundException', `Subscription filter [${filterName}] not found`, 404);
|
|
619
|
+
|
|
620
|
+
filters.splice(idx, 1);
|
|
621
|
+
this.subscriptionFilters.set(logGroupName, filters);
|
|
622
|
+
this.logger.info(`[CloudWatch] Subscription filter deleted: ${logGroupName}/${filterName}`);
|
|
623
|
+
this.save();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* DescribeSubscriptionFilters
|
|
628
|
+
*/
|
|
629
|
+
describeSubscriptionFilters({ logGroupName, filterNamePrefix, limit = 50, nextToken } = {}) {
|
|
630
|
+
if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
|
|
631
|
+
if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
|
|
632
|
+
|
|
633
|
+
let filters = this.subscriptionFilters.get(logGroupName) || [];
|
|
634
|
+
if (filterNamePrefix) {
|
|
635
|
+
filters = filters.filter(f => f.filterName.startsWith(filterNamePrefix));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let startIdx = 0;
|
|
639
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
640
|
+
const page = filters.slice(startIdx, startIdx + limit);
|
|
641
|
+
const newNextToken = startIdx + limit < filters.length ? String(startIdx + limit) : null;
|
|
642
|
+
|
|
643
|
+
return { subscriptionFilters: page, nextToken: newNextToken };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Entrega eventos para subscription filters (Lambda ou SQS)
|
|
648
|
+
* @private
|
|
649
|
+
*/
|
|
650
|
+
async _deliverToSubscriptionFilters(logGroupName, logStreamName, events) {
|
|
651
|
+
const filters = this.subscriptionFilters.get(logGroupName) || [];
|
|
652
|
+
if (filters.length === 0) return;
|
|
653
|
+
|
|
654
|
+
for (const filter of filters) {
|
|
655
|
+
let matchedEvents = events;
|
|
656
|
+
|
|
657
|
+
// Aplica filtro de padrão
|
|
658
|
+
if (filter.filterPattern) {
|
|
659
|
+
const terms = filter.filterPattern.split(/\s+/).filter(t => t && !t.startsWith('?'));
|
|
660
|
+
matchedEvents = events.filter(e => terms.every(t => e.message.includes(t)));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (matchedEvents.length === 0) continue;
|
|
664
|
+
|
|
665
|
+
const payload = {
|
|
666
|
+
awslogs: {
|
|
667
|
+
data: Buffer.from(
|
|
668
|
+
JSON.stringify({
|
|
669
|
+
messageType: 'DATA_MESSAGE',
|
|
670
|
+
owner: ACCOUNT,
|
|
671
|
+
logGroup: logGroupName,
|
|
672
|
+
logStream: logStreamName,
|
|
673
|
+
subscriptionFilters: [filter.filterName],
|
|
674
|
+
logEvents: matchedEvents.map(e => ({
|
|
675
|
+
id: randomUUID().replace(/-/g, ''),
|
|
676
|
+
timestamp: e.timestamp,
|
|
677
|
+
message: e.message,
|
|
678
|
+
})),
|
|
679
|
+
})
|
|
680
|
+
).toString('base64'),
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// Entrega para Lambda se tiver referência
|
|
685
|
+
if (filter.destinationArn.includes(':lambda:') && this.lambdaSimulator) {
|
|
686
|
+
try {
|
|
687
|
+
const fnName = filter.destinationArn.split(':').pop();
|
|
688
|
+
await this.lambdaSimulator.invoke(fnName, payload, { eventType: 'cloudwatch-logs' });
|
|
689
|
+
this.logger.debug(`[CloudWatch] Delivered ${matchedEvents.length} events to Lambda ${fnName}`);
|
|
690
|
+
} catch (err) {
|
|
691
|
+
this.logger.error(`[CloudWatch] Failed to deliver to Lambda: ${err.message}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ─── Integração Lambda ────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Adiciona logs de uma execução Lambda automaticamente
|
|
701
|
+
* Chamado pelo simulador Lambda após cada invocação
|
|
702
|
+
*/
|
|
703
|
+
async putLambdaLogs(functionName, requestId, logs) {
|
|
704
|
+
const logGroupName = `${this.lambdaLogGroup}/${functionName}`;
|
|
705
|
+
const logStreamName = `${new Date().toISOString().slice(0, 10).replace(/-/g, '/')}/${randomUUID().slice(0, 8)}`;
|
|
706
|
+
|
|
707
|
+
// Monta eventos de log
|
|
708
|
+
const now = nowMs();
|
|
709
|
+
const logEvents = [
|
|
710
|
+
{ timestamp: now, message: `START RequestId: ${requestId} Version: $LATEST` },
|
|
711
|
+
...logs.map((line, i) => ({ timestamp: now + i + 1, message: String(line) })),
|
|
712
|
+
{ timestamp: now + logs.length + 1, message: `END RequestId: ${requestId}` },
|
|
713
|
+
{ timestamp: now + logs.length + 2, message: `REPORT RequestId: ${requestId}` },
|
|
714
|
+
];
|
|
715
|
+
|
|
716
|
+
await this.putLogEvents({ logGroupName, logStreamName, logEvents });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ─── Metrics ─────────────────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* PutMetricData
|
|
723
|
+
*/
|
|
724
|
+
putMetricData({ namespace, metricData }) {
|
|
725
|
+
if (!namespace) throw Errors.InvalidParameter('namespace is required');
|
|
726
|
+
if (!metricData || !Array.isArray(metricData)) throw Errors.InvalidParameter('metricData must be an array');
|
|
727
|
+
|
|
728
|
+
const now = nowMs();
|
|
729
|
+
|
|
730
|
+
for (const metric of metricData) {
|
|
731
|
+
const point = {
|
|
732
|
+
namespace,
|
|
733
|
+
metricName: metric.metricName,
|
|
734
|
+
dimensions: metric.dimensions || [],
|
|
735
|
+
timestamp: metric.timestamp ? new Date(metric.timestamp).getTime() : now,
|
|
736
|
+
value: metric.value !== undefined ? metric.value : null,
|
|
737
|
+
statistic: metric.statistic || null,
|
|
738
|
+
counts: metric.counts || null,
|
|
739
|
+
values: metric.values || null,
|
|
740
|
+
unit: metric.unit || 'None',
|
|
741
|
+
storageResolution: metric.storageResolution || 60,
|
|
742
|
+
};
|
|
743
|
+
this.metricData.push(point);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Mantém apenas os últimos 14 dias
|
|
747
|
+
const cutoff = now - 14 * 24 * 60 * 60 * 1000;
|
|
748
|
+
this.metricData = this.metricData.filter(p => p.timestamp >= cutoff);
|
|
749
|
+
|
|
750
|
+
this.logger.debug(`[CloudWatch] PutMetricData: ${namespace} +${metricData.length} points`);
|
|
751
|
+
|
|
752
|
+
// Verifica alarms após novo dado
|
|
753
|
+
this._evaluateAlarms(namespace);
|
|
754
|
+
|
|
755
|
+
this.save();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* GetMetricStatistics
|
|
760
|
+
*/
|
|
761
|
+
getMetricStatistics({ namespace, metricName, dimensions, startTime, endTime, period, statistics, unit }) {
|
|
762
|
+
if (!namespace) throw Errors.InvalidParameter('namespace is required');
|
|
763
|
+
if (!metricName) throw Errors.InvalidParameter('metricName is required');
|
|
764
|
+
if (!startTime) throw Errors.InvalidParameter('startTime is required');
|
|
765
|
+
if (!endTime) throw Errors.InvalidParameter('endTime is required');
|
|
766
|
+
if (!period) throw Errors.InvalidParameter('period is required');
|
|
767
|
+
if (!statistics || !Array.isArray(statistics)) throw Errors.InvalidParameter('statistics must be an array');
|
|
768
|
+
|
|
769
|
+
const start = new Date(startTime).getTime();
|
|
770
|
+
const end = new Date(endTime).getTime();
|
|
771
|
+
|
|
772
|
+
// Filtra pontos
|
|
773
|
+
let points = this.metricData.filter(p => {
|
|
774
|
+
if (p.namespace !== namespace) return false;
|
|
775
|
+
if (p.metricName !== metricName) return false;
|
|
776
|
+
if (p.timestamp < start || p.timestamp > end) return false;
|
|
777
|
+
if (unit && p.unit !== unit) return false;
|
|
778
|
+
|
|
779
|
+
// Verifica dimensions
|
|
780
|
+
if (dimensions && dimensions.length > 0) {
|
|
781
|
+
const pDims = p.dimensions || [];
|
|
782
|
+
return dimensions.every(d =>
|
|
783
|
+
pDims.some(pd => pd.name === d.name && pd.value === d.value)
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return true;
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Agrupa por período
|
|
790
|
+
const buckets = new Map();
|
|
791
|
+
for (const point of points) {
|
|
792
|
+
const bucket = Math.floor((point.timestamp - start) / (period * 1000)) * period * 1000 + start;
|
|
793
|
+
if (!buckets.has(bucket)) buckets.set(bucket, []);
|
|
794
|
+
buckets.get(bucket).push(point.value !== null ? point.value : 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Calcula estatísticas por bucket
|
|
798
|
+
const datapoints = [];
|
|
799
|
+
for (const [timestamp, values] of buckets.entries()) {
|
|
800
|
+
const dp = { timestamp: new Date(timestamp).toISOString(), unit: unit || 'None' };
|
|
801
|
+
|
|
802
|
+
if (statistics.includes('SampleCount')) dp.sampleCount = values.length;
|
|
803
|
+
if (statistics.includes('Sum')) dp.sum = values.reduce((a, b) => a + b, 0);
|
|
804
|
+
if (statistics.includes('Average')) dp.average = values.reduce((a, b) => a + b, 0) / values.length;
|
|
805
|
+
if (statistics.includes('Minimum')) dp.minimum = Math.min(...values);
|
|
806
|
+
if (statistics.includes('Maximum')) dp.maximum = Math.max(...values);
|
|
807
|
+
|
|
808
|
+
datapoints.push(dp);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
datapoints.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
|
812
|
+
|
|
813
|
+
return { label: metricName, datapoints };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* ListMetrics
|
|
818
|
+
*/
|
|
819
|
+
listMetrics({ namespace, metricName, dimensions, nextToken } = {}) {
|
|
820
|
+
const seen = new Map();
|
|
821
|
+
|
|
822
|
+
for (const point of this.metricData) {
|
|
823
|
+
if (namespace && point.namespace !== namespace) continue;
|
|
824
|
+
if (metricName && point.metricName !== metricName) continue;
|
|
825
|
+
|
|
826
|
+
const key = `${point.namespace}/${point.metricName}/${JSON.stringify(point.dimensions || [])}`;
|
|
827
|
+
if (!seen.has(key)) {
|
|
828
|
+
seen.set(key, {
|
|
829
|
+
namespace: point.namespace,
|
|
830
|
+
metricName: point.metricName,
|
|
831
|
+
dimensions: point.dimensions || [],
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
let metrics = [...seen.values()];
|
|
837
|
+
|
|
838
|
+
// Filtro por dimensions
|
|
839
|
+
if (dimensions && dimensions.length > 0) {
|
|
840
|
+
metrics = metrics.filter(m =>
|
|
841
|
+
dimensions.every(d =>
|
|
842
|
+
(m.dimensions || []).some(md => md.name === d.name && md.value === d.value)
|
|
843
|
+
)
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Paginação simples
|
|
848
|
+
let startIdx = 0;
|
|
849
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
850
|
+
const limit = 500;
|
|
851
|
+
const page = metrics.slice(startIdx, startIdx + limit);
|
|
852
|
+
const newNextToken = startIdx + limit < metrics.length ? String(startIdx + limit) : null;
|
|
853
|
+
|
|
854
|
+
return { metrics: page, nextToken: newNextToken };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─── Alarms ───────────────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* PutMetricAlarm
|
|
861
|
+
*/
|
|
862
|
+
putMetricAlarm(params) {
|
|
863
|
+
const {
|
|
864
|
+
alarmName,
|
|
865
|
+
alarmDescription,
|
|
866
|
+
actionsEnabled = true,
|
|
867
|
+
okActions = [],
|
|
868
|
+
alarmActions = [],
|
|
869
|
+
insufficientDataActions = [],
|
|
870
|
+
metricName,
|
|
871
|
+
namespace,
|
|
872
|
+
statistic = 'Average',
|
|
873
|
+
dimensions = [],
|
|
874
|
+
period = 60,
|
|
875
|
+
evaluationPeriods = 1,
|
|
876
|
+
datapointsToAlarm,
|
|
877
|
+
threshold,
|
|
878
|
+
comparisonOperator,
|
|
879
|
+
treatMissingData = 'missing',
|
|
880
|
+
unit,
|
|
881
|
+
} = params;
|
|
882
|
+
|
|
883
|
+
if (!alarmName) throw Errors.InvalidParameter('alarmName is required');
|
|
884
|
+
if (!metricName) throw Errors.InvalidParameter('metricName is required');
|
|
885
|
+
if (!namespace) throw Errors.InvalidParameter('namespace is required');
|
|
886
|
+
if (!comparisonOperator) throw Errors.InvalidParameter('comparisonOperator is required');
|
|
887
|
+
if (threshold === undefined) throw Errors.InvalidParameter('threshold is required');
|
|
888
|
+
|
|
889
|
+
const existing = this.alarms.get(alarmName);
|
|
890
|
+
const alarm = {
|
|
891
|
+
alarmName,
|
|
892
|
+
alarmArn: alarmArn(alarmName),
|
|
893
|
+
alarmDescription: alarmDescription || '',
|
|
894
|
+
actionsEnabled,
|
|
895
|
+
okActions,
|
|
896
|
+
alarmActions,
|
|
897
|
+
insufficientDataActions,
|
|
898
|
+
metricName,
|
|
899
|
+
namespace,
|
|
900
|
+
statistic,
|
|
901
|
+
dimensions,
|
|
902
|
+
period,
|
|
903
|
+
evaluationPeriods,
|
|
904
|
+
datapointsToAlarm: datapointsToAlarm || evaluationPeriods,
|
|
905
|
+
threshold,
|
|
906
|
+
comparisonOperator,
|
|
907
|
+
treatMissingData,
|
|
908
|
+
unit: unit || null,
|
|
909
|
+
stateValue: existing ? existing.stateValue : AlarmState.INSUFFICIENT_DATA,
|
|
910
|
+
stateReason: existing ? existing.stateReason : 'Alarm created',
|
|
911
|
+
stateReasonData: existing ? existing.stateReasonData : null,
|
|
912
|
+
stateUpdatedTimestamp: existing ? existing.stateUpdatedTimestamp : nowIso(),
|
|
913
|
+
alarmConfigurationUpdatedTimestamp: nowIso(),
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
this.alarms.set(alarmName, alarm);
|
|
917
|
+
this.logger.info(`[CloudWatch] Alarm created/updated: ${alarmName}`);
|
|
918
|
+
this.save();
|
|
919
|
+
|
|
920
|
+
// Avalia imediatamente
|
|
921
|
+
this._evaluateAlarm(alarm);
|
|
922
|
+
|
|
923
|
+
return alarm;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* DeleteAlarms
|
|
928
|
+
*/
|
|
929
|
+
deleteAlarms({ alarmNames }) {
|
|
930
|
+
if (!alarmNames || !Array.isArray(alarmNames)) throw Errors.InvalidParameter('alarmNames is required');
|
|
931
|
+
|
|
932
|
+
for (const name of alarmNames) {
|
|
933
|
+
this.alarms.delete(name);
|
|
934
|
+
this.logger.info(`[CloudWatch] Alarm deleted: ${name}`);
|
|
935
|
+
}
|
|
936
|
+
this.save();
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* DescribeAlarms
|
|
941
|
+
*/
|
|
942
|
+
describeAlarms({ alarmNames, alarmNamePrefix, stateValue, actionPrefix, maxRecords = 50, nextToken } = {}) {
|
|
943
|
+
let alarms = [...this.alarms.values()];
|
|
944
|
+
|
|
945
|
+
if (alarmNames && alarmNames.length > 0) {
|
|
946
|
+
alarms = alarms.filter(a => alarmNames.includes(a.alarmName));
|
|
947
|
+
}
|
|
948
|
+
if (alarmNamePrefix) {
|
|
949
|
+
alarms = alarms.filter(a => a.alarmName.startsWith(alarmNamePrefix));
|
|
950
|
+
}
|
|
951
|
+
if (stateValue) {
|
|
952
|
+
alarms = alarms.filter(a => a.stateValue === stateValue);
|
|
953
|
+
}
|
|
954
|
+
if (actionPrefix) {
|
|
955
|
+
alarms = alarms.filter(a =>
|
|
956
|
+
[...(a.alarmActions || []), ...(a.okActions || []), ...(a.insufficientDataActions || [])]
|
|
957
|
+
.some(action => action.startsWith(actionPrefix))
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
let startIdx = 0;
|
|
962
|
+
if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
|
|
963
|
+
const page = alarms.slice(startIdx, startIdx + maxRecords);
|
|
964
|
+
const newNextToken = startIdx + maxRecords < alarms.length ? String(startIdx + maxRecords) : null;
|
|
965
|
+
|
|
966
|
+
return { metricAlarms: page, nextToken: newNextToken };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* DescribeAlarmsForMetric
|
|
971
|
+
*/
|
|
972
|
+
describeAlarmsForMetric({ metricName, namespace, statistic, dimensions, period, unit } = {}) {
|
|
973
|
+
if (!metricName) throw Errors.InvalidParameter('metricName is required');
|
|
974
|
+
if (!namespace) throw Errors.InvalidParameter('namespace is required');
|
|
975
|
+
|
|
976
|
+
let alarms = [...this.alarms.values()].filter(a =>
|
|
977
|
+
a.metricName === metricName && a.namespace === namespace
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
if (statistic) alarms = alarms.filter(a => a.statistic === statistic);
|
|
981
|
+
if (period) alarms = alarms.filter(a => a.period === period);
|
|
982
|
+
if (unit) alarms = alarms.filter(a => a.unit === unit);
|
|
983
|
+
|
|
984
|
+
return { metricAlarms: alarms };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* SetAlarmState
|
|
989
|
+
*/
|
|
990
|
+
setAlarmState({ alarmName, stateValue, stateReason, stateReasonData }) {
|
|
991
|
+
if (!alarmName) throw Errors.InvalidParameter('alarmName is required');
|
|
992
|
+
if (!stateValue) throw Errors.InvalidParameter('stateValue is required');
|
|
993
|
+
if (!stateReason) throw Errors.InvalidParameter('stateReason is required');
|
|
994
|
+
|
|
995
|
+
const alarm = this.alarms.get(alarmName);
|
|
996
|
+
if (!alarm) throw Errors.AlarmNotFound(alarmName);
|
|
997
|
+
|
|
998
|
+
const prevState = alarm.stateValue;
|
|
999
|
+
alarm.stateValue = stateValue;
|
|
1000
|
+
alarm.stateReason = stateReason;
|
|
1001
|
+
alarm.stateReasonData = stateReasonData || null;
|
|
1002
|
+
alarm.stateUpdatedTimestamp = nowIso();
|
|
1003
|
+
|
|
1004
|
+
this.logger.info(`[CloudWatch] Alarm state set: ${alarmName} -> ${stateValue}`);
|
|
1005
|
+
this.save();
|
|
1006
|
+
|
|
1007
|
+
// Dispara ações se mudou de estado
|
|
1008
|
+
if (prevState !== stateValue) {
|
|
1009
|
+
this._triggerAlarmActions(alarm, prevState);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Avalia todos os alarms de um namespace após novos dados
|
|
1015
|
+
* @private
|
|
1016
|
+
*/
|
|
1017
|
+
_evaluateAlarms(namespace) {
|
|
1018
|
+
for (const alarm of this.alarms.values()) {
|
|
1019
|
+
if (alarm.namespace === namespace) {
|
|
1020
|
+
this._evaluateAlarm(alarm);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Avalia um alarm individual com base nos dados de métrica recentes
|
|
1027
|
+
* @private
|
|
1028
|
+
*/
|
|
1029
|
+
_evaluateAlarm(alarm) {
|
|
1030
|
+
const now = nowMs();
|
|
1031
|
+
const windowStart = now - alarm.evaluationPeriods * alarm.period * 1000;
|
|
1032
|
+
|
|
1033
|
+
let points = this.metricData.filter(p => {
|
|
1034
|
+
if (p.namespace !== alarm.namespace) return false;
|
|
1035
|
+
if (p.metricName !== alarm.metricName) return false;
|
|
1036
|
+
if (p.timestamp < windowStart) return false;
|
|
1037
|
+
|
|
1038
|
+
if (alarm.dimensions && alarm.dimensions.length > 0) {
|
|
1039
|
+
const pDims = p.dimensions || [];
|
|
1040
|
+
return alarm.dimensions.every(d =>
|
|
1041
|
+
pDims.some(pd => pd.name === d.name && pd.value === d.value)
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
return true;
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
if (points.length === 0) {
|
|
1048
|
+
const newState = alarm.treatMissingData === 'breaching'
|
|
1049
|
+
? AlarmState.ALARM
|
|
1050
|
+
: alarm.treatMissingData === 'notBreaching'
|
|
1051
|
+
? AlarmState.OK
|
|
1052
|
+
: AlarmState.INSUFFICIENT_DATA;
|
|
1053
|
+
|
|
1054
|
+
this._updateAlarmState(alarm, newState, 'Insufficient data for evaluation');
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Calcula estatística
|
|
1059
|
+
const values = points.map(p => p.value !== null ? p.value : 0);
|
|
1060
|
+
let statValue;
|
|
1061
|
+
switch (alarm.statistic) {
|
|
1062
|
+
case 'Sum': statValue = values.reduce((a, b) => a + b, 0); break;
|
|
1063
|
+
case 'Minimum': statValue = Math.min(...values); break;
|
|
1064
|
+
case 'Maximum': statValue = Math.max(...values); break;
|
|
1065
|
+
case 'SampleCount': statValue = values.length; break;
|
|
1066
|
+
case 'Average':
|
|
1067
|
+
default: statValue = values.reduce((a, b) => a + b, 0) / values.length;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Compara com threshold
|
|
1071
|
+
let breaching = false;
|
|
1072
|
+
switch (alarm.comparisonOperator) {
|
|
1073
|
+
case 'GreaterThanOrEqualToThreshold': breaching = statValue >= alarm.threshold; break;
|
|
1074
|
+
case 'GreaterThanThreshold': breaching = statValue > alarm.threshold; break;
|
|
1075
|
+
case 'LessThanThreshold': breaching = statValue < alarm.threshold; break;
|
|
1076
|
+
case 'LessThanOrEqualToThreshold': breaching = statValue <= alarm.threshold; break;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const newState = breaching ? AlarmState.ALARM : AlarmState.OK;
|
|
1080
|
+
const reason = `Threshold Crossed: ${statValue} ${alarm.comparisonOperator} ${alarm.threshold}`;
|
|
1081
|
+
this._updateAlarmState(alarm, newState, reason);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Atualiza estado de um alarm e dispara ações se mudou
|
|
1086
|
+
* @private
|
|
1087
|
+
*/
|
|
1088
|
+
_updateAlarmState(alarm, newState, reason) {
|
|
1089
|
+
if (alarm.stateValue === newState) return;
|
|
1090
|
+
|
|
1091
|
+
const prevState = alarm.stateValue;
|
|
1092
|
+
alarm.stateValue = newState;
|
|
1093
|
+
alarm.stateReason = reason;
|
|
1094
|
+
alarm.stateUpdatedTimestamp = nowIso();
|
|
1095
|
+
|
|
1096
|
+
this.logger.info(`[CloudWatch] Alarm state changed: ${alarm.alarmName} ${prevState} -> ${newState}`);
|
|
1097
|
+
this._triggerAlarmActions(alarm, prevState);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Dispara ações de alarm (SNS)
|
|
1102
|
+
* @private
|
|
1103
|
+
*/
|
|
1104
|
+
async _triggerAlarmActions(alarm, prevState) {
|
|
1105
|
+
if (!alarm.actionsEnabled) return;
|
|
1106
|
+
|
|
1107
|
+
let actions = [];
|
|
1108
|
+
if (alarm.stateValue === AlarmState.ALARM) actions = alarm.alarmActions || [];
|
|
1109
|
+
else if (alarm.stateValue === AlarmState.OK) actions = alarm.okActions || [];
|
|
1110
|
+
else if (alarm.stateValue === AlarmState.INSUFFICIENT_DATA) actions = alarm.insufficientDataActions || [];
|
|
1111
|
+
|
|
1112
|
+
for (const actionArn of actions) {
|
|
1113
|
+
if (actionArn.includes(':sns:') && this.snsSimulator) {
|
|
1114
|
+
try {
|
|
1115
|
+
const message = JSON.stringify({
|
|
1116
|
+
AlarmName: alarm.alarmName,
|
|
1117
|
+
AlarmDescription: alarm.alarmDescription,
|
|
1118
|
+
AWSAccountId: ACCOUNT,
|
|
1119
|
+
NewStateValue: alarm.stateValue,
|
|
1120
|
+
NewStateReason: alarm.stateReason,
|
|
1121
|
+
OldStateValue: prevState,
|
|
1122
|
+
Trigger: {
|
|
1123
|
+
MetricName: alarm.metricName,
|
|
1124
|
+
Namespace: alarm.namespace,
|
|
1125
|
+
Threshold: alarm.threshold,
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const topicArn = actionArn;
|
|
1130
|
+
await this.snsSimulator.publish({ topicArn, message, subject: `ALARM: ${alarm.alarmName}` });
|
|
1131
|
+
this.logger.info(`[CloudWatch] Alarm action triggered: ${alarm.alarmName} -> SNS ${topicArn}`);
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
this.logger.error(`[CloudWatch] Failed to trigger alarm action: ${err.message}`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// ─── Admin ────────────────────────────────────────────────────────────────
|
|
1140
|
+
|
|
1141
|
+
getStatus() {
|
|
1142
|
+
return {
|
|
1143
|
+
logGroups: this.logGroups.size,
|
|
1144
|
+
logStreams: [...this.logStreams.values()].reduce((acc, s) => acc + s.size, 0),
|
|
1145
|
+
logEventCount: [...this.logEvents.values()].reduce((acc, e) => acc + e.length, 0),
|
|
1146
|
+
metricDataPoints: this.metricData.length,
|
|
1147
|
+
alarms: this.alarms.size,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
listAdminLogGroups() {
|
|
1152
|
+
return [...this.logGroups.values()].map(g => ({
|
|
1153
|
+
...g,
|
|
1154
|
+
streamCount: (this.logStreams.get(g.logGroupName) || new Map()).size,
|
|
1155
|
+
}));
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
listAdminAlarms() {
|
|
1159
|
+
return [...this.alarms.values()];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
listAdminMetrics() {
|
|
1163
|
+
const seen = new Map();
|
|
1164
|
+
for (const p of this.metricData) {
|
|
1165
|
+
const key = `${p.namespace}/${p.metricName}`;
|
|
1166
|
+
if (!seen.has(key)) seen.set(key, { namespace: p.namespace, metricName: p.metricName, count: 0 });
|
|
1167
|
+
seen.get(key).count++;
|
|
1168
|
+
}
|
|
1169
|
+
return [...seen.values()];
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
module.exports = { CloudWatchSimulator };
|