@k-msg/analytics 0.1.1 → 0.1.3
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/dist/aggregators/index.d.ts +5 -0
- package/dist/aggregators/metric.aggregator.d.ts +76 -0
- package/dist/aggregators/time-series.aggregator.d.ts +51 -0
- package/dist/collectors/event.collector.d.ts +86 -0
- package/dist/collectors/index.d.ts +5 -0
- package/dist/collectors/webhook.collector.d.ts +67 -0
- package/dist/index.d.ts +17 -1047
- package/dist/index.js +56 -4450
- package/dist/index.js.map +94 -1
- package/dist/index.mjs +64 -0
- package/dist/index.mjs.map +94 -0
- package/dist/insights/anomaly.detector.d.ts +81 -0
- package/dist/insights/index.d.ts +5 -0
- package/dist/insights/recommendation.engine.d.ts +131 -0
- package/dist/reports/dashboard.d.ts +143 -0
- package/dist/reports/export.manager.d.ts +104 -0
- package/dist/reports/index.d.ts +5 -0
- package/dist/services/analytics.service.d.ts +66 -0
- package/dist/services/insight.engine.d.ts +44 -0
- package/dist/services/metrics.collector.d.ts +58 -0
- package/dist/services/report.generator.d.ts +61 -0
- package/dist/types/analytics.types.d.ts +164 -0
- package/package.json +18 -13
- package/dist/index.cjs +0 -4497
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -1048
package/dist/index.cjs
DELETED
|
@@ -1,4497 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
-
|
|
20
|
-
// src/index.ts
|
|
21
|
-
var index_exports = {};
|
|
22
|
-
__export(index_exports, {
|
|
23
|
-
AnalyticsService: () => AnalyticsService,
|
|
24
|
-
AnomalyDetector: () => AnomalyDetector,
|
|
25
|
-
DashboardGenerator: () => DashboardGenerator,
|
|
26
|
-
EventCollector: () => EventCollector,
|
|
27
|
-
ExportManager: () => ExportManager,
|
|
28
|
-
InsightEngine: () => InsightEngine,
|
|
29
|
-
MetricAggregator: () => MetricAggregator,
|
|
30
|
-
MetricType: () => MetricType,
|
|
31
|
-
MetricsCollector: () => MetricsCollector,
|
|
32
|
-
RecommendationEngine: () => RecommendationEngine,
|
|
33
|
-
ReportGenerator: () => ReportGenerator,
|
|
34
|
-
TimeSeriesAggregator: () => TimeSeriesAggregator,
|
|
35
|
-
WebhookCollector: () => WebhookCollector
|
|
36
|
-
});
|
|
37
|
-
module.exports = __toCommonJS(index_exports);
|
|
38
|
-
|
|
39
|
-
// src/services/metrics.collector.ts
|
|
40
|
-
var MetricsCollector = class {
|
|
41
|
-
config;
|
|
42
|
-
buffer = [];
|
|
43
|
-
batchSize = 1e3;
|
|
44
|
-
flushInterval = 5e3;
|
|
45
|
-
// 5초
|
|
46
|
-
storage = /* @__PURE__ */ new Map();
|
|
47
|
-
constructor(config) {
|
|
48
|
-
this.config = config;
|
|
49
|
-
this.startBatchProcessor();
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* 메트릭 수집
|
|
53
|
-
*/
|
|
54
|
-
async collect(metric) {
|
|
55
|
-
this.validateMetric(metric);
|
|
56
|
-
this.buffer.push(metric);
|
|
57
|
-
if (this.buffer.length >= this.batchSize) {
|
|
58
|
-
await this.flush();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* 여러 메트릭 일괄 수집
|
|
63
|
-
*/
|
|
64
|
-
async collectBatch(metrics) {
|
|
65
|
-
for (const metric of metrics) {
|
|
66
|
-
await this.collect(metric);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* 최근 메트릭 조회
|
|
71
|
-
*/
|
|
72
|
-
async getRecentMetrics(types, durationMs) {
|
|
73
|
-
const cutoff = new Date(Date.now() - durationMs);
|
|
74
|
-
const recent = [];
|
|
75
|
-
for (const type of types) {
|
|
76
|
-
const typeKey = type.toString();
|
|
77
|
-
const metrics = this.storage.get(typeKey) || [];
|
|
78
|
-
const recentMetrics = metrics.filter((m) => m.timestamp >= cutoff);
|
|
79
|
-
recent.push(...recentMetrics);
|
|
80
|
-
}
|
|
81
|
-
return recent.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* 메트릭 통계 조회
|
|
85
|
-
*/
|
|
86
|
-
async getMetricStats(type, timeRange) {
|
|
87
|
-
const typeKey = type.toString();
|
|
88
|
-
const metrics = this.storage.get(typeKey) || [];
|
|
89
|
-
const filtered = metrics.filter(
|
|
90
|
-
(m) => m.timestamp >= timeRange.start && m.timestamp <= timeRange.end
|
|
91
|
-
);
|
|
92
|
-
if (filtered.length === 0) {
|
|
93
|
-
return {
|
|
94
|
-
count: 0,
|
|
95
|
-
sum: 0,
|
|
96
|
-
avg: 0,
|
|
97
|
-
min: 0,
|
|
98
|
-
max: 0
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
const values = filtered.map((m) => m.value);
|
|
102
|
-
const sum = values.reduce((acc, val) => acc + val, 0);
|
|
103
|
-
return {
|
|
104
|
-
count: filtered.length,
|
|
105
|
-
sum,
|
|
106
|
-
avg: sum / filtered.length,
|
|
107
|
-
min: Math.min(...values),
|
|
108
|
-
max: Math.max(...values)
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* 메트릭 카운터 증가
|
|
113
|
-
*/
|
|
114
|
-
async incrementCounter(type, dimensions, value = 1) {
|
|
115
|
-
const metric = {
|
|
116
|
-
id: this.generateMetricId(),
|
|
117
|
-
type,
|
|
118
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
119
|
-
value,
|
|
120
|
-
dimensions
|
|
121
|
-
};
|
|
122
|
-
await this.collect(metric);
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* 메트릭 게이지 값 설정
|
|
126
|
-
*/
|
|
127
|
-
async setGauge(type, dimensions, value) {
|
|
128
|
-
const metric = {
|
|
129
|
-
id: this.generateMetricId(),
|
|
130
|
-
type,
|
|
131
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
132
|
-
value,
|
|
133
|
-
dimensions
|
|
134
|
-
};
|
|
135
|
-
await this.collect(metric);
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* 메트릭 히스토그램 기록
|
|
139
|
-
*/
|
|
140
|
-
async recordHistogram(type, dimensions, value) {
|
|
141
|
-
const metric = {
|
|
142
|
-
id: this.generateMetricId(),
|
|
143
|
-
type,
|
|
144
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
145
|
-
value,
|
|
146
|
-
dimensions,
|
|
147
|
-
metadata: {
|
|
148
|
-
metricClass: "histogram"
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
await this.collect(metric);
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* 메트릭 버퍼 플러시
|
|
155
|
-
*/
|
|
156
|
-
async flush() {
|
|
157
|
-
if (this.buffer.length === 0) {
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
const metrics = [...this.buffer];
|
|
161
|
-
this.buffer = [];
|
|
162
|
-
try {
|
|
163
|
-
await this.persistMetrics(metrics);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
console.error("Failed to persist metrics:", error);
|
|
166
|
-
this.buffer.unshift(...metrics);
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* 메트릭 정리 (보존 기간 초과)
|
|
172
|
-
*/
|
|
173
|
-
async cleanup() {
|
|
174
|
-
const cutoff = new Date(Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1e3);
|
|
175
|
-
for (const [typeKey, metrics] of this.storage.entries()) {
|
|
176
|
-
const filtered = metrics.filter((m) => m.timestamp >= cutoff);
|
|
177
|
-
this.storage.set(typeKey, filtered);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
validateMetric(metric) {
|
|
181
|
-
if (!metric.id) {
|
|
182
|
-
throw new Error("Metric ID is required");
|
|
183
|
-
}
|
|
184
|
-
if (!metric.type) {
|
|
185
|
-
throw new Error("Metric type is required");
|
|
186
|
-
}
|
|
187
|
-
if (typeof metric.value !== "number" || isNaN(metric.value)) {
|
|
188
|
-
throw new Error("Metric value must be a valid number");
|
|
189
|
-
}
|
|
190
|
-
if (!metric.timestamp || !(metric.timestamp instanceof Date)) {
|
|
191
|
-
throw new Error("Invalid metric timestamp");
|
|
192
|
-
}
|
|
193
|
-
if (!metric.dimensions || typeof metric.dimensions !== "object") {
|
|
194
|
-
throw new Error("Metric dimensions must be an object");
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
async persistMetrics(metrics) {
|
|
198
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
199
|
-
for (const metric of metrics) {
|
|
200
|
-
const typeKey = metric.type.toString();
|
|
201
|
-
if (!grouped.has(typeKey)) {
|
|
202
|
-
grouped.set(typeKey, []);
|
|
203
|
-
}
|
|
204
|
-
grouped.get(typeKey).push(metric);
|
|
205
|
-
}
|
|
206
|
-
for (const [typeKey, typeMetrics] of grouped.entries()) {
|
|
207
|
-
const existing = this.storage.get(typeKey) || [];
|
|
208
|
-
existing.push(...typeMetrics);
|
|
209
|
-
existing.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
210
|
-
if (existing.length > 1e4) {
|
|
211
|
-
existing.splice(1e4);
|
|
212
|
-
}
|
|
213
|
-
this.storage.set(typeKey, existing);
|
|
214
|
-
}
|
|
215
|
-
console.log(`Persisted ${metrics.length} metrics`);
|
|
216
|
-
}
|
|
217
|
-
startBatchProcessor() {
|
|
218
|
-
setInterval(async () => {
|
|
219
|
-
try {
|
|
220
|
-
await this.flush();
|
|
221
|
-
} catch (error) {
|
|
222
|
-
console.error("Batch processing failed:", error);
|
|
223
|
-
}
|
|
224
|
-
}, this.flushInterval);
|
|
225
|
-
setInterval(async () => {
|
|
226
|
-
try {
|
|
227
|
-
await this.cleanup();
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.error("Cleanup failed:", error);
|
|
230
|
-
}
|
|
231
|
-
}, 24 * 60 * 60 * 1e3);
|
|
232
|
-
}
|
|
233
|
-
generateMetricId() {
|
|
234
|
-
return `metric_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
235
|
-
}
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
// src/types/analytics.types.ts
|
|
239
|
-
var import_zod = require("zod");
|
|
240
|
-
var MetricType = /* @__PURE__ */ ((MetricType2) => {
|
|
241
|
-
MetricType2["MESSAGE_SENT"] = "message_sent";
|
|
242
|
-
MetricType2["MESSAGE_DELIVERED"] = "message_delivered";
|
|
243
|
-
MetricType2["MESSAGE_FAILED"] = "message_failed";
|
|
244
|
-
MetricType2["MESSAGE_CLICKED"] = "message_clicked";
|
|
245
|
-
MetricType2["TEMPLATE_USAGE"] = "template_usage";
|
|
246
|
-
MetricType2["PROVIDER_PERFORMANCE"] = "provider_performance";
|
|
247
|
-
MetricType2["CHANNEL_USAGE"] = "channel_usage";
|
|
248
|
-
MetricType2["ERROR_RATE"] = "error_rate";
|
|
249
|
-
MetricType2["DELIVERY_RATE"] = "delivery_rate";
|
|
250
|
-
MetricType2["CLICK_RATE"] = "click_rate";
|
|
251
|
-
return MetricType2;
|
|
252
|
-
})(MetricType || {});
|
|
253
|
-
var MetricDataSchema = import_zod.z.object({
|
|
254
|
-
id: import_zod.z.string(),
|
|
255
|
-
type: import_zod.z.nativeEnum(MetricType),
|
|
256
|
-
timestamp: import_zod.z.date(),
|
|
257
|
-
value: import_zod.z.number(),
|
|
258
|
-
dimensions: import_zod.z.record(import_zod.z.string(), import_zod.z.string()),
|
|
259
|
-
metadata: import_zod.z.record(import_zod.z.string(), import_zod.z.any()).optional()
|
|
260
|
-
});
|
|
261
|
-
var AnalyticsQuerySchema = import_zod.z.object({
|
|
262
|
-
metrics: import_zod.z.array(import_zod.z.nativeEnum(MetricType)),
|
|
263
|
-
dateRange: import_zod.z.object({
|
|
264
|
-
start: import_zod.z.date(),
|
|
265
|
-
end: import_zod.z.date()
|
|
266
|
-
}),
|
|
267
|
-
interval: import_zod.z.enum(["minute", "hour", "day", "week", "month"]).optional(),
|
|
268
|
-
filters: import_zod.z.record(import_zod.z.string(), import_zod.z.any()).optional(),
|
|
269
|
-
groupBy: import_zod.z.array(import_zod.z.string()).optional(),
|
|
270
|
-
orderBy: import_zod.z.array(import_zod.z.object({
|
|
271
|
-
field: import_zod.z.string(),
|
|
272
|
-
direction: import_zod.z.enum(["asc", "desc"])
|
|
273
|
-
})).optional(),
|
|
274
|
-
limit: import_zod.z.number().min(1).max(1e4).optional(),
|
|
275
|
-
offset: import_zod.z.number().min(0).optional()
|
|
276
|
-
});
|
|
277
|
-
var InsightDataSchema = import_zod.z.object({
|
|
278
|
-
id: import_zod.z.string(),
|
|
279
|
-
type: import_zod.z.enum(["anomaly", "trend", "recommendation"]),
|
|
280
|
-
title: import_zod.z.string(),
|
|
281
|
-
description: import_zod.z.string(),
|
|
282
|
-
severity: import_zod.z.enum(["low", "medium", "high", "critical"]),
|
|
283
|
-
metric: import_zod.z.nativeEnum(MetricType),
|
|
284
|
-
dimensions: import_zod.z.record(import_zod.z.string(), import_zod.z.string()),
|
|
285
|
-
value: import_zod.z.number(),
|
|
286
|
-
expectedValue: import_zod.z.number().optional(),
|
|
287
|
-
confidence: import_zod.z.number().min(0).max(1),
|
|
288
|
-
actionable: import_zod.z.boolean(),
|
|
289
|
-
recommendations: import_zod.z.array(import_zod.z.string()).optional(),
|
|
290
|
-
detectedAt: import_zod.z.date()
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
// src/services/report.generator.ts
|
|
294
|
-
var ReportGenerator = class {
|
|
295
|
-
config;
|
|
296
|
-
constructor(config) {
|
|
297
|
-
this.config = config;
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* 일일 요약 보고서 생성
|
|
301
|
-
*/
|
|
302
|
-
async generateDailySummary(date) {
|
|
303
|
-
const startOfDay = new Date(date);
|
|
304
|
-
startOfDay.setHours(0, 0, 0, 0);
|
|
305
|
-
const endOfDay = new Date(date);
|
|
306
|
-
endOfDay.setHours(23, 59, 59, 999);
|
|
307
|
-
const previousDay = new Date(startOfDay);
|
|
308
|
-
previousDay.setDate(previousDay.getDate() - 1);
|
|
309
|
-
return this.generateReport({
|
|
310
|
-
id: `daily_${date.toISOString().split("T")[0]}`,
|
|
311
|
-
name: `Daily Summary - ${date.toISOString().split("T")[0]}`,
|
|
312
|
-
description: "Daily messaging performance summary",
|
|
313
|
-
dateRange: { start: startOfDay, end: endOfDay },
|
|
314
|
-
filters: {},
|
|
315
|
-
metrics: await this.calculateDailyMetrics(startOfDay, endOfDay, previousDay),
|
|
316
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
317
|
-
format: "json"
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* 주간 보고서 생성
|
|
322
|
-
*/
|
|
323
|
-
async generateWeeklyReport(weekStartDate) {
|
|
324
|
-
const weekEnd = new Date(weekStartDate);
|
|
325
|
-
weekEnd.setDate(weekEnd.getDate() + 6);
|
|
326
|
-
const previousWeekStart = new Date(weekStartDate);
|
|
327
|
-
previousWeekStart.setDate(previousWeekStart.getDate() - 7);
|
|
328
|
-
return this.generateReport({
|
|
329
|
-
id: `weekly_${weekStartDate.toISOString().split("T")[0]}`,
|
|
330
|
-
name: `Weekly Report - Week of ${weekStartDate.toISOString().split("T")[0]}`,
|
|
331
|
-
description: "Weekly messaging performance analysis",
|
|
332
|
-
dateRange: { start: weekStartDate, end: weekEnd },
|
|
333
|
-
filters: {},
|
|
334
|
-
metrics: await this.calculateWeeklyMetrics(weekStartDate, weekEnd, previousWeekStart),
|
|
335
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
336
|
-
format: "json"
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
/**
|
|
340
|
-
* 월간 보고서 생성
|
|
341
|
-
*/
|
|
342
|
-
async generateMonthlyReport(year, month) {
|
|
343
|
-
const monthStart = new Date(year, month - 1, 1);
|
|
344
|
-
const monthEnd = new Date(year, month, 0);
|
|
345
|
-
const previousMonthStart = new Date(year, month - 2, 1);
|
|
346
|
-
const previousMonthEnd = new Date(year, month - 1, 0);
|
|
347
|
-
return this.generateReport({
|
|
348
|
-
id: `monthly_${year}_${month.toString().padStart(2, "0")}`,
|
|
349
|
-
name: `Monthly Report - ${year}-${month.toString().padStart(2, "0")}`,
|
|
350
|
-
description: "Monthly messaging performance analysis",
|
|
351
|
-
dateRange: { start: monthStart, end: monthEnd },
|
|
352
|
-
filters: {},
|
|
353
|
-
metrics: await this.calculateMonthlyMetrics(monthStart, monthEnd, previousMonthStart, previousMonthEnd),
|
|
354
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
355
|
-
format: "json"
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* 프로바이더별 성능 보고서
|
|
360
|
-
*/
|
|
361
|
-
async generateProviderReport(providerId, dateRange) {
|
|
362
|
-
return this.generateReport({
|
|
363
|
-
id: `provider_${providerId}_${dateRange.start.toISOString().split("T")[0]}`,
|
|
364
|
-
name: `Provider Performance - ${providerId}`,
|
|
365
|
-
description: `Performance analysis for provider ${providerId}`,
|
|
366
|
-
dateRange,
|
|
367
|
-
filters: { provider: providerId },
|
|
368
|
-
metrics: await this.calculateProviderMetrics(providerId, dateRange),
|
|
369
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
370
|
-
format: "json"
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* 템플릿 사용량 보고서
|
|
375
|
-
*/
|
|
376
|
-
async generateTemplateUsageReport(dateRange) {
|
|
377
|
-
return this.generateReport({
|
|
378
|
-
id: `template_usage_${dateRange.start.toISOString().split("T")[0]}`,
|
|
379
|
-
name: "Template Usage Report",
|
|
380
|
-
description: "Analysis of template usage and performance",
|
|
381
|
-
dateRange,
|
|
382
|
-
filters: {},
|
|
383
|
-
metrics: await this.calculateTemplateMetrics(dateRange),
|
|
384
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
385
|
-
format: "json"
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* 커스텀 보고서 생성
|
|
390
|
-
*/
|
|
391
|
-
async generateCustomReport(name, dateRange, filters, metricTypes) {
|
|
392
|
-
return this.generateReport({
|
|
393
|
-
id: `custom_${Date.now()}`,
|
|
394
|
-
name,
|
|
395
|
-
description: "Custom analytics report",
|
|
396
|
-
dateRange,
|
|
397
|
-
filters,
|
|
398
|
-
metrics: await this.calculateCustomMetrics(dateRange, filters, metricTypes),
|
|
399
|
-
generatedAt: /* @__PURE__ */ new Date(),
|
|
400
|
-
format: "json"
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* 보고서를 CSV 형식으로 내보내기
|
|
405
|
-
*/
|
|
406
|
-
async exportToCSV(report) {
|
|
407
|
-
const headers = ["Metric Type", "Value", "Change (%)", "Trend"];
|
|
408
|
-
const rows = report.metrics.map((metric) => [
|
|
409
|
-
metric.type.toString(),
|
|
410
|
-
metric.value.toString(),
|
|
411
|
-
metric.change?.toFixed(2) || "0",
|
|
412
|
-
metric.trend || "stable"
|
|
413
|
-
]);
|
|
414
|
-
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(",")).join("\n");
|
|
415
|
-
return csvContent;
|
|
416
|
-
}
|
|
417
|
-
/**
|
|
418
|
-
* 보고서를 JSON 형식으로 내보내기
|
|
419
|
-
*/
|
|
420
|
-
async exportToJSON(report) {
|
|
421
|
-
return JSON.stringify(report, null, 2);
|
|
422
|
-
}
|
|
423
|
-
async generateReport(reportData) {
|
|
424
|
-
this.validateReport(reportData);
|
|
425
|
-
reportData.metrics.sort((a, b) => {
|
|
426
|
-
const priority = this.getMetricPriority(a.type) - this.getMetricPriority(b.type);
|
|
427
|
-
return priority;
|
|
428
|
-
});
|
|
429
|
-
return reportData;
|
|
430
|
-
}
|
|
431
|
-
async calculateDailyMetrics(startDate, endDate, previousDate) {
|
|
432
|
-
const metrics = [];
|
|
433
|
-
const totalSent = await this.getMetricValue("message_sent" /* MESSAGE_SENT */, startDate, endDate);
|
|
434
|
-
const totalDelivered = await this.getMetricValue("message_delivered" /* MESSAGE_DELIVERED */, startDate, endDate);
|
|
435
|
-
const totalFailed = await this.getMetricValue("message_failed" /* MESSAGE_FAILED */, startDate, endDate);
|
|
436
|
-
const totalClicked = await this.getMetricValue("message_clicked" /* MESSAGE_CLICKED */, startDate, endDate);
|
|
437
|
-
const previousStart = new Date(previousDate);
|
|
438
|
-
const previousEnd = new Date(previousDate);
|
|
439
|
-
previousEnd.setHours(23, 59, 59, 999);
|
|
440
|
-
const prevSent = await this.getMetricValue("message_sent" /* MESSAGE_SENT */, previousStart, previousEnd);
|
|
441
|
-
metrics.push({
|
|
442
|
-
type: "message_sent" /* MESSAGE_SENT */,
|
|
443
|
-
value: totalSent,
|
|
444
|
-
change: this.calculateChange(totalSent, prevSent),
|
|
445
|
-
trend: this.calculateTrend(totalSent, prevSent)
|
|
446
|
-
});
|
|
447
|
-
if (totalSent > 0) {
|
|
448
|
-
const deliveryRate = totalDelivered / totalSent * 100;
|
|
449
|
-
const errorRate = totalFailed / totalSent * 100;
|
|
450
|
-
metrics.push({
|
|
451
|
-
type: "delivery_rate" /* DELIVERY_RATE */,
|
|
452
|
-
value: deliveryRate,
|
|
453
|
-
change: 0,
|
|
454
|
-
// TODO: 이전 기간과 비교
|
|
455
|
-
trend: "stable"
|
|
456
|
-
});
|
|
457
|
-
metrics.push({
|
|
458
|
-
type: "error_rate" /* ERROR_RATE */,
|
|
459
|
-
value: errorRate,
|
|
460
|
-
change: 0,
|
|
461
|
-
// TODO: 이전 기간과 비교
|
|
462
|
-
trend: "stable"
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
if (totalDelivered > 0) {
|
|
466
|
-
const clickRate = totalClicked / totalDelivered * 100;
|
|
467
|
-
metrics.push({
|
|
468
|
-
type: "click_rate" /* CLICK_RATE */,
|
|
469
|
-
value: clickRate,
|
|
470
|
-
change: 0,
|
|
471
|
-
// TODO: 이전 기간과 비교
|
|
472
|
-
trend: "stable"
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
return metrics;
|
|
476
|
-
}
|
|
477
|
-
async calculateWeeklyMetrics(weekStart, weekEnd, previousWeekStart) {
|
|
478
|
-
return this.calculateDailyMetrics(weekStart, weekEnd, previousWeekStart);
|
|
479
|
-
}
|
|
480
|
-
async calculateMonthlyMetrics(monthStart, monthEnd, previousMonthStart, previousMonthEnd) {
|
|
481
|
-
return this.calculateDailyMetrics(monthStart, monthEnd, previousMonthStart);
|
|
482
|
-
}
|
|
483
|
-
async calculateProviderMetrics(providerId, dateRange) {
|
|
484
|
-
const metrics = [];
|
|
485
|
-
const performance = await this.getProviderPerformance(providerId, dateRange);
|
|
486
|
-
metrics.push({
|
|
487
|
-
type: "provider_performance" /* PROVIDER_PERFORMANCE */,
|
|
488
|
-
value: performance.averageResponseTime,
|
|
489
|
-
breakdown: {
|
|
490
|
-
"Success Rate": performance.successRate,
|
|
491
|
-
"Error Rate": performance.errorRate,
|
|
492
|
-
"Avg Response Time": performance.averageResponseTime
|
|
493
|
-
}
|
|
494
|
-
});
|
|
495
|
-
return metrics;
|
|
496
|
-
}
|
|
497
|
-
async calculateTemplateMetrics(dateRange) {
|
|
498
|
-
const metrics = [];
|
|
499
|
-
const templateUsage = await this.getTemplateUsage(dateRange);
|
|
500
|
-
metrics.push({
|
|
501
|
-
type: "template_usage" /* TEMPLATE_USAGE */,
|
|
502
|
-
value: templateUsage.totalUsage,
|
|
503
|
-
breakdown: templateUsage.byTemplate
|
|
504
|
-
});
|
|
505
|
-
return metrics;
|
|
506
|
-
}
|
|
507
|
-
async calculateCustomMetrics(dateRange, filters, metricTypes) {
|
|
508
|
-
const metrics = [];
|
|
509
|
-
for (const type of metricTypes) {
|
|
510
|
-
const value = await this.getMetricValue(type, dateRange.start, dateRange.end, filters);
|
|
511
|
-
metrics.push({
|
|
512
|
-
type,
|
|
513
|
-
value,
|
|
514
|
-
change: 0,
|
|
515
|
-
// TODO: 이전 기간과 비교
|
|
516
|
-
trend: "stable"
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
return metrics;
|
|
520
|
-
}
|
|
521
|
-
async getMetricValue(type, start, end, filters) {
|
|
522
|
-
return Math.floor(Math.random() * 1e4);
|
|
523
|
-
}
|
|
524
|
-
async getProviderPerformance(providerId, dateRange) {
|
|
525
|
-
return {
|
|
526
|
-
successRate: 95.5,
|
|
527
|
-
errorRate: 4.5,
|
|
528
|
-
averageResponseTime: 250
|
|
529
|
-
// ms
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
async getTemplateUsage(dateRange) {
|
|
533
|
-
return {
|
|
534
|
-
totalUsage: 5e3,
|
|
535
|
-
byTemplate: {
|
|
536
|
-
"auth_otp": 2e3,
|
|
537
|
-
"welcome": 1500,
|
|
538
|
-
"notification": 1e3,
|
|
539
|
-
"others": 500
|
|
540
|
-
}
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
calculateChange(current, previous) {
|
|
544
|
-
if (previous === 0) return current > 0 ? 100 : 0;
|
|
545
|
-
return (current - previous) / previous * 100;
|
|
546
|
-
}
|
|
547
|
-
calculateTrend(current, previous) {
|
|
548
|
-
const change = this.calculateChange(current, previous);
|
|
549
|
-
if (Math.abs(change) < 5) return "stable";
|
|
550
|
-
return change > 0 ? "up" : "down";
|
|
551
|
-
}
|
|
552
|
-
getMetricPriority(type) {
|
|
553
|
-
const priorities = {
|
|
554
|
-
["message_sent" /* MESSAGE_SENT */]: 1,
|
|
555
|
-
["delivery_rate" /* DELIVERY_RATE */]: 2,
|
|
556
|
-
["error_rate" /* ERROR_RATE */]: 3,
|
|
557
|
-
["click_rate" /* CLICK_RATE */]: 4,
|
|
558
|
-
["message_delivered" /* MESSAGE_DELIVERED */]: 5,
|
|
559
|
-
["message_failed" /* MESSAGE_FAILED */]: 6,
|
|
560
|
-
["message_clicked" /* MESSAGE_CLICKED */]: 7,
|
|
561
|
-
["template_usage" /* TEMPLATE_USAGE */]: 8,
|
|
562
|
-
["provider_performance" /* PROVIDER_PERFORMANCE */]: 9,
|
|
563
|
-
["channel_usage" /* CHANNEL_USAGE */]: 10
|
|
564
|
-
};
|
|
565
|
-
return priorities[type] || 99;
|
|
566
|
-
}
|
|
567
|
-
validateReport(report) {
|
|
568
|
-
if (!report.id) {
|
|
569
|
-
throw new Error("Report ID is required");
|
|
570
|
-
}
|
|
571
|
-
if (!report.name) {
|
|
572
|
-
throw new Error("Report name is required");
|
|
573
|
-
}
|
|
574
|
-
if (!report.dateRange || !report.dateRange.start || !report.dateRange.end) {
|
|
575
|
-
throw new Error("Valid date range is required");
|
|
576
|
-
}
|
|
577
|
-
if (report.dateRange.start >= report.dateRange.end) {
|
|
578
|
-
throw new Error("Invalid date range: start must be before end");
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
// src/services/insight.engine.ts
|
|
584
|
-
var InsightEngine = class {
|
|
585
|
-
config;
|
|
586
|
-
anomalyThresholds = /* @__PURE__ */ new Map();
|
|
587
|
-
historicalData = /* @__PURE__ */ new Map();
|
|
588
|
-
constructor(config) {
|
|
589
|
-
this.config = config;
|
|
590
|
-
this.initializeBaselines();
|
|
591
|
-
}
|
|
592
|
-
/**
|
|
593
|
-
* 실시간 이상 탐지
|
|
594
|
-
*/
|
|
595
|
-
async detectRealTimeAnomalies(metric) {
|
|
596
|
-
const insights = [];
|
|
597
|
-
const threshold = this.anomalyThresholds.get(metric.type);
|
|
598
|
-
if (!threshold) {
|
|
599
|
-
return insights;
|
|
600
|
-
}
|
|
601
|
-
if (metric.value > threshold.max || metric.value < threshold.min) {
|
|
602
|
-
insights.push({
|
|
603
|
-
id: `anomaly_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
604
|
-
type: "anomaly",
|
|
605
|
-
title: `${metric.type} Anomaly Detected`,
|
|
606
|
-
description: `Value ${metric.value} is outside normal range [${threshold.min}, ${threshold.max}]`,
|
|
607
|
-
severity: this.calculateSeverity(metric.value, threshold),
|
|
608
|
-
metric: metric.type,
|
|
609
|
-
dimensions: metric.dimensions,
|
|
610
|
-
value: metric.value,
|
|
611
|
-
expectedValue: (threshold.min + threshold.max) / 2,
|
|
612
|
-
confidence: this.calculateConfidence(metric.value, threshold),
|
|
613
|
-
actionable: true,
|
|
614
|
-
recommendations: this.generateRecommendations(metric),
|
|
615
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
616
|
-
});
|
|
617
|
-
}
|
|
618
|
-
const recentTrend = await this.detectTrendChange(metric);
|
|
619
|
-
if (recentTrend) {
|
|
620
|
-
insights.push(recentTrend);
|
|
621
|
-
}
|
|
622
|
-
return insights;
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* 시계열 이상 탐지
|
|
626
|
-
*/
|
|
627
|
-
async detectAnomalies(metricType, timeRange) {
|
|
628
|
-
const insights = [];
|
|
629
|
-
const seasonalAnomalies = await this.detectSeasonalAnomalies(metricType, timeRange);
|
|
630
|
-
insights.push(...seasonalAnomalies);
|
|
631
|
-
const patternAnomalies = await this.detectPatternAnomalies(metricType, timeRange);
|
|
632
|
-
insights.push(...patternAnomalies);
|
|
633
|
-
const thresholdAnomalies = await this.detectThresholdAnomalies(metricType, timeRange);
|
|
634
|
-
insights.push(...thresholdAnomalies);
|
|
635
|
-
return insights;
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* 인사이트 생성
|
|
639
|
-
*/
|
|
640
|
-
async generateInsights(query, data) {
|
|
641
|
-
const insights = [];
|
|
642
|
-
const performanceInsights = await this.generatePerformanceInsights(data);
|
|
643
|
-
insights.push(...performanceInsights);
|
|
644
|
-
const trendInsights = await this.generateTrendInsights(data);
|
|
645
|
-
insights.push(...trendInsights);
|
|
646
|
-
const comparisonInsights = await this.generateComparisonInsights(data);
|
|
647
|
-
insights.push(...comparisonInsights);
|
|
648
|
-
const recommendationInsights = await this.generateRecommendationInsights(data);
|
|
649
|
-
insights.push(...recommendationInsights);
|
|
650
|
-
return insights.sort((a, b) => {
|
|
651
|
-
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
652
|
-
return severityOrder[b.severity] - severityOrder[a.severity];
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
/**
|
|
656
|
-
* 트렌드 예측
|
|
657
|
-
*/
|
|
658
|
-
async predictTrends(metricType, forecastDays) {
|
|
659
|
-
const historical = this.historicalData.get(metricType) || [];
|
|
660
|
-
if (historical.length < 7) {
|
|
661
|
-
return [];
|
|
662
|
-
}
|
|
663
|
-
const predictions = [];
|
|
664
|
-
const lastValue = historical[historical.length - 1];
|
|
665
|
-
const trend = this.calculateTrend(historical);
|
|
666
|
-
for (let i = 1; i <= forecastDays; i++) {
|
|
667
|
-
const predictedValue = lastValue + trend * i;
|
|
668
|
-
const confidence = Math.max(0.1, 1 - i * 0.1);
|
|
669
|
-
predictions.push({
|
|
670
|
-
date: new Date(Date.now() + i * 24 * 60 * 60 * 1e3),
|
|
671
|
-
predicted: Math.max(0, predictedValue),
|
|
672
|
-
confidence
|
|
673
|
-
});
|
|
674
|
-
}
|
|
675
|
-
return predictions;
|
|
676
|
-
}
|
|
677
|
-
async initializeBaselines() {
|
|
678
|
-
this.anomalyThresholds.set("message_sent" /* MESSAGE_SENT */, { min: 0, max: 1e4, stdDev: 500 });
|
|
679
|
-
this.anomalyThresholds.set("message_delivered" /* MESSAGE_DELIVERED */, { min: 0, max: 1e4, stdDev: 500 });
|
|
680
|
-
this.anomalyThresholds.set("message_failed" /* MESSAGE_FAILED */, { min: 0, max: 1e3, stdDev: 100 });
|
|
681
|
-
this.anomalyThresholds.set("delivery_rate" /* DELIVERY_RATE */, { min: 85, max: 99, stdDev: 5 });
|
|
682
|
-
this.anomalyThresholds.set("error_rate" /* ERROR_RATE */, { min: 0, max: 15, stdDev: 3 });
|
|
683
|
-
}
|
|
684
|
-
async detectTrendChange(metric) {
|
|
685
|
-
const history = this.historicalData.get(metric.type) || [];
|
|
686
|
-
history.push(metric.value);
|
|
687
|
-
if (history.length > 5) {
|
|
688
|
-
history.shift();
|
|
689
|
-
}
|
|
690
|
-
this.historicalData.set(metric.type, history);
|
|
691
|
-
if (history.length < 3) {
|
|
692
|
-
return null;
|
|
693
|
-
}
|
|
694
|
-
const recentChange = (history[history.length - 1] - history[history.length - 2]) / history[history.length - 2];
|
|
695
|
-
if (Math.abs(recentChange) > 0.5) {
|
|
696
|
-
return {
|
|
697
|
-
id: `trend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
698
|
-
type: "trend",
|
|
699
|
-
title: `Sudden ${recentChange > 0 ? "Increase" : "Decrease"} in ${metric.type}`,
|
|
700
|
-
description: `${metric.type} changed by ${(recentChange * 100).toFixed(1)}% in the last period`,
|
|
701
|
-
severity: Math.abs(recentChange) > 0.8 ? "high" : "medium",
|
|
702
|
-
metric: metric.type,
|
|
703
|
-
dimensions: metric.dimensions,
|
|
704
|
-
value: metric.value,
|
|
705
|
-
expectedValue: history[history.length - 2],
|
|
706
|
-
confidence: 0.8,
|
|
707
|
-
actionable: true,
|
|
708
|
-
recommendations: [`Monitor ${metric.type} closely`, "Check for system changes or external factors"],
|
|
709
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
return null;
|
|
713
|
-
}
|
|
714
|
-
async detectSeasonalAnomalies(metricType, timeRange) {
|
|
715
|
-
const insights = [];
|
|
716
|
-
const isWeekend = timeRange.start.getDay() === 0 || timeRange.start.getDay() === 6;
|
|
717
|
-
const currentHour = timeRange.start.getHours();
|
|
718
|
-
if (!isWeekend && (currentHour < 6 || currentHour > 22)) {
|
|
719
|
-
insights.push({
|
|
720
|
-
id: `seasonal_${Date.now()}`,
|
|
721
|
-
type: "anomaly",
|
|
722
|
-
title: "Unusual Activity During Off-Hours",
|
|
723
|
-
description: `High ${metricType} activity detected during off-business hours`,
|
|
724
|
-
severity: "medium",
|
|
725
|
-
metric: metricType,
|
|
726
|
-
dimensions: { timeframe: "off_hours" },
|
|
727
|
-
value: 0,
|
|
728
|
-
// 실제 값으로 대체
|
|
729
|
-
confidence: 0.7,
|
|
730
|
-
actionable: true,
|
|
731
|
-
recommendations: ["Check for automated systems or bulk operations", "Verify if this is expected behavior"],
|
|
732
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
return insights;
|
|
736
|
-
}
|
|
737
|
-
async detectPatternAnomalies(metricType, timeRange) {
|
|
738
|
-
return [];
|
|
739
|
-
}
|
|
740
|
-
async detectThresholdAnomalies(metricType, timeRange) {
|
|
741
|
-
return [];
|
|
742
|
-
}
|
|
743
|
-
async generatePerformanceInsights(data) {
|
|
744
|
-
const insights = [];
|
|
745
|
-
const deliveryMetrics = data.filter((d) => d.type === "delivery_rate" /* DELIVERY_RATE */);
|
|
746
|
-
for (const metric of deliveryMetrics) {
|
|
747
|
-
if (metric.aggregations.avg < 90) {
|
|
748
|
-
insights.push({
|
|
749
|
-
id: `performance_delivery_${Date.now()}`,
|
|
750
|
-
type: "recommendation",
|
|
751
|
-
title: "Low Delivery Rate Detected",
|
|
752
|
-
description: `Delivery rate of ${metric.aggregations.avg.toFixed(1)}% is below optimal threshold`,
|
|
753
|
-
severity: metric.aggregations.avg < 80 ? "high" : "medium",
|
|
754
|
-
metric: "delivery_rate" /* DELIVERY_RATE */,
|
|
755
|
-
dimensions: metric.dimensions,
|
|
756
|
-
value: metric.aggregations.avg,
|
|
757
|
-
expectedValue: 95,
|
|
758
|
-
confidence: 0.9,
|
|
759
|
-
actionable: true,
|
|
760
|
-
recommendations: [
|
|
761
|
-
"Check provider API status",
|
|
762
|
-
"Review template approval status",
|
|
763
|
-
"Verify recipient phone numbers",
|
|
764
|
-
"Consider switching to backup provider"
|
|
765
|
-
],
|
|
766
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
return insights;
|
|
771
|
-
}
|
|
772
|
-
async generateTrendInsights(data) {
|
|
773
|
-
const insights = [];
|
|
774
|
-
const sentMetrics = data.filter((d) => d.type === "message_sent" /* MESSAGE_SENT */).sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
775
|
-
if (sentMetrics.length >= 3) {
|
|
776
|
-
const trend = this.calculateTrend(sentMetrics.map((m) => m.aggregations.sum));
|
|
777
|
-
if (Math.abs(trend) > 100) {
|
|
778
|
-
insights.push({
|
|
779
|
-
id: `trend_sent_${Date.now()}`,
|
|
780
|
-
type: "trend",
|
|
781
|
-
title: `${trend > 0 ? "Increasing" : "Decreasing"} Message Volume`,
|
|
782
|
-
description: `Message volume is ${trend > 0 ? "increasing" : "decreasing"} by approximately ${Math.abs(trend).toFixed(0)} messages per day`,
|
|
783
|
-
severity: "low",
|
|
784
|
-
metric: "message_sent" /* MESSAGE_SENT */,
|
|
785
|
-
dimensions: {},
|
|
786
|
-
value: sentMetrics[sentMetrics.length - 1].aggregations.sum,
|
|
787
|
-
confidence: 0.7,
|
|
788
|
-
actionable: trend < -500,
|
|
789
|
-
// 급격한 감소시에만 액션 필요
|
|
790
|
-
recommendations: trend < -500 ? [
|
|
791
|
-
"Investigate cause of volume decrease",
|
|
792
|
-
"Check for system issues or campaign changes"
|
|
793
|
-
] : void 0,
|
|
794
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
return insights;
|
|
799
|
-
}
|
|
800
|
-
async generateComparisonInsights(data) {
|
|
801
|
-
const insights = [];
|
|
802
|
-
const providerPerformance = /* @__PURE__ */ new Map();
|
|
803
|
-
for (const metric of data) {
|
|
804
|
-
const provider = metric.dimensions.provider;
|
|
805
|
-
if (!provider) continue;
|
|
806
|
-
if (!providerPerformance.has(provider)) {
|
|
807
|
-
providerPerformance.set(provider, { delivered: 0, sent: 0 });
|
|
808
|
-
}
|
|
809
|
-
const stats = providerPerformance.get(provider);
|
|
810
|
-
if (metric.type === "message_sent" /* MESSAGE_SENT */) {
|
|
811
|
-
stats.sent += metric.aggregations.sum;
|
|
812
|
-
} else if (metric.type === "message_delivered" /* MESSAGE_DELIVERED */) {
|
|
813
|
-
stats.delivered += metric.aggregations.sum;
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
const providers = Array.from(providerPerformance.entries());
|
|
817
|
-
if (providers.length > 1) {
|
|
818
|
-
const rates = providers.map(([provider, stats]) => ({
|
|
819
|
-
provider,
|
|
820
|
-
rate: stats.sent > 0 ? stats.delivered / stats.sent * 100 : 0,
|
|
821
|
-
volume: stats.sent
|
|
822
|
-
}));
|
|
823
|
-
const avgRate = rates.reduce((sum, r) => sum + r.rate, 0) / rates.length;
|
|
824
|
-
const underperforming = rates.filter((r) => r.rate < avgRate - 10);
|
|
825
|
-
for (const provider of underperforming) {
|
|
826
|
-
insights.push({
|
|
827
|
-
id: `comparison_provider_${provider.provider}_${Date.now()}`,
|
|
828
|
-
type: "recommendation",
|
|
829
|
-
title: `Provider ${provider.provider} Underperforming`,
|
|
830
|
-
description: `${provider.provider} delivery rate (${provider.rate.toFixed(1)}%) is significantly below average (${avgRate.toFixed(1)}%)`,
|
|
831
|
-
severity: provider.rate < avgRate - 20 ? "high" : "medium",
|
|
832
|
-
metric: "provider_performance" /* PROVIDER_PERFORMANCE */,
|
|
833
|
-
dimensions: { provider: provider.provider },
|
|
834
|
-
value: provider.rate,
|
|
835
|
-
expectedValue: avgRate,
|
|
836
|
-
confidence: 0.8,
|
|
837
|
-
actionable: true,
|
|
838
|
-
recommendations: [
|
|
839
|
-
`Contact ${provider.provider} support team`,
|
|
840
|
-
"Consider reducing traffic to this provider",
|
|
841
|
-
"Check provider-specific settings and configurations"
|
|
842
|
-
],
|
|
843
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
844
|
-
});
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
return insights;
|
|
848
|
-
}
|
|
849
|
-
async generateRecommendationInsights(data) {
|
|
850
|
-
const insights = [];
|
|
851
|
-
const channelUsage = /* @__PURE__ */ new Map();
|
|
852
|
-
for (const metric of data) {
|
|
853
|
-
if (metric.type === "channel_usage" /* CHANNEL_USAGE */) {
|
|
854
|
-
const channel = metric.dimensions.channel;
|
|
855
|
-
if (channel) {
|
|
856
|
-
channelUsage.set(channel, (channelUsage.get(channel) || 0) + metric.aggregations.sum);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
const totalUsage = Array.from(channelUsage.values()).reduce((sum, usage) => sum + usage, 0);
|
|
861
|
-
if (totalUsage > 0) {
|
|
862
|
-
const smsUsage = channelUsage.get("sms") || 0;
|
|
863
|
-
const alimtalkUsage = channelUsage.get("alimtalk") || 0;
|
|
864
|
-
if (smsUsage / totalUsage > 0.3 && alimtalkUsage > 0) {
|
|
865
|
-
insights.push({
|
|
866
|
-
id: `recommendation_channel_${Date.now()}`,
|
|
867
|
-
type: "recommendation",
|
|
868
|
-
title: "Consider Switching to AlimTalk",
|
|
869
|
-
description: `${(smsUsage / totalUsage * 100).toFixed(1)}% of messages are sent via SMS. AlimTalk could reduce costs`,
|
|
870
|
-
severity: "low",
|
|
871
|
-
metric: "channel_usage" /* CHANNEL_USAGE */,
|
|
872
|
-
dimensions: { optimization: "cost" },
|
|
873
|
-
value: smsUsage,
|
|
874
|
-
confidence: 0.8,
|
|
875
|
-
actionable: true,
|
|
876
|
-
recommendations: [
|
|
877
|
-
"Evaluate message types suitable for AlimTalk conversion",
|
|
878
|
-
"Create AlimTalk templates for common SMS use cases",
|
|
879
|
-
"Implement fallback logic for failed AlimTalk messages"
|
|
880
|
-
],
|
|
881
|
-
detectedAt: /* @__PURE__ */ new Date()
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
return insights;
|
|
886
|
-
}
|
|
887
|
-
calculateSeverity(value, threshold) {
|
|
888
|
-
const distance = Math.min(
|
|
889
|
-
Math.abs(value - threshold.min) / threshold.stdDev,
|
|
890
|
-
Math.abs(value - threshold.max) / threshold.stdDev
|
|
891
|
-
);
|
|
892
|
-
if (distance > 3) return "critical";
|
|
893
|
-
if (distance > 2) return "high";
|
|
894
|
-
if (distance > 1) return "medium";
|
|
895
|
-
return "low";
|
|
896
|
-
}
|
|
897
|
-
calculateConfidence(value, threshold) {
|
|
898
|
-
const distance = Math.min(
|
|
899
|
-
Math.abs(value - threshold.min) / threshold.stdDev,
|
|
900
|
-
Math.abs(value - threshold.max) / threshold.stdDev
|
|
901
|
-
);
|
|
902
|
-
return Math.min(0.99, 0.5 + distance * 0.15);
|
|
903
|
-
}
|
|
904
|
-
generateRecommendations(metric) {
|
|
905
|
-
const recommendations = [];
|
|
906
|
-
switch (metric.type) {
|
|
907
|
-
case "message_failed" /* MESSAGE_FAILED */:
|
|
908
|
-
recommendations.push(
|
|
909
|
-
"Check provider API status and connectivity",
|
|
910
|
-
"Verify template approval status",
|
|
911
|
-
"Validate recipient phone numbers",
|
|
912
|
-
"Review message content for compliance"
|
|
913
|
-
);
|
|
914
|
-
break;
|
|
915
|
-
case "delivery_rate" /* DELIVERY_RATE */:
|
|
916
|
-
recommendations.push(
|
|
917
|
-
"Monitor provider performance metrics",
|
|
918
|
-
"Check for network connectivity issues",
|
|
919
|
-
"Verify recipient opt-in status"
|
|
920
|
-
);
|
|
921
|
-
break;
|
|
922
|
-
case "error_rate" /* ERROR_RATE */:
|
|
923
|
-
recommendations.push(
|
|
924
|
-
"Investigate error patterns and root causes",
|
|
925
|
-
"Implement retry mechanisms for transient failures",
|
|
926
|
-
"Consider switching to backup provider"
|
|
927
|
-
);
|
|
928
|
-
break;
|
|
929
|
-
default:
|
|
930
|
-
recommendations.push(
|
|
931
|
-
"Monitor the metric closely",
|
|
932
|
-
"Investigate potential causes",
|
|
933
|
-
"Check system health and performance"
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
return recommendations;
|
|
937
|
-
}
|
|
938
|
-
calculateTrend(values) {
|
|
939
|
-
if (values.length < 2) return 0;
|
|
940
|
-
const n = values.length;
|
|
941
|
-
const x = Array.from({ length: n }, (_, i) => i);
|
|
942
|
-
const sumX = x.reduce((a, b) => a + b, 0);
|
|
943
|
-
const sumY = values.reduce((a, b) => a + b, 0);
|
|
944
|
-
const sumXY = x.reduce((sum, xi, i) => sum + xi * values[i], 0);
|
|
945
|
-
const sumXX = x.reduce((sum, xi) => sum + xi * xi, 0);
|
|
946
|
-
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
947
|
-
return slope;
|
|
948
|
-
}
|
|
949
|
-
};
|
|
950
|
-
|
|
951
|
-
// src/services/analytics.service.ts
|
|
952
|
-
var AnalyticsService = class {
|
|
953
|
-
config;
|
|
954
|
-
metricsCollector;
|
|
955
|
-
reportGenerator;
|
|
956
|
-
insightEngine;
|
|
957
|
-
metrics = /* @__PURE__ */ new Map();
|
|
958
|
-
aggregatedMetrics = /* @__PURE__ */ new Map();
|
|
959
|
-
constructor(config) {
|
|
960
|
-
this.config = config;
|
|
961
|
-
this.metricsCollector = new MetricsCollector(config);
|
|
962
|
-
this.reportGenerator = new ReportGenerator(config);
|
|
963
|
-
this.insightEngine = new InsightEngine(config);
|
|
964
|
-
this.startAggregationTasks();
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* 메트릭 데이터 수집
|
|
968
|
-
*/
|
|
969
|
-
async collectMetric(metric) {
|
|
970
|
-
if (!this.config.enabledMetrics.includes(metric.type)) {
|
|
971
|
-
return;
|
|
972
|
-
}
|
|
973
|
-
await this.metricsCollector.collect(metric);
|
|
974
|
-
if (this.config.enableRealTimeTracking) {
|
|
975
|
-
await this.processRealTimeMetric(metric);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
/**
|
|
979
|
-
* 분석 쿼리 실행
|
|
980
|
-
*/
|
|
981
|
-
async query(query) {
|
|
982
|
-
const startTime = Date.now();
|
|
983
|
-
this.validateQuery(query);
|
|
984
|
-
const data = await this.executeQuery(query);
|
|
985
|
-
const insights = await this.generateInsights(query, data);
|
|
986
|
-
const executionTime = Date.now() - startTime;
|
|
987
|
-
return {
|
|
988
|
-
query,
|
|
989
|
-
data,
|
|
990
|
-
summary: {
|
|
991
|
-
totalRecords: data.length,
|
|
992
|
-
dateRange: query.dateRange,
|
|
993
|
-
executionTime
|
|
994
|
-
},
|
|
995
|
-
insights
|
|
996
|
-
};
|
|
997
|
-
}
|
|
998
|
-
/**
|
|
999
|
-
* 실시간 메트릭 스트림
|
|
1000
|
-
*/
|
|
1001
|
-
async *streamMetrics(types) {
|
|
1002
|
-
while (true) {
|
|
1003
|
-
const metrics = await this.metricsCollector.getRecentMetrics(types, 1e3);
|
|
1004
|
-
for (const metric of metrics) {
|
|
1005
|
-
yield metric;
|
|
1006
|
-
}
|
|
1007
|
-
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
/**
|
|
1011
|
-
* 대시보드 데이터 조회
|
|
1012
|
-
*/
|
|
1013
|
-
async getDashboardData(timeRange) {
|
|
1014
|
-
const now = /* @__PURE__ */ new Date();
|
|
1015
|
-
const previousPeriod = {
|
|
1016
|
-
start: new Date(timeRange.start.getTime() - (timeRange.end.getTime() - timeRange.start.getTime())),
|
|
1017
|
-
end: timeRange.start
|
|
1018
|
-
};
|
|
1019
|
-
const currentData = await this.query({
|
|
1020
|
-
metrics: this.config.enabledMetrics,
|
|
1021
|
-
dateRange: timeRange,
|
|
1022
|
-
interval: this.getOptimalInterval(timeRange),
|
|
1023
|
-
groupBy: ["provider", "channel"]
|
|
1024
|
-
});
|
|
1025
|
-
const previousData = await this.query({
|
|
1026
|
-
metrics: this.config.enabledMetrics,
|
|
1027
|
-
dateRange: previousPeriod,
|
|
1028
|
-
interval: this.getOptimalInterval(timeRange),
|
|
1029
|
-
groupBy: ["provider", "channel"]
|
|
1030
|
-
});
|
|
1031
|
-
const kpis = this.calculateKPIs(currentData.data, previousData.data);
|
|
1032
|
-
return {
|
|
1033
|
-
timeRange,
|
|
1034
|
-
kpis,
|
|
1035
|
-
metrics: currentData.data,
|
|
1036
|
-
insights: currentData.insights,
|
|
1037
|
-
trends: this.calculateTrends(currentData.data, previousData.data)
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
/**
|
|
1041
|
-
* 이상 탐지
|
|
1042
|
-
*/
|
|
1043
|
-
async detectAnomalies(metricType, timeRange) {
|
|
1044
|
-
return this.insightEngine.detectAnomalies(metricType, timeRange);
|
|
1045
|
-
}
|
|
1046
|
-
async processRealTimeMetric(metric) {
|
|
1047
|
-
const anomalies = await this.insightEngine.detectRealTimeAnomalies(metric);
|
|
1048
|
-
if (anomalies.length > 0) {
|
|
1049
|
-
await this.notifyAnomalies(anomalies);
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
validateQuery(query) {
|
|
1053
|
-
if (query.dateRange.start >= query.dateRange.end) {
|
|
1054
|
-
throw new Error("Invalid date range: start must be before end");
|
|
1055
|
-
}
|
|
1056
|
-
const maxRangeMs = 90 * 24 * 60 * 60 * 1e3;
|
|
1057
|
-
if (query.dateRange.end.getTime() - query.dateRange.start.getTime() > maxRangeMs) {
|
|
1058
|
-
throw new Error("Date range too large: maximum 90 days");
|
|
1059
|
-
}
|
|
1060
|
-
if (query.limit && query.limit > 1e4) {
|
|
1061
|
-
throw new Error("Limit too large: maximum 10000 records");
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
async executeQuery(query) {
|
|
1065
|
-
const interval = query.interval || this.getOptimalInterval(query.dateRange);
|
|
1066
|
-
const cacheKey = this.generateCacheKey(query);
|
|
1067
|
-
if (this.aggregatedMetrics.has(cacheKey)) {
|
|
1068
|
-
return this.aggregatedMetrics.get(cacheKey);
|
|
1069
|
-
}
|
|
1070
|
-
const aggregated = await this.performAggregation(query, interval);
|
|
1071
|
-
this.aggregatedMetrics.set(cacheKey, aggregated);
|
|
1072
|
-
return aggregated;
|
|
1073
|
-
}
|
|
1074
|
-
async performAggregation(query, interval) {
|
|
1075
|
-
return [];
|
|
1076
|
-
}
|
|
1077
|
-
async generateInsights(query, data) {
|
|
1078
|
-
return this.insightEngine.generateInsights(query, data);
|
|
1079
|
-
}
|
|
1080
|
-
getOptimalInterval(dateRange) {
|
|
1081
|
-
const durationMs = dateRange.end.getTime() - dateRange.start.getTime();
|
|
1082
|
-
const durationDays = durationMs / (24 * 60 * 60 * 1e3);
|
|
1083
|
-
if (durationDays <= 1) return "minute";
|
|
1084
|
-
if (durationDays <= 7) return "hour";
|
|
1085
|
-
if (durationDays <= 30) return "day";
|
|
1086
|
-
if (durationDays <= 90) return "week";
|
|
1087
|
-
return "month";
|
|
1088
|
-
}
|
|
1089
|
-
calculateKPIs(current, previous) {
|
|
1090
|
-
return {
|
|
1091
|
-
totalMessages: this.sumMetrics(current, "message_sent"),
|
|
1092
|
-
deliveryRate: this.calculateRate(current, "message_delivered", "message_sent"),
|
|
1093
|
-
errorRate: this.calculateRate(current, "message_failed", "message_sent"),
|
|
1094
|
-
clickRate: this.calculateRate(current, "message_clicked", "message_delivered")
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
calculateTrends(current, previous) {
|
|
1098
|
-
return {};
|
|
1099
|
-
}
|
|
1100
|
-
sumMetrics(metrics, type) {
|
|
1101
|
-
return metrics.filter((m) => m.type.toString() === type).reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
1102
|
-
}
|
|
1103
|
-
calculateRate(metrics, numerator, denominator) {
|
|
1104
|
-
const num = this.sumMetrics(metrics, numerator);
|
|
1105
|
-
const den = this.sumMetrics(metrics, denominator);
|
|
1106
|
-
return den > 0 ? num / den * 100 : 0;
|
|
1107
|
-
}
|
|
1108
|
-
generateCacheKey(query) {
|
|
1109
|
-
return JSON.stringify({
|
|
1110
|
-
metrics: query.metrics.sort(),
|
|
1111
|
-
dateRange: query.dateRange,
|
|
1112
|
-
interval: query.interval,
|
|
1113
|
-
filters: query.filters,
|
|
1114
|
-
groupBy: query.groupBy?.sort()
|
|
1115
|
-
});
|
|
1116
|
-
}
|
|
1117
|
-
startAggregationTasks() {
|
|
1118
|
-
for (const interval of this.config.aggregationIntervals) {
|
|
1119
|
-
this.scheduleAggregation(interval);
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
scheduleAggregation(interval) {
|
|
1123
|
-
const scheduleMs = this.getScheduleInterval(interval);
|
|
1124
|
-
setInterval(async () => {
|
|
1125
|
-
try {
|
|
1126
|
-
await this.runAggregation(interval);
|
|
1127
|
-
} catch (error) {
|
|
1128
|
-
console.error(`Aggregation failed for interval ${interval}:`, error);
|
|
1129
|
-
}
|
|
1130
|
-
}, scheduleMs);
|
|
1131
|
-
}
|
|
1132
|
-
getScheduleInterval(interval) {
|
|
1133
|
-
switch (interval) {
|
|
1134
|
-
case "minute":
|
|
1135
|
-
return 60 * 1e3;
|
|
1136
|
-
case "hour":
|
|
1137
|
-
return 60 * 60 * 1e3;
|
|
1138
|
-
case "day":
|
|
1139
|
-
return 24 * 60 * 60 * 1e3;
|
|
1140
|
-
case "week":
|
|
1141
|
-
return 7 * 24 * 60 * 60 * 1e3;
|
|
1142
|
-
case "month":
|
|
1143
|
-
return 30 * 24 * 60 * 60 * 1e3;
|
|
1144
|
-
default:
|
|
1145
|
-
return 60 * 60 * 1e3;
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
async runAggregation(interval) {
|
|
1149
|
-
console.log(`Running ${interval} aggregation...`);
|
|
1150
|
-
}
|
|
1151
|
-
async notifyAnomalies(anomalies) {
|
|
1152
|
-
for (const anomaly of anomalies) {
|
|
1153
|
-
if (anomaly.severity === "critical" || anomaly.severity === "high") {
|
|
1154
|
-
console.warn("Anomaly detected:", anomaly);
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
};
|
|
1159
|
-
|
|
1160
|
-
// src/aggregators/time-series.aggregator.ts
|
|
1161
|
-
var TimeSeriesAggregator = class {
|
|
1162
|
-
timezone;
|
|
1163
|
-
constructor(timezone = "UTC") {
|
|
1164
|
-
this.timezone = timezone;
|
|
1165
|
-
}
|
|
1166
|
-
/**
|
|
1167
|
-
* 시계열 데이터를 지정된 간격으로 집계
|
|
1168
|
-
*/
|
|
1169
|
-
async aggregate(metrics, interval, options = { fillGaps: false, fillValue: 0 }) {
|
|
1170
|
-
if (metrics.length === 0) {
|
|
1171
|
-
return [];
|
|
1172
|
-
}
|
|
1173
|
-
const groupedByType = this.groupByType(metrics);
|
|
1174
|
-
const aggregated = [];
|
|
1175
|
-
for (const [type, typeMetrics] of groupedByType.entries()) {
|
|
1176
|
-
const groupedByDimensions = this.groupByDimensions(typeMetrics);
|
|
1177
|
-
for (const [dimensionKey, dimensionMetrics] of groupedByDimensions.entries()) {
|
|
1178
|
-
const typeAggregated = await this.aggregateByInterval(
|
|
1179
|
-
dimensionMetrics,
|
|
1180
|
-
type,
|
|
1181
|
-
interval,
|
|
1182
|
-
JSON.parse(dimensionKey),
|
|
1183
|
-
options
|
|
1184
|
-
);
|
|
1185
|
-
aggregated.push(...typeAggregated);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
return aggregated.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
1189
|
-
}
|
|
1190
|
-
/**
|
|
1191
|
-
* 롤링 윈도우 집계
|
|
1192
|
-
*/
|
|
1193
|
-
async aggregateRolling(metrics, windowSize, step = windowSize) {
|
|
1194
|
-
if (metrics.length === 0) {
|
|
1195
|
-
return [];
|
|
1196
|
-
}
|
|
1197
|
-
const sortedMetrics = [...metrics].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
1198
|
-
const startTime = sortedMetrics[0].timestamp;
|
|
1199
|
-
const endTime = sortedMetrics[sortedMetrics.length - 1].timestamp;
|
|
1200
|
-
const aggregated = [];
|
|
1201
|
-
const windowMs = windowSize * 60 * 1e3;
|
|
1202
|
-
const stepMs = step * 60 * 1e3;
|
|
1203
|
-
let currentTime = startTime.getTime();
|
|
1204
|
-
const endTimeMs = endTime.getTime();
|
|
1205
|
-
while (currentTime <= endTimeMs) {
|
|
1206
|
-
const windowStart = new Date(currentTime);
|
|
1207
|
-
const windowEnd = new Date(currentTime + windowMs);
|
|
1208
|
-
const windowMetrics = sortedMetrics.filter(
|
|
1209
|
-
(m) => m.timestamp >= windowStart && m.timestamp < windowEnd
|
|
1210
|
-
);
|
|
1211
|
-
if (windowMetrics.length > 0) {
|
|
1212
|
-
const windowAggregated = await this.aggregateWindow(windowMetrics, windowStart);
|
|
1213
|
-
aggregated.push(...windowAggregated);
|
|
1214
|
-
}
|
|
1215
|
-
currentTime += stepMs;
|
|
1216
|
-
}
|
|
1217
|
-
return aggregated;
|
|
1218
|
-
}
|
|
1219
|
-
/**
|
|
1220
|
-
* 계절성 분해 (간단한 이동평균 기반)
|
|
1221
|
-
*/
|
|
1222
|
-
async decomposeSeasonality(metrics, seasonLength = 24) {
|
|
1223
|
-
if (metrics.length < seasonLength * 2) {
|
|
1224
|
-
return { trend: [], seasonal: [], residual: [] };
|
|
1225
|
-
}
|
|
1226
|
-
const sortedMetrics = [...metrics].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
1227
|
-
const trend = this.calculateMovingAverage(sortedMetrics, seasonLength);
|
|
1228
|
-
const seasonal = this.calculateSeasonalComponent(sortedMetrics, trend, seasonLength);
|
|
1229
|
-
const residual = this.calculateResidual(sortedMetrics, trend, seasonal);
|
|
1230
|
-
return { trend, seasonal, residual };
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* 다운샘플링 (고해상도 → 저해상도)
|
|
1234
|
-
*/
|
|
1235
|
-
async downsample(metrics, targetInterval) {
|
|
1236
|
-
const groupedByTime = this.groupByTimeInterval(metrics, targetInterval);
|
|
1237
|
-
const downsampled = [];
|
|
1238
|
-
for (const [timeKey, timeMetrics] of groupedByTime.entries()) {
|
|
1239
|
-
const timestamp = new Date(timeKey);
|
|
1240
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1241
|
-
for (const metric of timeMetrics) {
|
|
1242
|
-
const typeKey = metric.type.toString();
|
|
1243
|
-
const dimensionKey = JSON.stringify(metric.dimensions);
|
|
1244
|
-
if (!grouped.has(typeKey)) {
|
|
1245
|
-
grouped.set(typeKey, /* @__PURE__ */ new Map());
|
|
1246
|
-
}
|
|
1247
|
-
if (!grouped.get(typeKey).has(dimensionKey)) {
|
|
1248
|
-
grouped.get(typeKey).set(dimensionKey, []);
|
|
1249
|
-
}
|
|
1250
|
-
grouped.get(typeKey).get(dimensionKey).push(metric);
|
|
1251
|
-
}
|
|
1252
|
-
for (const [typeKey, typeGroups] of grouped.entries()) {
|
|
1253
|
-
for (const [dimensionKey, dimensionMetrics] of typeGroups.entries()) {
|
|
1254
|
-
const aggregations = this.calculateAggregations(dimensionMetrics.map((m) => m.aggregations.sum));
|
|
1255
|
-
downsampled.push({
|
|
1256
|
-
type: dimensionMetrics[0].type,
|
|
1257
|
-
interval: targetInterval,
|
|
1258
|
-
timestamp,
|
|
1259
|
-
dimensions: JSON.parse(dimensionKey),
|
|
1260
|
-
aggregations
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
return downsampled.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
1266
|
-
}
|
|
1267
|
-
groupByType(metrics) {
|
|
1268
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1269
|
-
for (const metric of metrics) {
|
|
1270
|
-
if (!grouped.has(metric.type)) {
|
|
1271
|
-
grouped.set(metric.type, []);
|
|
1272
|
-
}
|
|
1273
|
-
grouped.get(metric.type).push(metric);
|
|
1274
|
-
}
|
|
1275
|
-
return grouped;
|
|
1276
|
-
}
|
|
1277
|
-
groupByDimensions(metrics) {
|
|
1278
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1279
|
-
for (const metric of metrics) {
|
|
1280
|
-
const key = JSON.stringify(metric.dimensions);
|
|
1281
|
-
if (!grouped.has(key)) {
|
|
1282
|
-
grouped.set(key, []);
|
|
1283
|
-
}
|
|
1284
|
-
grouped.get(key).push(metric);
|
|
1285
|
-
}
|
|
1286
|
-
return grouped;
|
|
1287
|
-
}
|
|
1288
|
-
async aggregateByInterval(metrics, type, interval, dimensions, options) {
|
|
1289
|
-
const grouped = this.groupByTimeInterval(metrics, interval);
|
|
1290
|
-
const aggregated = [];
|
|
1291
|
-
if (options.fillGaps && grouped.size > 0) {
|
|
1292
|
-
const timeKeys = Array.from(grouped.keys()).sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
|
|
1293
|
-
const filledGroups = this.fillTimeGaps(timeKeys, interval, options.fillValue);
|
|
1294
|
-
for (const [timeKey, defaultValue] of filledGroups.entries()) {
|
|
1295
|
-
if (!grouped.has(timeKey)) {
|
|
1296
|
-
grouped.set(timeKey, [{
|
|
1297
|
-
id: `filled_${Date.now()}`,
|
|
1298
|
-
type,
|
|
1299
|
-
timestamp: new Date(timeKey),
|
|
1300
|
-
value: defaultValue,
|
|
1301
|
-
dimensions
|
|
1302
|
-
}]);
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
for (const [timeKey, timeMetrics] of grouped.entries()) {
|
|
1307
|
-
const timestamp = new Date(timeKey);
|
|
1308
|
-
const values = timeMetrics.map((m) => m.value);
|
|
1309
|
-
const aggregations = this.calculateAggregations(values);
|
|
1310
|
-
aggregated.push({
|
|
1311
|
-
type,
|
|
1312
|
-
interval,
|
|
1313
|
-
timestamp,
|
|
1314
|
-
dimensions,
|
|
1315
|
-
aggregations
|
|
1316
|
-
});
|
|
1317
|
-
}
|
|
1318
|
-
return aggregated;
|
|
1319
|
-
}
|
|
1320
|
-
groupByTimeInterval(metrics, interval) {
|
|
1321
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1322
|
-
for (const metric of metrics) {
|
|
1323
|
-
const timeKey = this.getTimeIntervalKey(metric.timestamp, interval);
|
|
1324
|
-
if (!grouped.has(timeKey)) {
|
|
1325
|
-
grouped.set(timeKey, []);
|
|
1326
|
-
}
|
|
1327
|
-
grouped.get(timeKey).push(metric);
|
|
1328
|
-
}
|
|
1329
|
-
return grouped;
|
|
1330
|
-
}
|
|
1331
|
-
getTimeIntervalKey(timestamp, interval) {
|
|
1332
|
-
const date = new Date(timestamp);
|
|
1333
|
-
switch (interval) {
|
|
1334
|
-
case "minute":
|
|
1335
|
-
date.setSeconds(0, 0);
|
|
1336
|
-
break;
|
|
1337
|
-
case "hour":
|
|
1338
|
-
date.setMinutes(0, 0, 0);
|
|
1339
|
-
break;
|
|
1340
|
-
case "day":
|
|
1341
|
-
date.setHours(0, 0, 0, 0);
|
|
1342
|
-
break;
|
|
1343
|
-
case "week":
|
|
1344
|
-
const dayOfWeek = date.getDay();
|
|
1345
|
-
const diff = date.getDate() - dayOfWeek;
|
|
1346
|
-
date.setDate(diff);
|
|
1347
|
-
date.setHours(0, 0, 0, 0);
|
|
1348
|
-
break;
|
|
1349
|
-
case "month":
|
|
1350
|
-
date.setDate(1);
|
|
1351
|
-
date.setHours(0, 0, 0, 0);
|
|
1352
|
-
break;
|
|
1353
|
-
}
|
|
1354
|
-
return date.toISOString();
|
|
1355
|
-
}
|
|
1356
|
-
fillTimeGaps(timeKeys, interval, fillValue) {
|
|
1357
|
-
const filled = /* @__PURE__ */ new Map();
|
|
1358
|
-
if (timeKeys.length < 2) {
|
|
1359
|
-
return filled;
|
|
1360
|
-
}
|
|
1361
|
-
const start = new Date(timeKeys[0]);
|
|
1362
|
-
const end = new Date(timeKeys[timeKeys.length - 1]);
|
|
1363
|
-
let current = new Date(start);
|
|
1364
|
-
while (current <= end) {
|
|
1365
|
-
const key = current.toISOString();
|
|
1366
|
-
filled.set(key, fillValue);
|
|
1367
|
-
switch (interval) {
|
|
1368
|
-
case "minute":
|
|
1369
|
-
current.setMinutes(current.getMinutes() + 1);
|
|
1370
|
-
break;
|
|
1371
|
-
case "hour":
|
|
1372
|
-
current.setHours(current.getHours() + 1);
|
|
1373
|
-
break;
|
|
1374
|
-
case "day":
|
|
1375
|
-
current.setDate(current.getDate() + 1);
|
|
1376
|
-
break;
|
|
1377
|
-
case "week":
|
|
1378
|
-
current.setDate(current.getDate() + 7);
|
|
1379
|
-
break;
|
|
1380
|
-
case "month":
|
|
1381
|
-
current.setMonth(current.getMonth() + 1);
|
|
1382
|
-
break;
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
return filled;
|
|
1386
|
-
}
|
|
1387
|
-
calculateAggregations(values) {
|
|
1388
|
-
if (values.length === 0) {
|
|
1389
|
-
return { count: 0, sum: 0, avg: 0, min: 0, max: 0 };
|
|
1390
|
-
}
|
|
1391
|
-
const sum = values.reduce((acc, val) => acc + val, 0);
|
|
1392
|
-
const count = values.length;
|
|
1393
|
-
const avg = sum / count;
|
|
1394
|
-
const min = Math.min(...values);
|
|
1395
|
-
const max = Math.max(...values);
|
|
1396
|
-
return { count, sum, avg, min, max };
|
|
1397
|
-
}
|
|
1398
|
-
async aggregateWindow(metrics, windowStart) {
|
|
1399
|
-
const grouped = this.groupByType(metrics);
|
|
1400
|
-
const aggregated = [];
|
|
1401
|
-
for (const [type, typeMetrics] of grouped.entries()) {
|
|
1402
|
-
const dimensionGroups = this.groupByDimensions(typeMetrics);
|
|
1403
|
-
for (const [dimensionKey, dimensionMetrics] of dimensionGroups.entries()) {
|
|
1404
|
-
const values = dimensionMetrics.map((m) => m.value);
|
|
1405
|
-
const aggregations = this.calculateAggregations(values);
|
|
1406
|
-
aggregated.push({
|
|
1407
|
-
type,
|
|
1408
|
-
interval: "minute",
|
|
1409
|
-
// 롤링 윈도우는 분 단위로 처리
|
|
1410
|
-
timestamp: windowStart,
|
|
1411
|
-
dimensions: JSON.parse(dimensionKey),
|
|
1412
|
-
aggregations
|
|
1413
|
-
});
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
return aggregated;
|
|
1417
|
-
}
|
|
1418
|
-
calculateMovingAverage(metrics, windowSize) {
|
|
1419
|
-
const trend = [];
|
|
1420
|
-
for (let i = Math.floor(windowSize / 2); i < metrics.length - Math.floor(windowSize / 2); i++) {
|
|
1421
|
-
const window = metrics.slice(i - Math.floor(windowSize / 2), i + Math.floor(windowSize / 2) + 1);
|
|
1422
|
-
const avgValue = window.reduce((sum, m) => sum + m.aggregations.avg, 0) / window.length;
|
|
1423
|
-
trend.push({
|
|
1424
|
-
...metrics[i],
|
|
1425
|
-
aggregations: {
|
|
1426
|
-
...metrics[i].aggregations,
|
|
1427
|
-
avg: avgValue,
|
|
1428
|
-
sum: avgValue * metrics[i].aggregations.count
|
|
1429
|
-
}
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
return trend;
|
|
1433
|
-
}
|
|
1434
|
-
calculateSeasonalComponent(metrics, trend, seasonLength) {
|
|
1435
|
-
const seasonal = [];
|
|
1436
|
-
const seasonalPattern = new Array(seasonLength).fill(0);
|
|
1437
|
-
const seasonalCounts = new Array(seasonLength).fill(0);
|
|
1438
|
-
for (let i = 0; i < metrics.length; i++) {
|
|
1439
|
-
const seasonIndex = i % seasonLength;
|
|
1440
|
-
const trendValue = trend.find((t) => t.timestamp.getTime() === metrics[i].timestamp.getTime());
|
|
1441
|
-
if (trendValue) {
|
|
1442
|
-
seasonalPattern[seasonIndex] += metrics[i].aggregations.avg - trendValue.aggregations.avg;
|
|
1443
|
-
seasonalCounts[seasonIndex]++;
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
for (let i = 0; i < seasonLength; i++) {
|
|
1447
|
-
if (seasonalCounts[i] > 0) {
|
|
1448
|
-
seasonalPattern[i] /= seasonalCounts[i];
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
for (let i = 0; i < metrics.length; i++) {
|
|
1452
|
-
const seasonIndex = i % seasonLength;
|
|
1453
|
-
seasonal.push({
|
|
1454
|
-
...metrics[i],
|
|
1455
|
-
aggregations: {
|
|
1456
|
-
...metrics[i].aggregations,
|
|
1457
|
-
avg: seasonalPattern[seasonIndex],
|
|
1458
|
-
sum: seasonalPattern[seasonIndex] * metrics[i].aggregations.count
|
|
1459
|
-
}
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
return seasonal;
|
|
1463
|
-
}
|
|
1464
|
-
calculateResidual(original, trend, seasonal) {
|
|
1465
|
-
const residual = [];
|
|
1466
|
-
for (let i = 0; i < original.length; i++) {
|
|
1467
|
-
const trendValue = trend.find((t) => t.timestamp.getTime() === original[i].timestamp.getTime());
|
|
1468
|
-
const seasonalValue = seasonal[i];
|
|
1469
|
-
let residualValue = original[i].aggregations.avg;
|
|
1470
|
-
if (trendValue) {
|
|
1471
|
-
residualValue -= trendValue.aggregations.avg;
|
|
1472
|
-
}
|
|
1473
|
-
if (seasonalValue) {
|
|
1474
|
-
residualValue -= seasonalValue.aggregations.avg;
|
|
1475
|
-
}
|
|
1476
|
-
residual.push({
|
|
1477
|
-
...original[i],
|
|
1478
|
-
aggregations: {
|
|
1479
|
-
...original[i].aggregations,
|
|
1480
|
-
avg: residualValue,
|
|
1481
|
-
sum: residualValue * original[i].aggregations.count
|
|
1482
|
-
}
|
|
1483
|
-
});
|
|
1484
|
-
}
|
|
1485
|
-
return residual;
|
|
1486
|
-
}
|
|
1487
|
-
};
|
|
1488
|
-
|
|
1489
|
-
// src/aggregators/metric.aggregator.ts
|
|
1490
|
-
var MetricAggregator = class {
|
|
1491
|
-
config;
|
|
1492
|
-
buffer = /* @__PURE__ */ new Map();
|
|
1493
|
-
aggregationCache = /* @__PURE__ */ new Map();
|
|
1494
|
-
constructor(config) {
|
|
1495
|
-
this.config = config;
|
|
1496
|
-
this.startPeriodicFlush();
|
|
1497
|
-
}
|
|
1498
|
-
/**
|
|
1499
|
-
* 메트릭 추가 및 실시간 집계
|
|
1500
|
-
*/
|
|
1501
|
-
async addMetric(metric) {
|
|
1502
|
-
const bufferKey = this.getBufferKey(metric);
|
|
1503
|
-
if (!this.buffer.has(bufferKey)) {
|
|
1504
|
-
this.buffer.set(bufferKey, []);
|
|
1505
|
-
}
|
|
1506
|
-
this.buffer.get(bufferKey).push(metric);
|
|
1507
|
-
if (this.buffer.get(bufferKey).length >= this.config.batchSize) {
|
|
1508
|
-
await this.flushBuffer(bufferKey);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
/**
|
|
1512
|
-
* 배치 메트릭 처리
|
|
1513
|
-
*/
|
|
1514
|
-
async addMetrics(metrics) {
|
|
1515
|
-
for (const metric of metrics) {
|
|
1516
|
-
await this.addMetric(metric);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
/**
|
|
1520
|
-
* 규칙 기반 집계 실행
|
|
1521
|
-
*/
|
|
1522
|
-
async aggregateByRules(metrics) {
|
|
1523
|
-
const results = [];
|
|
1524
|
-
for (const rule of this.config.rules) {
|
|
1525
|
-
const applicableMetrics = this.filterMetricsByRule(metrics, rule);
|
|
1526
|
-
if (applicableMetrics.length === 0) continue;
|
|
1527
|
-
const aggregated = await this.applyAggregationRule(applicableMetrics, rule);
|
|
1528
|
-
results.push(...aggregated);
|
|
1529
|
-
}
|
|
1530
|
-
return results;
|
|
1531
|
-
}
|
|
1532
|
-
/**
|
|
1533
|
-
* 커스텀 집계 (동적 규칙)
|
|
1534
|
-
*/
|
|
1535
|
-
async aggregateCustom(metrics, groupBy, aggregationType, filters) {
|
|
1536
|
-
let filteredMetrics = metrics;
|
|
1537
|
-
if (filters) {
|
|
1538
|
-
filteredMetrics = this.applyFilters(metrics, filters);
|
|
1539
|
-
}
|
|
1540
|
-
const grouped = this.groupMetrics(filteredMetrics, groupBy);
|
|
1541
|
-
const results = [];
|
|
1542
|
-
for (const [groupKey, groupMetrics] of grouped.entries()) {
|
|
1543
|
-
const dimensions = JSON.parse(groupKey);
|
|
1544
|
-
const aggregated = await this.performAggregation(groupMetrics, aggregationType, dimensions);
|
|
1545
|
-
results.push(...aggregated);
|
|
1546
|
-
}
|
|
1547
|
-
return results;
|
|
1548
|
-
}
|
|
1549
|
-
/**
|
|
1550
|
-
* 비율 계산 (예: 전환율, 오류율)
|
|
1551
|
-
*/
|
|
1552
|
-
async calculateRates(numeratorMetrics, denominatorMetrics, groupBy = []) {
|
|
1553
|
-
const numGrouped = this.groupMetrics(numeratorMetrics, groupBy);
|
|
1554
|
-
const denGrouped = this.groupMetrics(denominatorMetrics, groupBy);
|
|
1555
|
-
const rates = [];
|
|
1556
|
-
for (const [groupKey, numMetrics] of numGrouped.entries()) {
|
|
1557
|
-
const denMetrics = denGrouped.get(groupKey) || [];
|
|
1558
|
-
const dimensions = JSON.parse(groupKey);
|
|
1559
|
-
const numSum = numMetrics.reduce((sum, m) => sum + m.value, 0);
|
|
1560
|
-
const denSum = denMetrics.reduce((sum, m) => sum + m.value, 0);
|
|
1561
|
-
const rate = denSum > 0 ? numSum / denSum * 100 : 0;
|
|
1562
|
-
const latestTimestamp = new Date(Math.max(
|
|
1563
|
-
...numMetrics.map((m) => m.timestamp.getTime()),
|
|
1564
|
-
...denMetrics.map((m) => m.timestamp.getTime())
|
|
1565
|
-
));
|
|
1566
|
-
rates.push({
|
|
1567
|
-
type: numMetrics[0]?.type || "delivery_rate" /* DELIVERY_RATE */,
|
|
1568
|
-
interval: "minute",
|
|
1569
|
-
timestamp: latestTimestamp,
|
|
1570
|
-
dimensions,
|
|
1571
|
-
aggregations: {
|
|
1572
|
-
count: numMetrics.length + denMetrics.length,
|
|
1573
|
-
sum: rate,
|
|
1574
|
-
avg: rate,
|
|
1575
|
-
min: rate,
|
|
1576
|
-
max: rate
|
|
1577
|
-
}
|
|
1578
|
-
});
|
|
1579
|
-
}
|
|
1580
|
-
return rates;
|
|
1581
|
-
}
|
|
1582
|
-
/**
|
|
1583
|
-
* 백분위수 계산
|
|
1584
|
-
*/
|
|
1585
|
-
async calculatePercentiles(metrics, percentiles, groupBy = []) {
|
|
1586
|
-
const grouped = this.groupMetrics(metrics, groupBy);
|
|
1587
|
-
const results = [];
|
|
1588
|
-
for (const [groupKey, groupMetrics] of grouped.entries()) {
|
|
1589
|
-
const dimensions = JSON.parse(groupKey);
|
|
1590
|
-
const values = groupMetrics.map((m) => m.value).sort((a, b) => a - b);
|
|
1591
|
-
for (const percentile of percentiles) {
|
|
1592
|
-
const value = this.calculatePercentile(values, percentile);
|
|
1593
|
-
results.push({
|
|
1594
|
-
type: groupMetrics[0].type,
|
|
1595
|
-
interval: "minute",
|
|
1596
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
1597
|
-
dimensions: { ...dimensions, percentile: percentile.toString() },
|
|
1598
|
-
aggregations: {
|
|
1599
|
-
count: values.length,
|
|
1600
|
-
sum: value,
|
|
1601
|
-
avg: value,
|
|
1602
|
-
min: value,
|
|
1603
|
-
max: value
|
|
1604
|
-
}
|
|
1605
|
-
});
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
return results;
|
|
1609
|
-
}
|
|
1610
|
-
/**
|
|
1611
|
-
* 슬라이딩 윈도우 집계
|
|
1612
|
-
*/
|
|
1613
|
-
async aggregateSlidingWindow(metrics, windowSizeMs, stepMs, aggregationType) {
|
|
1614
|
-
const sortedMetrics = [...metrics].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
1615
|
-
if (sortedMetrics.length === 0) return [];
|
|
1616
|
-
const results = [];
|
|
1617
|
-
const startTime = sortedMetrics[0].timestamp.getTime();
|
|
1618
|
-
const endTime = sortedMetrics[sortedMetrics.length - 1].timestamp.getTime();
|
|
1619
|
-
let currentTime = startTime;
|
|
1620
|
-
while (currentTime <= endTime) {
|
|
1621
|
-
const windowStart = new Date(currentTime);
|
|
1622
|
-
const windowEnd = new Date(currentTime + windowSizeMs);
|
|
1623
|
-
const windowMetrics = sortedMetrics.filter(
|
|
1624
|
-
(m) => m.timestamp >= windowStart && m.timestamp < windowEnd
|
|
1625
|
-
);
|
|
1626
|
-
if (windowMetrics.length > 0) {
|
|
1627
|
-
const aggregated = await this.performAggregation(windowMetrics, aggregationType, {});
|
|
1628
|
-
results.push(...aggregated);
|
|
1629
|
-
}
|
|
1630
|
-
currentTime += stepMs;
|
|
1631
|
-
}
|
|
1632
|
-
return results;
|
|
1633
|
-
}
|
|
1634
|
-
/**
|
|
1635
|
-
* 메트릭 정규화
|
|
1636
|
-
*/
|
|
1637
|
-
async normalizeMetrics(metrics, method) {
|
|
1638
|
-
const byType = /* @__PURE__ */ new Map();
|
|
1639
|
-
for (const metric of metrics) {
|
|
1640
|
-
if (!byType.has(metric.type)) {
|
|
1641
|
-
byType.set(metric.type, []);
|
|
1642
|
-
}
|
|
1643
|
-
byType.get(metric.type).push(metric);
|
|
1644
|
-
}
|
|
1645
|
-
const normalized = [];
|
|
1646
|
-
for (const [type, typeMetrics] of byType.entries()) {
|
|
1647
|
-
const values = typeMetrics.map((m) => m.aggregations.avg);
|
|
1648
|
-
let normalizedValues;
|
|
1649
|
-
switch (method) {
|
|
1650
|
-
case "minmax":
|
|
1651
|
-
normalizedValues = this.minMaxNormalize(values);
|
|
1652
|
-
break;
|
|
1653
|
-
case "zscore":
|
|
1654
|
-
normalizedValues = this.zScoreNormalize(values);
|
|
1655
|
-
break;
|
|
1656
|
-
case "robust":
|
|
1657
|
-
normalizedValues = this.robustNormalize(values);
|
|
1658
|
-
break;
|
|
1659
|
-
default:
|
|
1660
|
-
normalizedValues = values;
|
|
1661
|
-
}
|
|
1662
|
-
for (let i = 0; i < typeMetrics.length; i++) {
|
|
1663
|
-
normalized.push({
|
|
1664
|
-
...typeMetrics[i],
|
|
1665
|
-
aggregations: {
|
|
1666
|
-
...typeMetrics[i].aggregations,
|
|
1667
|
-
avg: normalizedValues[i],
|
|
1668
|
-
sum: normalizedValues[i] * typeMetrics[i].aggregations.count
|
|
1669
|
-
}
|
|
1670
|
-
});
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
return normalized;
|
|
1674
|
-
}
|
|
1675
|
-
getBufferKey(metric) {
|
|
1676
|
-
return `${metric.type}_${JSON.stringify(metric.dimensions)}`;
|
|
1677
|
-
}
|
|
1678
|
-
async flushBuffer(bufferKey) {
|
|
1679
|
-
const metrics = this.buffer.get(bufferKey);
|
|
1680
|
-
if (!metrics || metrics.length === 0) return;
|
|
1681
|
-
const aggregated = await this.aggregateByRules(metrics);
|
|
1682
|
-
if (aggregated.length > 0) {
|
|
1683
|
-
const cacheKey = `${bufferKey}_${Date.now()}`;
|
|
1684
|
-
this.aggregationCache.set(cacheKey, aggregated);
|
|
1685
|
-
}
|
|
1686
|
-
this.buffer.set(bufferKey, []);
|
|
1687
|
-
}
|
|
1688
|
-
filterMetricsByRule(metrics, rule) {
|
|
1689
|
-
let filtered = metrics.filter((m) => m.type === rule.metricType);
|
|
1690
|
-
if (rule.conditions) {
|
|
1691
|
-
filtered = filtered.filter((metric) => {
|
|
1692
|
-
return rule.conditions.every((condition) => {
|
|
1693
|
-
const value = this.getFieldValue(metric, condition.field);
|
|
1694
|
-
return this.evaluateCondition(value, condition.operator, condition.value);
|
|
1695
|
-
});
|
|
1696
|
-
});
|
|
1697
|
-
}
|
|
1698
|
-
return filtered;
|
|
1699
|
-
}
|
|
1700
|
-
async applyAggregationRule(metrics, rule) {
|
|
1701
|
-
const grouped = this.groupMetrics(metrics, rule.dimensions);
|
|
1702
|
-
const results = [];
|
|
1703
|
-
for (const [groupKey, groupMetrics] of grouped.entries()) {
|
|
1704
|
-
const dimensions = JSON.parse(groupKey);
|
|
1705
|
-
if (rule.aggregationType === "percentile") {
|
|
1706
|
-
if (rule.percentile) {
|
|
1707
|
-
const values = groupMetrics.map((m) => m.value).sort((a, b) => a - b);
|
|
1708
|
-
const percentileValue = this.calculatePercentile(values, rule.percentile);
|
|
1709
|
-
results.push({
|
|
1710
|
-
type: rule.metricType,
|
|
1711
|
-
interval: "minute",
|
|
1712
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
1713
|
-
dimensions,
|
|
1714
|
-
aggregations: {
|
|
1715
|
-
count: values.length,
|
|
1716
|
-
sum: percentileValue,
|
|
1717
|
-
avg: percentileValue,
|
|
1718
|
-
min: percentileValue,
|
|
1719
|
-
max: percentileValue
|
|
1720
|
-
}
|
|
1721
|
-
});
|
|
1722
|
-
}
|
|
1723
|
-
} else {
|
|
1724
|
-
const aggregated = await this.performAggregation(groupMetrics, rule.aggregationType, dimensions);
|
|
1725
|
-
results.push(...aggregated);
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
return results;
|
|
1729
|
-
}
|
|
1730
|
-
groupMetrics(metrics, groupBy) {
|
|
1731
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1732
|
-
for (const metric of metrics) {
|
|
1733
|
-
const groupKey = this.createGroupKey(metric, groupBy);
|
|
1734
|
-
if (!grouped.has(groupKey)) {
|
|
1735
|
-
grouped.set(groupKey, []);
|
|
1736
|
-
}
|
|
1737
|
-
grouped.get(groupKey).push(metric);
|
|
1738
|
-
}
|
|
1739
|
-
return grouped;
|
|
1740
|
-
}
|
|
1741
|
-
createGroupKey(metric, groupBy) {
|
|
1742
|
-
const groupDimensions = {};
|
|
1743
|
-
for (const field of groupBy) {
|
|
1744
|
-
groupDimensions[field] = this.getFieldValue(metric, field);
|
|
1745
|
-
}
|
|
1746
|
-
return JSON.stringify(groupDimensions);
|
|
1747
|
-
}
|
|
1748
|
-
getFieldValue(metric, field) {
|
|
1749
|
-
if (field === "type") return metric.type.toString();
|
|
1750
|
-
if (field === "timestamp") return metric.timestamp.toISOString();
|
|
1751
|
-
return metric.dimensions[field] || "";
|
|
1752
|
-
}
|
|
1753
|
-
evaluateCondition(value, operator, expected) {
|
|
1754
|
-
switch (operator) {
|
|
1755
|
-
case "equals":
|
|
1756
|
-
return value === expected;
|
|
1757
|
-
case "not_equals":
|
|
1758
|
-
return value !== expected;
|
|
1759
|
-
case "gt":
|
|
1760
|
-
return Number(value) > Number(expected);
|
|
1761
|
-
case "lt":
|
|
1762
|
-
return Number(value) < Number(expected);
|
|
1763
|
-
case "contains":
|
|
1764
|
-
return String(value).includes(String(expected));
|
|
1765
|
-
default:
|
|
1766
|
-
return false;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
async performAggregation(metrics, aggregationType, dimensions) {
|
|
1770
|
-
if (metrics.length === 0) return [];
|
|
1771
|
-
const values = metrics.map((m) => m.value);
|
|
1772
|
-
let aggregatedValue;
|
|
1773
|
-
switch (aggregationType) {
|
|
1774
|
-
case "sum":
|
|
1775
|
-
aggregatedValue = values.reduce((sum, val) => sum + val, 0);
|
|
1776
|
-
break;
|
|
1777
|
-
case "avg":
|
|
1778
|
-
aggregatedValue = values.reduce((sum, val) => sum + val, 0) / values.length;
|
|
1779
|
-
break;
|
|
1780
|
-
case "min":
|
|
1781
|
-
aggregatedValue = Math.min(...values);
|
|
1782
|
-
break;
|
|
1783
|
-
case "max":
|
|
1784
|
-
aggregatedValue = Math.max(...values);
|
|
1785
|
-
break;
|
|
1786
|
-
case "count":
|
|
1787
|
-
aggregatedValue = values.length;
|
|
1788
|
-
break;
|
|
1789
|
-
case "rate":
|
|
1790
|
-
aggregatedValue = 0;
|
|
1791
|
-
break;
|
|
1792
|
-
default:
|
|
1793
|
-
aggregatedValue = 0;
|
|
1794
|
-
}
|
|
1795
|
-
return [{
|
|
1796
|
-
type: metrics[0].type,
|
|
1797
|
-
interval: "minute",
|
|
1798
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
1799
|
-
dimensions,
|
|
1800
|
-
aggregations: {
|
|
1801
|
-
count: values.length,
|
|
1802
|
-
sum: aggregationType === "sum" ? aggregatedValue : values.reduce((sum, val) => sum + val, 0),
|
|
1803
|
-
avg: aggregationType === "avg" ? aggregatedValue : aggregatedValue,
|
|
1804
|
-
min: Math.min(...values),
|
|
1805
|
-
max: Math.max(...values)
|
|
1806
|
-
}
|
|
1807
|
-
}];
|
|
1808
|
-
}
|
|
1809
|
-
applyFilters(metrics, filters) {
|
|
1810
|
-
return metrics.filter((metric) => {
|
|
1811
|
-
return Object.entries(filters).every(([key, value]) => {
|
|
1812
|
-
const metricValue = this.getFieldValue(metric, key);
|
|
1813
|
-
return Array.isArray(value) ? value.includes(metricValue) : metricValue === value;
|
|
1814
|
-
});
|
|
1815
|
-
});
|
|
1816
|
-
}
|
|
1817
|
-
calculatePercentile(values, percentile) {
|
|
1818
|
-
if (values.length === 0) return 0;
|
|
1819
|
-
const index = percentile / 100 * (values.length - 1);
|
|
1820
|
-
const lower = Math.floor(index);
|
|
1821
|
-
const upper = Math.ceil(index);
|
|
1822
|
-
if (lower === upper) {
|
|
1823
|
-
return values[lower];
|
|
1824
|
-
}
|
|
1825
|
-
const weight = index - lower;
|
|
1826
|
-
return values[lower] * (1 - weight) + values[upper] * weight;
|
|
1827
|
-
}
|
|
1828
|
-
minMaxNormalize(values) {
|
|
1829
|
-
const min = Math.min(...values);
|
|
1830
|
-
const max = Math.max(...values);
|
|
1831
|
-
const range = max - min;
|
|
1832
|
-
return range === 0 ? values.map(() => 0) : values.map((v) => (v - min) / range);
|
|
1833
|
-
}
|
|
1834
|
-
zScoreNormalize(values) {
|
|
1835
|
-
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
1836
|
-
const stdDev = Math.sqrt(values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length);
|
|
1837
|
-
return stdDev === 0 ? values.map(() => 0) : values.map((v) => (v - mean) / stdDev);
|
|
1838
|
-
}
|
|
1839
|
-
robustNormalize(values) {
|
|
1840
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
1841
|
-
const median = this.calculatePercentile(sorted, 50);
|
|
1842
|
-
const mad = this.calculateMAD(sorted, median);
|
|
1843
|
-
return mad === 0 ? values.map(() => 0) : values.map((v) => (v - median) / mad);
|
|
1844
|
-
}
|
|
1845
|
-
calculateMAD(values, median) {
|
|
1846
|
-
const absDeviations = values.map((v) => Math.abs(v - median));
|
|
1847
|
-
return this.calculatePercentile(absDeviations.sort((a, b) => a - b), 50);
|
|
1848
|
-
}
|
|
1849
|
-
startPeriodicFlush() {
|
|
1850
|
-
setInterval(async () => {
|
|
1851
|
-
for (const bufferKey of this.buffer.keys()) {
|
|
1852
|
-
await this.flushBuffer(bufferKey);
|
|
1853
|
-
}
|
|
1854
|
-
}, this.config.flushInterval);
|
|
1855
|
-
}
|
|
1856
|
-
};
|
|
1857
|
-
|
|
1858
|
-
// src/collectors/event.collector.ts
|
|
1859
|
-
var import_events = require("events");
|
|
1860
|
-
var EventCollector = class extends import_events.EventEmitter {
|
|
1861
|
-
config;
|
|
1862
|
-
buffer = [];
|
|
1863
|
-
processors = /* @__PURE__ */ new Map();
|
|
1864
|
-
recentEvents = /* @__PURE__ */ new Map();
|
|
1865
|
-
// 중복 제거용
|
|
1866
|
-
metrics = [];
|
|
1867
|
-
defaultConfig = {
|
|
1868
|
-
bufferSize: 1e3,
|
|
1869
|
-
flushInterval: 5e3,
|
|
1870
|
-
enableDeduplication: true,
|
|
1871
|
-
deduplicationWindow: 6e4,
|
|
1872
|
-
// 1분
|
|
1873
|
-
enableSampling: false,
|
|
1874
|
-
samplingRate: 1
|
|
1875
|
-
};
|
|
1876
|
-
constructor(config = {}) {
|
|
1877
|
-
super();
|
|
1878
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
1879
|
-
this.initializeDefaultProcessors();
|
|
1880
|
-
this.startPeriodicFlush();
|
|
1881
|
-
this.startCleanup();
|
|
1882
|
-
}
|
|
1883
|
-
/**
|
|
1884
|
-
* 이벤트 수집
|
|
1885
|
-
*/
|
|
1886
|
-
async collectEvent(event) {
|
|
1887
|
-
if (this.config.enableSampling && Math.random() > this.config.samplingRate) {
|
|
1888
|
-
return;
|
|
1889
|
-
}
|
|
1890
|
-
if (this.config.enableDeduplication && this.isDuplicate(event)) {
|
|
1891
|
-
return;
|
|
1892
|
-
}
|
|
1893
|
-
this.validateEvent(event);
|
|
1894
|
-
this.buffer.push(event);
|
|
1895
|
-
if (this.isHighPriorityEvent(event)) {
|
|
1896
|
-
await this.processEvent(event);
|
|
1897
|
-
}
|
|
1898
|
-
if (this.buffer.length >= this.config.bufferSize) {
|
|
1899
|
-
await this.flush();
|
|
1900
|
-
}
|
|
1901
|
-
this.emit("event:collected", event);
|
|
1902
|
-
}
|
|
1903
|
-
/**
|
|
1904
|
-
* 배치 이벤트 수집
|
|
1905
|
-
*/
|
|
1906
|
-
async collectEvents(events) {
|
|
1907
|
-
for (const event of events) {
|
|
1908
|
-
await this.collectEvent(event);
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
/**
|
|
1912
|
-
* 커스텀 이벤트 프로세서 등록
|
|
1913
|
-
*/
|
|
1914
|
-
registerProcessor(name, processor) {
|
|
1915
|
-
this.processors.set(name, processor);
|
|
1916
|
-
this.emit("processor:registered", { name, processor });
|
|
1917
|
-
}
|
|
1918
|
-
/**
|
|
1919
|
-
* 이벤트 프로세서 제거
|
|
1920
|
-
*/
|
|
1921
|
-
unregisterProcessor(name) {
|
|
1922
|
-
const removed = this.processors.delete(name);
|
|
1923
|
-
if (removed) {
|
|
1924
|
-
this.emit("processor:unregistered", { name });
|
|
1925
|
-
}
|
|
1926
|
-
return removed;
|
|
1927
|
-
}
|
|
1928
|
-
/**
|
|
1929
|
-
* 수집된 메트릭 조회
|
|
1930
|
-
*/
|
|
1931
|
-
getCollectedMetrics(since) {
|
|
1932
|
-
if (!since) {
|
|
1933
|
-
return [...this.metrics];
|
|
1934
|
-
}
|
|
1935
|
-
return this.metrics.filter((m) => m.timestamp >= since);
|
|
1936
|
-
}
|
|
1937
|
-
/**
|
|
1938
|
-
* 실시간 메트릭 스트림
|
|
1939
|
-
*/
|
|
1940
|
-
async *streamMetrics() {
|
|
1941
|
-
while (true) {
|
|
1942
|
-
await new Promise((resolve) => setTimeout(resolve, this.config.flushInterval));
|
|
1943
|
-
if (this.buffer.length > 0) {
|
|
1944
|
-
await this.flush();
|
|
1945
|
-
}
|
|
1946
|
-
const recentMetrics = this.getCollectedMetrics(
|
|
1947
|
-
new Date(Date.now() - this.config.flushInterval)
|
|
1948
|
-
);
|
|
1949
|
-
if (recentMetrics.length > 0) {
|
|
1950
|
-
yield recentMetrics;
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
/**
|
|
1955
|
-
* 이벤트 통계
|
|
1956
|
-
*/
|
|
1957
|
-
getEventStats() {
|
|
1958
|
-
const eventsByType = {};
|
|
1959
|
-
const eventsBySource = {};
|
|
1960
|
-
for (const event of this.buffer) {
|
|
1961
|
-
eventsByType[event.type] = (eventsByType[event.type] || 0) + 1;
|
|
1962
|
-
eventsBySource[event.source] = (eventsBySource[event.source] || 0) + 1;
|
|
1963
|
-
}
|
|
1964
|
-
return {
|
|
1965
|
-
totalEvents: this.buffer.length,
|
|
1966
|
-
eventsByType,
|
|
1967
|
-
eventsBySource,
|
|
1968
|
-
bufferSize: this.buffer.length,
|
|
1969
|
-
metricsGenerated: this.metrics.length
|
|
1970
|
-
};
|
|
1971
|
-
}
|
|
1972
|
-
/**
|
|
1973
|
-
* 버퍼 강제 플러시
|
|
1974
|
-
*/
|
|
1975
|
-
async flush() {
|
|
1976
|
-
if (this.buffer.length === 0) {
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
const events = [...this.buffer];
|
|
1980
|
-
this.buffer = [];
|
|
1981
|
-
try {
|
|
1982
|
-
const allMetrics = [];
|
|
1983
|
-
for (const event of events) {
|
|
1984
|
-
const metrics = await this.processEvent(event);
|
|
1985
|
-
allMetrics.push(...metrics);
|
|
1986
|
-
}
|
|
1987
|
-
this.metrics.push(...allMetrics);
|
|
1988
|
-
if (this.metrics.length > 1e5) {
|
|
1989
|
-
this.metrics = this.metrics.slice(-5e4);
|
|
1990
|
-
}
|
|
1991
|
-
this.emit("events:flushed", {
|
|
1992
|
-
eventCount: events.length,
|
|
1993
|
-
metricCount: allMetrics.length
|
|
1994
|
-
});
|
|
1995
|
-
} catch (error) {
|
|
1996
|
-
this.buffer.unshift(...events);
|
|
1997
|
-
this.emit("flush:error", error);
|
|
1998
|
-
throw error;
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
async processEvent(event) {
|
|
2002
|
-
const metrics = [];
|
|
2003
|
-
for (const [name, processor] of this.processors.entries()) {
|
|
2004
|
-
try {
|
|
2005
|
-
if (processor.canProcess(event)) {
|
|
2006
|
-
const processorMetrics = await processor.process(event);
|
|
2007
|
-
metrics.push(...processorMetrics);
|
|
2008
|
-
}
|
|
2009
|
-
} catch (error) {
|
|
2010
|
-
this.emit("processor:error", { processorName: name, event, error });
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
return metrics;
|
|
2014
|
-
}
|
|
2015
|
-
validateEvent(event) {
|
|
2016
|
-
if (!event.id) {
|
|
2017
|
-
throw new Error("Event ID is required");
|
|
2018
|
-
}
|
|
2019
|
-
if (!event.type) {
|
|
2020
|
-
throw new Error("Event type is required");
|
|
2021
|
-
}
|
|
2022
|
-
if (!event.timestamp || !(event.timestamp instanceof Date)) {
|
|
2023
|
-
throw new Error("Valid event timestamp is required");
|
|
2024
|
-
}
|
|
2025
|
-
if (!event.source) {
|
|
2026
|
-
throw new Error("Event source is required");
|
|
2027
|
-
}
|
|
2028
|
-
if (!event.payload || typeof event.payload !== "object") {
|
|
2029
|
-
throw new Error("Event payload must be an object");
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
isDuplicate(event) {
|
|
2033
|
-
const now = Date.now();
|
|
2034
|
-
const eventKey = `${event.id}_${event.type}_${event.source}`;
|
|
2035
|
-
const lastSeen = this.recentEvents.get(eventKey);
|
|
2036
|
-
if (lastSeen && now - lastSeen < this.config.deduplicationWindow) {
|
|
2037
|
-
return true;
|
|
2038
|
-
}
|
|
2039
|
-
this.recentEvents.set(eventKey, now);
|
|
2040
|
-
return false;
|
|
2041
|
-
}
|
|
2042
|
-
isHighPriorityEvent(event) {
|
|
2043
|
-
const highPriorityTypes = [
|
|
2044
|
-
"message.failed",
|
|
2045
|
-
"system.error",
|
|
2046
|
-
"security.alert",
|
|
2047
|
-
"performance.degradation"
|
|
2048
|
-
];
|
|
2049
|
-
return highPriorityTypes.includes(event.type);
|
|
2050
|
-
}
|
|
2051
|
-
initializeDefaultProcessors() {
|
|
2052
|
-
this.registerProcessor("message-sent", {
|
|
2053
|
-
canProcess: (event) => event.type === "message.sent",
|
|
2054
|
-
process: async (event) => {
|
|
2055
|
-
return [{
|
|
2056
|
-
id: `metric_${event.id}`,
|
|
2057
|
-
type: "message_sent" /* MESSAGE_SENT */,
|
|
2058
|
-
timestamp: event.timestamp,
|
|
2059
|
-
value: 1,
|
|
2060
|
-
dimensions: {
|
|
2061
|
-
provider: event.payload.provider || "unknown",
|
|
2062
|
-
channel: event.payload.channel || "unknown",
|
|
2063
|
-
template: event.payload.templateId || "none"
|
|
2064
|
-
},
|
|
2065
|
-
metadata: {
|
|
2066
|
-
messageId: event.payload.messageId,
|
|
2067
|
-
recipientCount: event.payload.recipientCount || 1
|
|
2068
|
-
}
|
|
2069
|
-
}];
|
|
2070
|
-
}
|
|
2071
|
-
});
|
|
2072
|
-
this.registerProcessor("message-delivered", {
|
|
2073
|
-
canProcess: (event) => event.type === "message.delivered",
|
|
2074
|
-
process: async (event) => {
|
|
2075
|
-
return [{
|
|
2076
|
-
id: `metric_${event.id}`,
|
|
2077
|
-
type: "message_delivered" /* MESSAGE_DELIVERED */,
|
|
2078
|
-
timestamp: event.timestamp,
|
|
2079
|
-
value: 1,
|
|
2080
|
-
dimensions: {
|
|
2081
|
-
provider: event.payload.provider || "unknown",
|
|
2082
|
-
channel: event.payload.channel || "unknown"
|
|
2083
|
-
},
|
|
2084
|
-
metadata: {
|
|
2085
|
-
messageId: event.payload.messageId,
|
|
2086
|
-
deliveryTime: event.payload.deliveryTime
|
|
2087
|
-
}
|
|
2088
|
-
}];
|
|
2089
|
-
}
|
|
2090
|
-
});
|
|
2091
|
-
this.registerProcessor("message-failed", {
|
|
2092
|
-
canProcess: (event) => event.type === "message.failed",
|
|
2093
|
-
process: async (event) => {
|
|
2094
|
-
return [{
|
|
2095
|
-
id: `metric_${event.id}`,
|
|
2096
|
-
type: "message_failed" /* MESSAGE_FAILED */,
|
|
2097
|
-
timestamp: event.timestamp,
|
|
2098
|
-
value: 1,
|
|
2099
|
-
dimensions: {
|
|
2100
|
-
provider: event.payload.provider || "unknown",
|
|
2101
|
-
channel: event.payload.channel || "unknown",
|
|
2102
|
-
errorCode: event.payload.errorCode || "unknown"
|
|
2103
|
-
},
|
|
2104
|
-
metadata: {
|
|
2105
|
-
messageId: event.payload.messageId,
|
|
2106
|
-
errorMessage: event.payload.errorMessage,
|
|
2107
|
-
errorType: event.payload.errorType
|
|
2108
|
-
}
|
|
2109
|
-
}];
|
|
2110
|
-
}
|
|
2111
|
-
});
|
|
2112
|
-
this.registerProcessor("message-clicked", {
|
|
2113
|
-
canProcess: (event) => event.type === "message.clicked" || event.type === "link.clicked",
|
|
2114
|
-
process: async (event) => {
|
|
2115
|
-
return [{
|
|
2116
|
-
id: `metric_${event.id}`,
|
|
2117
|
-
type: "message_clicked" /* MESSAGE_CLICKED */,
|
|
2118
|
-
timestamp: event.timestamp,
|
|
2119
|
-
value: 1,
|
|
2120
|
-
dimensions: {
|
|
2121
|
-
provider: event.payload.provider || "unknown",
|
|
2122
|
-
channel: event.payload.channel || "unknown",
|
|
2123
|
-
linkType: event.payload.linkType || "unknown"
|
|
2124
|
-
},
|
|
2125
|
-
metadata: {
|
|
2126
|
-
messageId: event.payload.messageId,
|
|
2127
|
-
linkUrl: event.payload.linkUrl,
|
|
2128
|
-
userId: event.context?.userId
|
|
2129
|
-
}
|
|
2130
|
-
}];
|
|
2131
|
-
}
|
|
2132
|
-
});
|
|
2133
|
-
this.registerProcessor("template-used", {
|
|
2134
|
-
canProcess: (event) => event.type === "template.used",
|
|
2135
|
-
process: async (event) => {
|
|
2136
|
-
return [{
|
|
2137
|
-
id: `metric_${event.id}`,
|
|
2138
|
-
type: "template_usage" /* TEMPLATE_USAGE */,
|
|
2139
|
-
timestamp: event.timestamp,
|
|
2140
|
-
value: 1,
|
|
2141
|
-
dimensions: {
|
|
2142
|
-
templateId: event.payload.templateId || "unknown",
|
|
2143
|
-
provider: event.payload.provider || "unknown",
|
|
2144
|
-
channel: event.payload.channel || "unknown"
|
|
2145
|
-
},
|
|
2146
|
-
metadata: {
|
|
2147
|
-
templateName: event.payload.templateName,
|
|
2148
|
-
version: event.payload.version
|
|
2149
|
-
}
|
|
2150
|
-
}];
|
|
2151
|
-
}
|
|
2152
|
-
});
|
|
2153
|
-
this.registerProcessor("channel-used", {
|
|
2154
|
-
canProcess: (event) => ["message.sent", "message.delivered"].includes(event.type),
|
|
2155
|
-
process: async (event) => {
|
|
2156
|
-
return [{
|
|
2157
|
-
id: `metric_channel_${event.id}`,
|
|
2158
|
-
type: "channel_usage" /* CHANNEL_USAGE */,
|
|
2159
|
-
timestamp: event.timestamp,
|
|
2160
|
-
value: 1,
|
|
2161
|
-
dimensions: {
|
|
2162
|
-
channel: event.payload.channel || "unknown",
|
|
2163
|
-
provider: event.payload.provider || "unknown"
|
|
2164
|
-
}
|
|
2165
|
-
}];
|
|
2166
|
-
}
|
|
2167
|
-
});
|
|
2168
|
-
}
|
|
2169
|
-
startPeriodicFlush() {
|
|
2170
|
-
setInterval(async () => {
|
|
2171
|
-
try {
|
|
2172
|
-
await this.flush();
|
|
2173
|
-
} catch (error) {
|
|
2174
|
-
this.emit("flush:error", error);
|
|
2175
|
-
}
|
|
2176
|
-
}, this.config.flushInterval);
|
|
2177
|
-
}
|
|
2178
|
-
startCleanup() {
|
|
2179
|
-
setInterval(() => {
|
|
2180
|
-
const cutoff = Date.now() - this.config.deduplicationWindow;
|
|
2181
|
-
for (const [key, timestamp] of this.recentEvents.entries()) {
|
|
2182
|
-
if (timestamp < cutoff) {
|
|
2183
|
-
this.recentEvents.delete(key);
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
}, 10 * 60 * 1e3);
|
|
2187
|
-
setInterval(() => {
|
|
2188
|
-
if (this.metrics.length > 1e5) {
|
|
2189
|
-
this.metrics = this.metrics.slice(-5e4);
|
|
2190
|
-
this.emit("metrics:cleaned", { remainingCount: this.metrics.length });
|
|
2191
|
-
}
|
|
2192
|
-
}, 60 * 60 * 1e3);
|
|
2193
|
-
}
|
|
2194
|
-
};
|
|
2195
|
-
|
|
2196
|
-
// src/collectors/webhook.collector.ts
|
|
2197
|
-
var import_events2 = require("events");
|
|
2198
|
-
var WebhookCollector = class extends import_events2.EventEmitter {
|
|
2199
|
-
config;
|
|
2200
|
-
transformers = /* @__PURE__ */ new Map();
|
|
2201
|
-
requestCounts = /* @__PURE__ */ new Map();
|
|
2202
|
-
processedWebhooks = [];
|
|
2203
|
-
defaultConfig = {
|
|
2204
|
-
enableSignatureValidation: true,
|
|
2205
|
-
signatureHeader: "x-signature",
|
|
2206
|
-
allowedSources: [],
|
|
2207
|
-
maxPayloadSize: 1024 * 1024,
|
|
2208
|
-
// 1MB
|
|
2209
|
-
rateLimitPerMinute: 1e3
|
|
2210
|
-
};
|
|
2211
|
-
constructor(config = {}) {
|
|
2212
|
-
super();
|
|
2213
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
2214
|
-
this.initializeDefaultTransformers();
|
|
2215
|
-
this.startCleanup();
|
|
2216
|
-
}
|
|
2217
|
-
/**
|
|
2218
|
-
* 웹훅 수신 처리
|
|
2219
|
-
*/
|
|
2220
|
-
async receiveWebhook(webhook) {
|
|
2221
|
-
if (!this.checkRateLimit(webhook.source)) {
|
|
2222
|
-
throw new Error(`Rate limit exceeded for source: ${webhook.source}`);
|
|
2223
|
-
}
|
|
2224
|
-
await this.validateWebhook(webhook);
|
|
2225
|
-
const payloadSize = JSON.stringify(webhook.body).length;
|
|
2226
|
-
if (payloadSize > this.config.maxPayloadSize) {
|
|
2227
|
-
throw new Error(`Payload size ${payloadSize} exceeds maximum ${this.config.maxPayloadSize}`);
|
|
2228
|
-
}
|
|
2229
|
-
const events = await this.transformWebhook(webhook);
|
|
2230
|
-
this.processedWebhooks.push(webhook);
|
|
2231
|
-
if (this.processedWebhooks.length > 1e3) {
|
|
2232
|
-
this.processedWebhooks = this.processedWebhooks.slice(-500);
|
|
2233
|
-
}
|
|
2234
|
-
this.emit("webhook:received", { webhook, eventCount: events.length });
|
|
2235
|
-
return events;
|
|
2236
|
-
}
|
|
2237
|
-
/**
|
|
2238
|
-
* 웹훅 변환기 등록
|
|
2239
|
-
*/
|
|
2240
|
-
registerTransformer(name, transformer) {
|
|
2241
|
-
this.transformers.set(name, transformer);
|
|
2242
|
-
this.emit("transformer:registered", { name });
|
|
2243
|
-
}
|
|
2244
|
-
/**
|
|
2245
|
-
* 웹훅 변환기 제거
|
|
2246
|
-
*/
|
|
2247
|
-
unregisterTransformer(name) {
|
|
2248
|
-
const removed = this.transformers.delete(name);
|
|
2249
|
-
if (removed) {
|
|
2250
|
-
this.emit("transformer:unregistered", { name });
|
|
2251
|
-
}
|
|
2252
|
-
return removed;
|
|
2253
|
-
}
|
|
2254
|
-
/**
|
|
2255
|
-
* 처리된 웹훅 조회
|
|
2256
|
-
*/
|
|
2257
|
-
getProcessedWebhooks(since) {
|
|
2258
|
-
if (!since) {
|
|
2259
|
-
return [...this.processedWebhooks];
|
|
2260
|
-
}
|
|
2261
|
-
return this.processedWebhooks.filter((w) => w.timestamp >= since);
|
|
2262
|
-
}
|
|
2263
|
-
/**
|
|
2264
|
-
* 웹훅 통계
|
|
2265
|
-
*/
|
|
2266
|
-
getWebhookStats() {
|
|
2267
|
-
const bySource = {};
|
|
2268
|
-
const recentTime = new Date(Date.now() - 60 * 60 * 1e3);
|
|
2269
|
-
let recentCount = 0;
|
|
2270
|
-
for (const webhook of this.processedWebhooks) {
|
|
2271
|
-
bySource[webhook.source] = (bySource[webhook.source] || 0) + 1;
|
|
2272
|
-
if (webhook.timestamp >= recentTime) {
|
|
2273
|
-
recentCount++;
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return {
|
|
2277
|
-
totalProcessed: this.processedWebhooks.length,
|
|
2278
|
-
bySource,
|
|
2279
|
-
recentCount,
|
|
2280
|
-
transformerCount: this.transformers.size
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
async validateWebhook(webhook) {
|
|
2284
|
-
if (this.config.allowedSources.length > 0 && !this.config.allowedSources.includes(webhook.source)) {
|
|
2285
|
-
throw new Error(`Source ${webhook.source} is not allowed`);
|
|
2286
|
-
}
|
|
2287
|
-
if (this.config.enableSignatureValidation && this.config.secretKey) {
|
|
2288
|
-
await this.validateSignature(webhook);
|
|
2289
|
-
}
|
|
2290
|
-
if (!webhook.id) {
|
|
2291
|
-
throw new Error("Webhook ID is required");
|
|
2292
|
-
}
|
|
2293
|
-
if (!webhook.source) {
|
|
2294
|
-
throw new Error("Webhook source is required");
|
|
2295
|
-
}
|
|
2296
|
-
if (!webhook.timestamp || !(webhook.timestamp instanceof Date)) {
|
|
2297
|
-
throw new Error("Valid webhook timestamp is required");
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
async validateSignature(webhook) {
|
|
2301
|
-
const signature = webhook.headers[this.config.signatureHeader] || webhook.signature;
|
|
2302
|
-
if (!signature) {
|
|
2303
|
-
throw new Error(`Missing signature in header: ${this.config.signatureHeader}`);
|
|
2304
|
-
}
|
|
2305
|
-
const expectedSignature = await this.generateSignature(webhook.body, this.config.secretKey);
|
|
2306
|
-
if (signature !== expectedSignature) {
|
|
2307
|
-
throw new Error("Invalid webhook signature");
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
async generateSignature(payload, secret) {
|
|
2311
|
-
const payloadStr = JSON.stringify(payload);
|
|
2312
|
-
return `sha256=${payloadStr.length}_${secret.length}`;
|
|
2313
|
-
}
|
|
2314
|
-
checkRateLimit(source) {
|
|
2315
|
-
const now = Date.now();
|
|
2316
|
-
const minuteKey = Math.floor(now / (60 * 1e3));
|
|
2317
|
-
const rateLimitKey = `${source}_${minuteKey}`;
|
|
2318
|
-
const current = this.requestCounts.get(rateLimitKey) || { count: 0, resetTime: now + 6e4 };
|
|
2319
|
-
if (current.count >= this.config.rateLimitPerMinute) {
|
|
2320
|
-
return false;
|
|
2321
|
-
}
|
|
2322
|
-
current.count++;
|
|
2323
|
-
this.requestCounts.set(rateLimitKey, current);
|
|
2324
|
-
return true;
|
|
2325
|
-
}
|
|
2326
|
-
async transformWebhook(webhook) {
|
|
2327
|
-
const events = [];
|
|
2328
|
-
for (const [name, transformer] of this.transformers.entries()) {
|
|
2329
|
-
try {
|
|
2330
|
-
if (transformer.canTransform(webhook)) {
|
|
2331
|
-
const transformerEvents = await transformer.transform(webhook);
|
|
2332
|
-
events.push(...transformerEvents);
|
|
2333
|
-
}
|
|
2334
|
-
} catch (error) {
|
|
2335
|
-
this.emit("transformer:error", { transformerName: name, webhook, error });
|
|
2336
|
-
}
|
|
2337
|
-
}
|
|
2338
|
-
if (events.length === 0) {
|
|
2339
|
-
events.push(await this.defaultTransform(webhook));
|
|
2340
|
-
}
|
|
2341
|
-
return events;
|
|
2342
|
-
}
|
|
2343
|
-
async defaultTransform(webhook) {
|
|
2344
|
-
return {
|
|
2345
|
-
id: `webhook_${webhook.id}`,
|
|
2346
|
-
type: `webhook.${webhook.source}`,
|
|
2347
|
-
timestamp: webhook.timestamp,
|
|
2348
|
-
source: webhook.source,
|
|
2349
|
-
payload: webhook.body,
|
|
2350
|
-
context: {
|
|
2351
|
-
requestId: webhook.id,
|
|
2352
|
-
userAgent: webhook.headers["user-agent"],
|
|
2353
|
-
ipAddress: webhook.headers["x-forwarded-for"] || webhook.headers["x-real-ip"]
|
|
2354
|
-
}
|
|
2355
|
-
};
|
|
2356
|
-
}
|
|
2357
|
-
initializeDefaultTransformers() {
|
|
2358
|
-
this.registerTransformer("sms-provider", {
|
|
2359
|
-
canTransform: (webhook) => webhook.source.includes("sms") || webhook.source.includes("alimtalk"),
|
|
2360
|
-
transform: async (webhook) => {
|
|
2361
|
-
const events = [];
|
|
2362
|
-
const body = webhook.body;
|
|
2363
|
-
if (body.status === "sent" || body.status === "delivered") {
|
|
2364
|
-
events.push({
|
|
2365
|
-
id: `sms_delivered_${webhook.id}`,
|
|
2366
|
-
type: "message.delivered",
|
|
2367
|
-
timestamp: webhook.timestamp,
|
|
2368
|
-
source: webhook.source,
|
|
2369
|
-
payload: {
|
|
2370
|
-
messageId: body.messageId || body.id,
|
|
2371
|
-
provider: webhook.source,
|
|
2372
|
-
channel: body.channel || "sms",
|
|
2373
|
-
recipientNumber: body.to || body.recipient,
|
|
2374
|
-
deliveryTime: body.deliveredAt ? new Date(body.deliveredAt) : webhook.timestamp
|
|
2375
|
-
}
|
|
2376
|
-
});
|
|
2377
|
-
}
|
|
2378
|
-
if (body.status === "failed" || body.status === "error") {
|
|
2379
|
-
events.push({
|
|
2380
|
-
id: `sms_failed_${webhook.id}`,
|
|
2381
|
-
type: "message.failed",
|
|
2382
|
-
timestamp: webhook.timestamp,
|
|
2383
|
-
source: webhook.source,
|
|
2384
|
-
payload: {
|
|
2385
|
-
messageId: body.messageId || body.id,
|
|
2386
|
-
provider: webhook.source,
|
|
2387
|
-
channel: body.channel || "sms",
|
|
2388
|
-
errorCode: body.errorCode || "unknown",
|
|
2389
|
-
errorMessage: body.errorMessage || body.error,
|
|
2390
|
-
errorType: body.errorType || "delivery"
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
2393
|
-
}
|
|
2394
|
-
return events;
|
|
2395
|
-
}
|
|
2396
|
-
});
|
|
2397
|
-
this.registerTransformer("alimtalk-provider", {
|
|
2398
|
-
canTransform: (webhook) => webhook.source.includes("alimtalk") || webhook.source.includes("kakao"),
|
|
2399
|
-
transform: async (webhook) => {
|
|
2400
|
-
const events = [];
|
|
2401
|
-
const body = webhook.body;
|
|
2402
|
-
if (body.eventType === "click" || body.type === "button_click") {
|
|
2403
|
-
events.push({
|
|
2404
|
-
id: `alimtalk_click_${webhook.id}`,
|
|
2405
|
-
type: "message.clicked",
|
|
2406
|
-
timestamp: webhook.timestamp,
|
|
2407
|
-
source: webhook.source,
|
|
2408
|
-
payload: {
|
|
2409
|
-
messageId: body.messageId,
|
|
2410
|
-
provider: webhook.source,
|
|
2411
|
-
channel: "alimtalk",
|
|
2412
|
-
linkType: body.buttonType || "button",
|
|
2413
|
-
linkUrl: body.buttonUrl || body.url,
|
|
2414
|
-
buttonText: body.buttonText
|
|
2415
|
-
}
|
|
2416
|
-
});
|
|
2417
|
-
}
|
|
2418
|
-
if (body.messageStatus || body.status) {
|
|
2419
|
-
const status = body.messageStatus || body.status;
|
|
2420
|
-
if (["DELIVERED", "READ"].includes(status)) {
|
|
2421
|
-
events.push({
|
|
2422
|
-
id: `alimtalk_delivered_${webhook.id}`,
|
|
2423
|
-
type: "message.delivered",
|
|
2424
|
-
timestamp: webhook.timestamp,
|
|
2425
|
-
source: webhook.source,
|
|
2426
|
-
payload: {
|
|
2427
|
-
messageId: body.messageId,
|
|
2428
|
-
provider: webhook.source,
|
|
2429
|
-
channel: "alimtalk",
|
|
2430
|
-
deliveryTime: body.deliveredAt ? new Date(body.deliveredAt) : webhook.timestamp
|
|
2431
|
-
}
|
|
2432
|
-
});
|
|
2433
|
-
} else if (["FAILED", "REJECTED"].includes(status)) {
|
|
2434
|
-
events.push({
|
|
2435
|
-
id: `alimtalk_failed_${webhook.id}`,
|
|
2436
|
-
type: "message.failed",
|
|
2437
|
-
timestamp: webhook.timestamp,
|
|
2438
|
-
source: webhook.source,
|
|
2439
|
-
payload: {
|
|
2440
|
-
messageId: body.messageId,
|
|
2441
|
-
provider: webhook.source,
|
|
2442
|
-
channel: "alimtalk",
|
|
2443
|
-
errorCode: body.failureCode || "unknown",
|
|
2444
|
-
errorMessage: body.failureReason || "Unknown error"
|
|
2445
|
-
}
|
|
2446
|
-
});
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
return events;
|
|
2450
|
-
}
|
|
2451
|
-
});
|
|
2452
|
-
this.registerTransformer("generic-messaging", {
|
|
2453
|
-
canTransform: (webhook) => {
|
|
2454
|
-
const body = webhook.body;
|
|
2455
|
-
return body && (body.messageId || body.id) && body.status;
|
|
2456
|
-
},
|
|
2457
|
-
transform: async (webhook) => {
|
|
2458
|
-
const events = [];
|
|
2459
|
-
const body = webhook.body;
|
|
2460
|
-
const basePayload = {
|
|
2461
|
-
messageId: body.messageId || body.id,
|
|
2462
|
-
provider: webhook.source,
|
|
2463
|
-
channel: body.channel || "unknown"
|
|
2464
|
-
};
|
|
2465
|
-
switch (body.status) {
|
|
2466
|
-
case "delivered":
|
|
2467
|
-
case "read":
|
|
2468
|
-
events.push({
|
|
2469
|
-
id: `generic_delivered_${webhook.id}`,
|
|
2470
|
-
type: "message.delivered",
|
|
2471
|
-
timestamp: webhook.timestamp,
|
|
2472
|
-
source: webhook.source,
|
|
2473
|
-
payload: {
|
|
2474
|
-
...basePayload,
|
|
2475
|
-
deliveryTime: body.deliveredAt ? new Date(body.deliveredAt) : webhook.timestamp
|
|
2476
|
-
}
|
|
2477
|
-
});
|
|
2478
|
-
break;
|
|
2479
|
-
case "failed":
|
|
2480
|
-
case "rejected":
|
|
2481
|
-
case "undelivered":
|
|
2482
|
-
events.push({
|
|
2483
|
-
id: `generic_failed_${webhook.id}`,
|
|
2484
|
-
type: "message.failed",
|
|
2485
|
-
timestamp: webhook.timestamp,
|
|
2486
|
-
source: webhook.source,
|
|
2487
|
-
payload: {
|
|
2488
|
-
...basePayload,
|
|
2489
|
-
errorCode: body.errorCode || body.error_code || "unknown",
|
|
2490
|
-
errorMessage: body.errorMessage || body.error_message || "Unknown error",
|
|
2491
|
-
errorType: body.errorType || "delivery"
|
|
2492
|
-
}
|
|
2493
|
-
});
|
|
2494
|
-
break;
|
|
2495
|
-
case "clicked":
|
|
2496
|
-
events.push({
|
|
2497
|
-
id: `generic_clicked_${webhook.id}`,
|
|
2498
|
-
type: "message.clicked",
|
|
2499
|
-
timestamp: webhook.timestamp,
|
|
2500
|
-
source: webhook.source,
|
|
2501
|
-
payload: {
|
|
2502
|
-
...basePayload,
|
|
2503
|
-
linkUrl: body.clickedUrl || body.url,
|
|
2504
|
-
linkType: body.linkType || "link"
|
|
2505
|
-
}
|
|
2506
|
-
});
|
|
2507
|
-
break;
|
|
2508
|
-
}
|
|
2509
|
-
return events;
|
|
2510
|
-
}
|
|
2511
|
-
});
|
|
2512
|
-
}
|
|
2513
|
-
startCleanup() {
|
|
2514
|
-
setInterval(() => {
|
|
2515
|
-
const now = Date.now();
|
|
2516
|
-
for (const [key, data] of this.requestCounts.entries()) {
|
|
2517
|
-
if (now > data.resetTime) {
|
|
2518
|
-
this.requestCounts.delete(key);
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
}, 5 * 60 * 1e3);
|
|
2522
|
-
}
|
|
2523
|
-
};
|
|
2524
|
-
|
|
2525
|
-
// src/insights/anomaly.detector.ts
|
|
2526
|
-
var AnomalyDetector = class {
|
|
2527
|
-
config;
|
|
2528
|
-
historicalData = /* @__PURE__ */ new Map();
|
|
2529
|
-
seasonalPatterns = /* @__PURE__ */ new Map();
|
|
2530
|
-
baselines = /* @__PURE__ */ new Map();
|
|
2531
|
-
defaultConfig = {
|
|
2532
|
-
algorithms: [
|
|
2533
|
-
{ name: "zscore", type: "statistical", enabled: true, config: { threshold: 3 } },
|
|
2534
|
-
{ name: "iqr", type: "statistical", enabled: true, config: { multiplier: 1.5 } },
|
|
2535
|
-
{ name: "isolation", type: "ml", enabled: false, config: { contamination: 0.1 } }
|
|
2536
|
-
],
|
|
2537
|
-
sensitivity: "medium",
|
|
2538
|
-
minDataPoints: 10,
|
|
2539
|
-
confidenceThreshold: 0.7,
|
|
2540
|
-
enableSeasonalAdjustment: true
|
|
2541
|
-
};
|
|
2542
|
-
constructor(config = {}) {
|
|
2543
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
2544
|
-
}
|
|
2545
|
-
/**
|
|
2546
|
-
* 실시간 이상 탐지
|
|
2547
|
-
*/
|
|
2548
|
-
async detectRealTimeAnomalies(metric) {
|
|
2549
|
-
const key = this.getMetricKey(metric.type, metric.dimensions);
|
|
2550
|
-
const anomalies = [];
|
|
2551
|
-
this.updateHistoricalData(key, metric.value);
|
|
2552
|
-
for (const algorithm of this.config.algorithms) {
|
|
2553
|
-
if (!algorithm.enabled) continue;
|
|
2554
|
-
try {
|
|
2555
|
-
const anomaly = await this.runAlgorithm(metric, algorithm, key);
|
|
2556
|
-
if (anomaly && anomaly.confidence >= this.config.confidenceThreshold) {
|
|
2557
|
-
anomalies.push(anomaly);
|
|
2558
|
-
}
|
|
2559
|
-
} catch (error) {
|
|
2560
|
-
console.error(`Anomaly detection failed for algorithm ${algorithm.name}:`, error);
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
return this.deduplicateAndRankAnomalies(anomalies);
|
|
2564
|
-
}
|
|
2565
|
-
/**
|
|
2566
|
-
* 배치 이상 탐지
|
|
2567
|
-
*/
|
|
2568
|
-
async detectBatchAnomalies(metrics, timeWindow) {
|
|
2569
|
-
const anomalies = [];
|
|
2570
|
-
const groupedMetrics = this.groupMetricsByTypeAndDimensions(metrics);
|
|
2571
|
-
for (const [key, typeMetrics] of groupedMetrics.entries()) {
|
|
2572
|
-
const [metricType, dimensionsStr] = key.split("|");
|
|
2573
|
-
const dimensions = JSON.parse(dimensionsStr);
|
|
2574
|
-
const timeSeries = typeMetrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()).map((m) => ({ timestamp: m.timestamp, value: m.aggregations.avg }));
|
|
2575
|
-
if (timeSeries.length < this.config.minDataPoints) {
|
|
2576
|
-
continue;
|
|
2577
|
-
}
|
|
2578
|
-
let adjustedSeries = timeSeries;
|
|
2579
|
-
if (this.config.enableSeasonalAdjustment) {
|
|
2580
|
-
adjustedSeries = await this.adjustForSeasonality(timeSeries, key);
|
|
2581
|
-
}
|
|
2582
|
-
for (let i = Math.floor(adjustedSeries.length * 0.3); i < adjustedSeries.length; i++) {
|
|
2583
|
-
const point = adjustedSeries[i];
|
|
2584
|
-
const historicalWindow = adjustedSeries.slice(Math.max(0, i - 20), i);
|
|
2585
|
-
if (historicalWindow.length < this.config.minDataPoints) continue;
|
|
2586
|
-
const mockMetric = {
|
|
2587
|
-
id: `batch_${i}`,
|
|
2588
|
-
type: metricType,
|
|
2589
|
-
timestamp: point.timestamp,
|
|
2590
|
-
value: point.value,
|
|
2591
|
-
dimensions
|
|
2592
|
-
};
|
|
2593
|
-
const pointAnomalies = await this.detectRealTimeAnomalies(mockMetric);
|
|
2594
|
-
anomalies.push(...pointAnomalies);
|
|
2595
|
-
}
|
|
2596
|
-
}
|
|
2597
|
-
return this.deduplicateAndRankAnomalies(anomalies);
|
|
2598
|
-
}
|
|
2599
|
-
/**
|
|
2600
|
-
* 트렌드 변화 탐지
|
|
2601
|
-
*/
|
|
2602
|
-
async detectTrendChanges(metrics, windowSize = 10) {
|
|
2603
|
-
const insights = [];
|
|
2604
|
-
const groupedMetrics = this.groupMetricsByTypeAndDimensions(metrics);
|
|
2605
|
-
for (const [key, typeMetrics] of groupedMetrics.entries()) {
|
|
2606
|
-
const [metricType, dimensionsStr] = key.split("|");
|
|
2607
|
-
const dimensions = JSON.parse(dimensionsStr);
|
|
2608
|
-
const sortedMetrics = typeMetrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
2609
|
-
if (sortedMetrics.length < windowSize * 2) continue;
|
|
2610
|
-
for (let i = windowSize; i < sortedMetrics.length - windowSize; i++) {
|
|
2611
|
-
const beforeWindow = sortedMetrics.slice(i - windowSize, i);
|
|
2612
|
-
const afterWindow = sortedMetrics.slice(i, i + windowSize);
|
|
2613
|
-
const beforeTrend = this.calculateTrend(beforeWindow.map((m) => m.aggregations.avg));
|
|
2614
|
-
const afterTrend = this.calculateTrend(afterWindow.map((m) => m.aggregations.avg));
|
|
2615
|
-
const trendChange = Math.abs(afterTrend - beforeTrend);
|
|
2616
|
-
const significanceThreshold = this.getSensitivityThreshold("trend");
|
|
2617
|
-
if (trendChange > significanceThreshold) {
|
|
2618
|
-
insights.push({
|
|
2619
|
-
id: `trend_change_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
2620
|
-
type: "trend",
|
|
2621
|
-
title: `Trend Change Detected in ${metricType}`,
|
|
2622
|
-
description: `Significant trend change from ${beforeTrend.toFixed(2)} to ${afterTrend.toFixed(2)}`,
|
|
2623
|
-
severity: this.calculateTrendSeverity(trendChange, significanceThreshold),
|
|
2624
|
-
metric: metricType,
|
|
2625
|
-
dimensions,
|
|
2626
|
-
value: afterTrend,
|
|
2627
|
-
expectedValue: beforeTrend,
|
|
2628
|
-
confidence: Math.min(0.95, 0.5 + trendChange / significanceThreshold * 0.3),
|
|
2629
|
-
actionable: trendChange > significanceThreshold * 2,
|
|
2630
|
-
recommendations: this.generateTrendRecommendations(beforeTrend, afterTrend, metricType),
|
|
2631
|
-
detectedAt: sortedMetrics[i].timestamp
|
|
2632
|
-
});
|
|
2633
|
-
}
|
|
2634
|
-
}
|
|
2635
|
-
}
|
|
2636
|
-
return insights;
|
|
2637
|
-
}
|
|
2638
|
-
/**
|
|
2639
|
-
* 베이스라인 업데이트
|
|
2640
|
-
*/
|
|
2641
|
-
async updateBaselines(metrics) {
|
|
2642
|
-
const groupedMetrics = this.groupMetricsByTypeAndDimensions(metrics);
|
|
2643
|
-
for (const [key, typeMetrics] of groupedMetrics.entries()) {
|
|
2644
|
-
const values = typeMetrics.map((m) => m.aggregations.avg);
|
|
2645
|
-
if (values.length < this.config.minDataPoints) continue;
|
|
2646
|
-
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
2647
|
-
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
|
|
2648
|
-
const stdDev = Math.sqrt(variance);
|
|
2649
|
-
const min = Math.min(...values);
|
|
2650
|
-
const max = Math.max(...values);
|
|
2651
|
-
this.baselines.set(key, { mean, stdDev, min, max });
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
async runAlgorithm(metric, algorithm, key) {
|
|
2655
|
-
const historicalValues = this.historicalData.get(key) || [];
|
|
2656
|
-
if (historicalValues.length < this.config.minDataPoints) {
|
|
2657
|
-
return null;
|
|
2658
|
-
}
|
|
2659
|
-
switch (algorithm.name) {
|
|
2660
|
-
case "zscore":
|
|
2661
|
-
return this.zScoreDetection(metric, historicalValues, algorithm.config, key);
|
|
2662
|
-
case "iqr":
|
|
2663
|
-
return this.iqrDetection(metric, historicalValues, algorithm.config, key);
|
|
2664
|
-
case "isolation":
|
|
2665
|
-
return this.isolationForestDetection(metric, historicalValues, algorithm.config, key);
|
|
2666
|
-
case "threshold":
|
|
2667
|
-
return this.thresholdDetection(metric, algorithm.config, key);
|
|
2668
|
-
default:
|
|
2669
|
-
return null;
|
|
2670
|
-
}
|
|
2671
|
-
}
|
|
2672
|
-
zScoreDetection(metric, historicalValues, config, key) {
|
|
2673
|
-
const mean = historicalValues.reduce((sum, v) => sum + v, 0) / historicalValues.length;
|
|
2674
|
-
const variance = historicalValues.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / historicalValues.length;
|
|
2675
|
-
const stdDev = Math.sqrt(variance);
|
|
2676
|
-
if (stdDev === 0) return null;
|
|
2677
|
-
const zScore = Math.abs((metric.value - mean) / stdDev);
|
|
2678
|
-
const threshold = config.threshold || 3;
|
|
2679
|
-
if (zScore > threshold) {
|
|
2680
|
-
const severity = this.calculateSeverity(zScore, threshold);
|
|
2681
|
-
const confidence = Math.min(0.99, zScore / threshold * 0.8);
|
|
2682
|
-
return {
|
|
2683
|
-
id: `zscore_${metric.id}`,
|
|
2684
|
-
metricType: metric.type,
|
|
2685
|
-
timestamp: metric.timestamp,
|
|
2686
|
-
value: metric.value,
|
|
2687
|
-
expectedValue: mean,
|
|
2688
|
-
deviation: zScore,
|
|
2689
|
-
severity,
|
|
2690
|
-
confidence,
|
|
2691
|
-
algorithm: "zscore",
|
|
2692
|
-
dimensions: metric.dimensions,
|
|
2693
|
-
context: {
|
|
2694
|
-
trend: this.detectTrend(historicalValues),
|
|
2695
|
-
seasonality: false,
|
|
2696
|
-
historicalComparison: (metric.value - mean) / mean * 100
|
|
2697
|
-
}
|
|
2698
|
-
};
|
|
2699
|
-
}
|
|
2700
|
-
return null;
|
|
2701
|
-
}
|
|
2702
|
-
iqrDetection(metric, historicalValues, config, key) {
|
|
2703
|
-
const sortedValues = [...historicalValues].sort((a, b) => a - b);
|
|
2704
|
-
const q1Index = Math.floor(sortedValues.length * 0.25);
|
|
2705
|
-
const q3Index = Math.floor(sortedValues.length * 0.75);
|
|
2706
|
-
const q1 = sortedValues[q1Index];
|
|
2707
|
-
const q3 = sortedValues[q3Index];
|
|
2708
|
-
const iqr = q3 - q1;
|
|
2709
|
-
const multiplier = config.multiplier || 1.5;
|
|
2710
|
-
const lowerBound = q1 - multiplier * iqr;
|
|
2711
|
-
const upperBound = q3 + multiplier * iqr;
|
|
2712
|
-
if (metric.value < lowerBound || metric.value > upperBound) {
|
|
2713
|
-
const expectedValue = (q1 + q3) / 2;
|
|
2714
|
-
const deviation = Math.abs(metric.value - expectedValue) / iqr;
|
|
2715
|
-
const severity = this.calculateSeverity(deviation, multiplier);
|
|
2716
|
-
const confidence = Math.min(0.99, deviation / multiplier * 0.7);
|
|
2717
|
-
return {
|
|
2718
|
-
id: `iqr_${metric.id}`,
|
|
2719
|
-
metricType: metric.type,
|
|
2720
|
-
timestamp: metric.timestamp,
|
|
2721
|
-
value: metric.value,
|
|
2722
|
-
expectedValue,
|
|
2723
|
-
deviation,
|
|
2724
|
-
severity,
|
|
2725
|
-
confidence,
|
|
2726
|
-
algorithm: "iqr",
|
|
2727
|
-
dimensions: metric.dimensions,
|
|
2728
|
-
context: {
|
|
2729
|
-
trend: this.detectTrend(historicalValues),
|
|
2730
|
-
seasonality: false,
|
|
2731
|
-
historicalComparison: (metric.value - expectedValue) / expectedValue * 100
|
|
2732
|
-
}
|
|
2733
|
-
};
|
|
2734
|
-
}
|
|
2735
|
-
return null;
|
|
2736
|
-
}
|
|
2737
|
-
isolationForestDetection(metric, historicalValues, config, key) {
|
|
2738
|
-
const contamination = config.contamination || 0.1;
|
|
2739
|
-
const threshold = this.calculateIsolationThreshold(historicalValues, contamination);
|
|
2740
|
-
const anomalyScore = this.calculateIsolationScore(metric.value, historicalValues);
|
|
2741
|
-
if (anomalyScore > threshold) {
|
|
2742
|
-
const expectedValue = historicalValues.reduce((sum, v) => sum + v, 0) / historicalValues.length;
|
|
2743
|
-
const severity = this.calculateSeverity(anomalyScore, threshold);
|
|
2744
|
-
const confidence = Math.min(0.99, anomalyScore / threshold * 0.9);
|
|
2745
|
-
return {
|
|
2746
|
-
id: `isolation_${metric.id}`,
|
|
2747
|
-
metricType: metric.type,
|
|
2748
|
-
timestamp: metric.timestamp,
|
|
2749
|
-
value: metric.value,
|
|
2750
|
-
expectedValue,
|
|
2751
|
-
deviation: anomalyScore,
|
|
2752
|
-
severity,
|
|
2753
|
-
confidence,
|
|
2754
|
-
algorithm: "isolation",
|
|
2755
|
-
dimensions: metric.dimensions
|
|
2756
|
-
};
|
|
2757
|
-
}
|
|
2758
|
-
return null;
|
|
2759
|
-
}
|
|
2760
|
-
thresholdDetection(metric, config, key) {
|
|
2761
|
-
const upperThreshold = config.upperThreshold;
|
|
2762
|
-
const lowerThreshold = config.lowerThreshold;
|
|
2763
|
-
if (upperThreshold !== void 0 && metric.value > upperThreshold) {
|
|
2764
|
-
return {
|
|
2765
|
-
id: `threshold_upper_${metric.id}`,
|
|
2766
|
-
metricType: metric.type,
|
|
2767
|
-
timestamp: metric.timestamp,
|
|
2768
|
-
value: metric.value,
|
|
2769
|
-
expectedValue: upperThreshold,
|
|
2770
|
-
deviation: (metric.value - upperThreshold) / upperThreshold,
|
|
2771
|
-
severity: "high",
|
|
2772
|
-
confidence: 1,
|
|
2773
|
-
algorithm: "threshold",
|
|
2774
|
-
dimensions: metric.dimensions
|
|
2775
|
-
};
|
|
2776
|
-
}
|
|
2777
|
-
if (lowerThreshold !== void 0 && metric.value < lowerThreshold) {
|
|
2778
|
-
return {
|
|
2779
|
-
id: `threshold_lower_${metric.id}`,
|
|
2780
|
-
metricType: metric.type,
|
|
2781
|
-
timestamp: metric.timestamp,
|
|
2782
|
-
value: metric.value,
|
|
2783
|
-
expectedValue: lowerThreshold,
|
|
2784
|
-
deviation: (lowerThreshold - metric.value) / lowerThreshold,
|
|
2785
|
-
severity: "high",
|
|
2786
|
-
confidence: 1,
|
|
2787
|
-
algorithm: "threshold",
|
|
2788
|
-
dimensions: metric.dimensions
|
|
2789
|
-
};
|
|
2790
|
-
}
|
|
2791
|
-
return null;
|
|
2792
|
-
}
|
|
2793
|
-
calculateSeverity(deviation, threshold) {
|
|
2794
|
-
const ratio = deviation / threshold;
|
|
2795
|
-
if (ratio > 3) return "critical";
|
|
2796
|
-
if (ratio > 2) return "high";
|
|
2797
|
-
if (ratio > 1.5) return "medium";
|
|
2798
|
-
return "low";
|
|
2799
|
-
}
|
|
2800
|
-
calculateTrendSeverity(change, threshold) {
|
|
2801
|
-
const ratio = change / threshold;
|
|
2802
|
-
if (ratio > 4) return "critical";
|
|
2803
|
-
if (ratio > 3) return "high";
|
|
2804
|
-
if (ratio > 2) return "medium";
|
|
2805
|
-
return "low";
|
|
2806
|
-
}
|
|
2807
|
-
getMetricKey(type, dimensions) {
|
|
2808
|
-
return `${type}|${JSON.stringify(dimensions)}`;
|
|
2809
|
-
}
|
|
2810
|
-
updateHistoricalData(key, value) {
|
|
2811
|
-
const history = this.historicalData.get(key) || [];
|
|
2812
|
-
history.push(value);
|
|
2813
|
-
if (history.length > 100) {
|
|
2814
|
-
history.splice(0, history.length - 100);
|
|
2815
|
-
}
|
|
2816
|
-
this.historicalData.set(key, history);
|
|
2817
|
-
}
|
|
2818
|
-
groupMetricsByTypeAndDimensions(metrics) {
|
|
2819
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
2820
|
-
for (const metric of metrics) {
|
|
2821
|
-
const key = `${metric.type}|${JSON.stringify(metric.dimensions)}`;
|
|
2822
|
-
if (!grouped.has(key)) {
|
|
2823
|
-
grouped.set(key, []);
|
|
2824
|
-
}
|
|
2825
|
-
grouped.get(key).push(metric);
|
|
2826
|
-
}
|
|
2827
|
-
return grouped;
|
|
2828
|
-
}
|
|
2829
|
-
async adjustForSeasonality(timeSeries, key) {
|
|
2830
|
-
const windowSize = Math.min(24, Math.floor(timeSeries.length / 4));
|
|
2831
|
-
if (windowSize < 3) return timeSeries;
|
|
2832
|
-
return timeSeries.map((point, index) => {
|
|
2833
|
-
const windowStart = Math.max(0, index - Math.floor(windowSize / 2));
|
|
2834
|
-
const windowEnd = Math.min(timeSeries.length, windowStart + windowSize);
|
|
2835
|
-
const window = timeSeries.slice(windowStart, windowEnd);
|
|
2836
|
-
const seasonalAvg = window.reduce((sum, p) => sum + p.value, 0) / window.length;
|
|
2837
|
-
const adjustedValue = point.value - seasonalAvg + timeSeries.reduce((sum, p) => sum + p.value, 0) / timeSeries.length;
|
|
2838
|
-
return {
|
|
2839
|
-
timestamp: point.timestamp,
|
|
2840
|
-
value: adjustedValue
|
|
2841
|
-
};
|
|
2842
|
-
});
|
|
2843
|
-
}
|
|
2844
|
-
calculateTrend(values) {
|
|
2845
|
-
if (values.length < 2) return 0;
|
|
2846
|
-
const n = values.length;
|
|
2847
|
-
const x = Array.from({ length: n }, (_, i) => i);
|
|
2848
|
-
const sumX = x.reduce((a, b) => a + b, 0);
|
|
2849
|
-
const sumY = values.reduce((a, b) => a + b, 0);
|
|
2850
|
-
const sumXY = x.reduce((sum, xi, i) => sum + xi * values[i], 0);
|
|
2851
|
-
const sumXX = x.reduce((sum, xi) => sum + xi * xi, 0);
|
|
2852
|
-
return (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
|
2853
|
-
}
|
|
2854
|
-
detectTrend(values) {
|
|
2855
|
-
const trend = this.calculateTrend(values);
|
|
2856
|
-
const threshold = 0.1;
|
|
2857
|
-
if (Math.abs(trend) < threshold) return "stable";
|
|
2858
|
-
return trend > 0 ? "increasing" : "decreasing";
|
|
2859
|
-
}
|
|
2860
|
-
getSensitivityThreshold(type) {
|
|
2861
|
-
const baseThresholds = {
|
|
2862
|
-
anomaly: { low: 0.5, medium: 0.3, high: 0.1 },
|
|
2863
|
-
trend: { low: 2, medium: 1, high: 0.5 }
|
|
2864
|
-
};
|
|
2865
|
-
return baseThresholds[type][this.config.sensitivity];
|
|
2866
|
-
}
|
|
2867
|
-
calculateIsolationThreshold(values, contamination) {
|
|
2868
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
2869
|
-
const cutoffIndex = Math.floor((1 - contamination) * sorted.length);
|
|
2870
|
-
return 0.6;
|
|
2871
|
-
}
|
|
2872
|
-
calculateIsolationScore(value, historicalValues) {
|
|
2873
|
-
const mean = historicalValues.reduce((sum, v) => sum + v, 0) / historicalValues.length;
|
|
2874
|
-
const distances = historicalValues.map((v) => Math.abs(v - value));
|
|
2875
|
-
const avgDistance = distances.reduce((sum, d) => sum + d, 0) / distances.length;
|
|
2876
|
-
return avgDistance / (Math.abs(value - mean) + 1);
|
|
2877
|
-
}
|
|
2878
|
-
deduplicateAndRankAnomalies(anomalies) {
|
|
2879
|
-
const unique = /* @__PURE__ */ new Map();
|
|
2880
|
-
for (const anomaly of anomalies) {
|
|
2881
|
-
const key = `${anomaly.metricType}_${JSON.stringify(anomaly.dimensions)}_${anomaly.timestamp.toISOString()}`;
|
|
2882
|
-
if (!unique.has(key) || unique.get(key).confidence < anomaly.confidence) {
|
|
2883
|
-
unique.set(key, anomaly);
|
|
2884
|
-
}
|
|
2885
|
-
}
|
|
2886
|
-
return Array.from(unique.values()).sort((a, b) => b.confidence - a.confidence);
|
|
2887
|
-
}
|
|
2888
|
-
generateTrendRecommendations(beforeTrend, afterTrend, metricType) {
|
|
2889
|
-
const recommendations = [];
|
|
2890
|
-
const isIncreasing = afterTrend > beforeTrend;
|
|
2891
|
-
const change = Math.abs(afterTrend - beforeTrend);
|
|
2892
|
-
switch (metricType) {
|
|
2893
|
-
case "message_failed" /* MESSAGE_FAILED */:
|
|
2894
|
-
if (isIncreasing) {
|
|
2895
|
-
recommendations.push(
|
|
2896
|
-
"Investigate provider API issues",
|
|
2897
|
-
"Review recent template changes",
|
|
2898
|
-
"Check recipient data quality"
|
|
2899
|
-
);
|
|
2900
|
-
}
|
|
2901
|
-
break;
|
|
2902
|
-
case "delivery_rate" /* DELIVERY_RATE */:
|
|
2903
|
-
if (!isIncreasing) {
|
|
2904
|
-
recommendations.push(
|
|
2905
|
-
"Monitor provider performance",
|
|
2906
|
-
"Verify network connectivity",
|
|
2907
|
-
"Check for carrier-specific issues"
|
|
2908
|
-
);
|
|
2909
|
-
}
|
|
2910
|
-
break;
|
|
2911
|
-
case "message_sent" /* MESSAGE_SENT */:
|
|
2912
|
-
if (isIncreasing) {
|
|
2913
|
-
recommendations.push(
|
|
2914
|
-
"Monitor system capacity",
|
|
2915
|
-
"Prepare for increased load",
|
|
2916
|
-
"Check rate limiting settings"
|
|
2917
|
-
);
|
|
2918
|
-
} else {
|
|
2919
|
-
recommendations.push(
|
|
2920
|
-
"Investigate traffic decrease",
|
|
2921
|
-
"Check for system issues",
|
|
2922
|
-
"Review campaign status"
|
|
2923
|
-
);
|
|
2924
|
-
}
|
|
2925
|
-
break;
|
|
2926
|
-
}
|
|
2927
|
-
if (change > 2) {
|
|
2928
|
-
recommendations.push("Consider immediate investigation due to significant change");
|
|
2929
|
-
}
|
|
2930
|
-
return recommendations;
|
|
2931
|
-
}
|
|
2932
|
-
};
|
|
2933
|
-
|
|
2934
|
-
// src/insights/recommendation.engine.ts
|
|
2935
|
-
var RecommendationEngine = class {
|
|
2936
|
-
config;
|
|
2937
|
-
recommendations = /* @__PURE__ */ new Map();
|
|
2938
|
-
ruleExecutionHistory = /* @__PURE__ */ new Map();
|
|
2939
|
-
defaultConfig = {
|
|
2940
|
-
rules: [],
|
|
2941
|
-
enableMachineLearning: false,
|
|
2942
|
-
confidenceThreshold: 0.7,
|
|
2943
|
-
maxRecommendations: 10,
|
|
2944
|
-
categories: [
|
|
2945
|
-
{ id: "cost", name: "Cost Optimization", description: "Reduce operational costs", weight: 0.8 },
|
|
2946
|
-
{ id: "performance", name: "Performance", description: "Improve system performance", weight: 0.9 },
|
|
2947
|
-
{ id: "reliability", name: "Reliability", description: "Enhance system reliability", weight: 1 },
|
|
2948
|
-
{ id: "security", name: "Security", description: "Strengthen security posture", weight: 0.95 }
|
|
2949
|
-
]
|
|
2950
|
-
};
|
|
2951
|
-
constructor(config = {}) {
|
|
2952
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
2953
|
-
this.initializeDefaultRules();
|
|
2954
|
-
}
|
|
2955
|
-
/**
|
|
2956
|
-
* 메트릭 기반 추천 생성
|
|
2957
|
-
*/
|
|
2958
|
-
async generateRecommendations(metrics) {
|
|
2959
|
-
const recommendations = [];
|
|
2960
|
-
const ruleBasedRecommendations = await this.generateRuleBasedRecommendations(metrics);
|
|
2961
|
-
recommendations.push(...ruleBasedRecommendations);
|
|
2962
|
-
const patternBasedRecommendations = await this.generatePatternBasedRecommendations(metrics);
|
|
2963
|
-
recommendations.push(...patternBasedRecommendations);
|
|
2964
|
-
const comparisonBasedRecommendations = await this.generateComparisonBasedRecommendations(metrics);
|
|
2965
|
-
recommendations.push(...comparisonBasedRecommendations);
|
|
2966
|
-
if (this.config.enableMachineLearning) {
|
|
2967
|
-
const mlRecommendations = await this.generateMLBasedRecommendations(metrics);
|
|
2968
|
-
recommendations.push(...mlRecommendations);
|
|
2969
|
-
}
|
|
2970
|
-
const filteredRecommendations = this.deduplicateAndPrioritize(recommendations);
|
|
2971
|
-
for (const recommendation of filteredRecommendations) {
|
|
2972
|
-
this.recommendations.set(recommendation.id, recommendation);
|
|
2973
|
-
}
|
|
2974
|
-
return filteredRecommendations.slice(0, this.config.maxRecommendations);
|
|
2975
|
-
}
|
|
2976
|
-
/**
|
|
2977
|
-
* 특정 카테고리 추천 조회
|
|
2978
|
-
*/
|
|
2979
|
-
getRecommendationsByCategory(category) {
|
|
2980
|
-
return Array.from(this.recommendations.values()).filter((r) => r.category === category).sort((a, b) => b.priority - a.priority);
|
|
2981
|
-
}
|
|
2982
|
-
/**
|
|
2983
|
-
* 추천 실행 상태 업데이트
|
|
2984
|
-
*/
|
|
2985
|
-
markRecommendationAsImplemented(recommendationId) {
|
|
2986
|
-
const recommendation = this.recommendations.get(recommendationId);
|
|
2987
|
-
if (!recommendation) return false;
|
|
2988
|
-
this.recommendations.delete(recommendationId);
|
|
2989
|
-
return true;
|
|
2990
|
-
}
|
|
2991
|
-
/**
|
|
2992
|
-
* 추천 무시
|
|
2993
|
-
*/
|
|
2994
|
-
dismissRecommendation(recommendationId, reason) {
|
|
2995
|
-
const recommendation = this.recommendations.get(recommendationId);
|
|
2996
|
-
if (!recommendation) return false;
|
|
2997
|
-
this.recommendations.delete(recommendationId);
|
|
2998
|
-
return true;
|
|
2999
|
-
}
|
|
3000
|
-
/**
|
|
3001
|
-
* 추천 통계
|
|
3002
|
-
*/
|
|
3003
|
-
getRecommendationStats() {
|
|
3004
|
-
const recommendations = Array.from(this.recommendations.values());
|
|
3005
|
-
const byCategory = {};
|
|
3006
|
-
const byImpact = {};
|
|
3007
|
-
const byPriority = {};
|
|
3008
|
-
for (const rec of recommendations) {
|
|
3009
|
-
byCategory[rec.category] = (byCategory[rec.category] || 0) + 1;
|
|
3010
|
-
byImpact[rec.impact] = (byImpact[rec.impact] || 0) + 1;
|
|
3011
|
-
const priorityLevel = rec.priority >= 8 ? "high" : rec.priority >= 5 ? "medium" : "low";
|
|
3012
|
-
byPriority[priorityLevel] = (byPriority[priorityLevel] || 0) + 1;
|
|
3013
|
-
}
|
|
3014
|
-
return {
|
|
3015
|
-
total: recommendations.length,
|
|
3016
|
-
byCategory,
|
|
3017
|
-
byImpact,
|
|
3018
|
-
byPriority
|
|
3019
|
-
};
|
|
3020
|
-
}
|
|
3021
|
-
async generateRuleBasedRecommendations(metrics) {
|
|
3022
|
-
const recommendations = [];
|
|
3023
|
-
for (const rule of this.config.rules) {
|
|
3024
|
-
if (!rule.enabled) continue;
|
|
3025
|
-
try {
|
|
3026
|
-
const matchingMetrics = this.evaluateRuleConditions(rule, metrics);
|
|
3027
|
-
if (matchingMetrics.length > 0) {
|
|
3028
|
-
const recommendation = await this.createRecommendationFromRule(rule, matchingMetrics);
|
|
3029
|
-
if (recommendation && recommendation.confidence >= this.config.confidenceThreshold) {
|
|
3030
|
-
recommendations.push(recommendation);
|
|
3031
|
-
this.recordRuleExecution(rule.id);
|
|
3032
|
-
}
|
|
3033
|
-
}
|
|
3034
|
-
} catch (error) {
|
|
3035
|
-
console.error(`Rule execution failed for rule ${rule.id}:`, error);
|
|
3036
|
-
}
|
|
3037
|
-
}
|
|
3038
|
-
return recommendations;
|
|
3039
|
-
}
|
|
3040
|
-
async generatePatternBasedRecommendations(metrics) {
|
|
3041
|
-
const recommendations = [];
|
|
3042
|
-
const timePatterns = this.analyzeTimePatterns(metrics);
|
|
3043
|
-
recommendations.push(...this.generateTimeBasedRecommendations(timePatterns));
|
|
3044
|
-
const channelPatterns = this.analyzeChannelPatterns(metrics);
|
|
3045
|
-
recommendations.push(...this.generateChannelBasedRecommendations(channelPatterns));
|
|
3046
|
-
const errorPatterns = this.analyzeErrorPatterns(metrics);
|
|
3047
|
-
recommendations.push(...this.generateErrorBasedRecommendations(errorPatterns));
|
|
3048
|
-
return recommendations;
|
|
3049
|
-
}
|
|
3050
|
-
async generateComparisonBasedRecommendations(metrics) {
|
|
3051
|
-
const recommendations = [];
|
|
3052
|
-
const providerComparison = this.compareProviderPerformance(metrics);
|
|
3053
|
-
recommendations.push(...this.generateProviderRecommendations(providerComparison));
|
|
3054
|
-
const channelComparison = this.compareChannelEfficiency(metrics);
|
|
3055
|
-
recommendations.push(...this.generateChannelEfficiencyRecommendations(channelComparison));
|
|
3056
|
-
return recommendations;
|
|
3057
|
-
}
|
|
3058
|
-
async generateMLBasedRecommendations(metrics) {
|
|
3059
|
-
const recommendations = [];
|
|
3060
|
-
const capacityRecommendations = await this.generateCapacityRecommendations(metrics);
|
|
3061
|
-
recommendations.push(...capacityRecommendations);
|
|
3062
|
-
return recommendations;
|
|
3063
|
-
}
|
|
3064
|
-
evaluateRuleConditions(rule, metrics) {
|
|
3065
|
-
const matchingMetrics = [];
|
|
3066
|
-
for (const condition of rule.conditions) {
|
|
3067
|
-
const relevantMetrics = metrics.filter((m) => {
|
|
3068
|
-
if (m.type !== condition.metric) return false;
|
|
3069
|
-
if (condition.dimensions) {
|
|
3070
|
-
for (const [key, value] of Object.entries(condition.dimensions)) {
|
|
3071
|
-
if (m.dimensions[key] !== value) return false;
|
|
3072
|
-
}
|
|
3073
|
-
}
|
|
3074
|
-
return true;
|
|
3075
|
-
});
|
|
3076
|
-
for (const metric of relevantMetrics) {
|
|
3077
|
-
if (this.evaluateCondition(metric, condition)) {
|
|
3078
|
-
matchingMetrics.push(metric);
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
}
|
|
3082
|
-
return matchingMetrics;
|
|
3083
|
-
}
|
|
3084
|
-
evaluateCondition(metric, condition) {
|
|
3085
|
-
const value = metric.aggregations.avg;
|
|
3086
|
-
switch (condition.operator) {
|
|
3087
|
-
case "gt":
|
|
3088
|
-
return value > condition.value;
|
|
3089
|
-
case "lt":
|
|
3090
|
-
return value < condition.value;
|
|
3091
|
-
case "gte":
|
|
3092
|
-
return value >= condition.value;
|
|
3093
|
-
case "lte":
|
|
3094
|
-
return value <= condition.value;
|
|
3095
|
-
case "eq":
|
|
3096
|
-
return value === condition.value;
|
|
3097
|
-
case "between":
|
|
3098
|
-
const [min, max] = condition.value;
|
|
3099
|
-
return value >= min && value <= max;
|
|
3100
|
-
case "trend":
|
|
3101
|
-
return false;
|
|
3102
|
-
default:
|
|
3103
|
-
return false;
|
|
3104
|
-
}
|
|
3105
|
-
}
|
|
3106
|
-
async createRecommendationFromRule(rule, matchingMetrics) {
|
|
3107
|
-
if (rule.actions.length === 0) return null;
|
|
3108
|
-
const confidence = this.calculateRuleConfidence(rule, matchingMetrics);
|
|
3109
|
-
const impact = this.calculateAggregateImpact(rule.actions);
|
|
3110
|
-
const effort = this.calculateAggregateEffort(rule.actions);
|
|
3111
|
-
return {
|
|
3112
|
-
id: `rule_${rule.id}_${Date.now()}`,
|
|
3113
|
-
category: rule.category,
|
|
3114
|
-
priority: rule.priority,
|
|
3115
|
-
title: rule.name,
|
|
3116
|
-
description: `Based on analysis of ${matchingMetrics.length} metrics`,
|
|
3117
|
-
rationale: this.generateRationale(rule, matchingMetrics),
|
|
3118
|
-
actions: rule.actions,
|
|
3119
|
-
confidence,
|
|
3120
|
-
impact,
|
|
3121
|
-
effort,
|
|
3122
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3123
|
-
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3),
|
|
3124
|
-
// 1주일
|
|
3125
|
-
metadata: {
|
|
3126
|
-
ruleIds: [rule.id],
|
|
3127
|
-
triggeredBy: {
|
|
3128
|
-
metrics: matchingMetrics.map((m) => ({
|
|
3129
|
-
type: m.type,
|
|
3130
|
-
value: m.aggregations.avg,
|
|
3131
|
-
timestamp: m.timestamp
|
|
3132
|
-
})),
|
|
3133
|
-
conditions: rule.conditions.map((c) => `${c.metric} ${c.operator} ${c.value}`)
|
|
3134
|
-
}
|
|
3135
|
-
}
|
|
3136
|
-
};
|
|
3137
|
-
}
|
|
3138
|
-
analyzeTimePatterns(metrics) {
|
|
3139
|
-
const hourlyUsage = /* @__PURE__ */ new Map();
|
|
3140
|
-
const dailyUsage = /* @__PURE__ */ new Map();
|
|
3141
|
-
for (const metric of metrics) {
|
|
3142
|
-
const hour = metric.timestamp.getHours();
|
|
3143
|
-
const dayOfWeek = metric.timestamp.getDay();
|
|
3144
|
-
hourlyUsage.set(hour, (hourlyUsage.get(hour) || 0) + metric.aggregations.sum);
|
|
3145
|
-
dailyUsage.set(dayOfWeek, (dailyUsage.get(dayOfWeek) || 0) + metric.aggregations.sum);
|
|
3146
|
-
}
|
|
3147
|
-
return { hourlyUsage, dailyUsage };
|
|
3148
|
-
}
|
|
3149
|
-
generateTimeBasedRecommendations(timePatterns) {
|
|
3150
|
-
const recommendations = [];
|
|
3151
|
-
const { hourlyUsage, dailyUsage } = timePatterns;
|
|
3152
|
-
const peakHour = Array.from(hourlyUsage.entries()).reduce(
|
|
3153
|
-
(max, curr) => curr[1] > max[1] ? curr : max,
|
|
3154
|
-
[0, 0]
|
|
3155
|
-
);
|
|
3156
|
-
if (peakHour[1] > 0) {
|
|
3157
|
-
recommendations.push({
|
|
3158
|
-
id: `time_peak_${Date.now()}`,
|
|
3159
|
-
category: "performance",
|
|
3160
|
-
priority: 7,
|
|
3161
|
-
title: "Optimize for Peak Hours",
|
|
3162
|
-
description: `Peak usage occurs at ${peakHour[0]}:00. Consider load balancing optimizations.`,
|
|
3163
|
-
rationale: "High traffic concentration during peak hours may impact performance",
|
|
3164
|
-
actions: [{
|
|
3165
|
-
type: "performance",
|
|
3166
|
-
title: "Implement Load Balancing",
|
|
3167
|
-
description: "Configure load balancing to distribute traffic during peak hours",
|
|
3168
|
-
impact: "medium",
|
|
3169
|
-
effort: "medium",
|
|
3170
|
-
steps: [
|
|
3171
|
-
"Set up multiple provider connections",
|
|
3172
|
-
"Implement round-robin distribution",
|
|
3173
|
-
"Monitor performance during peak hours"
|
|
3174
|
-
]
|
|
3175
|
-
}],
|
|
3176
|
-
confidence: 0.8,
|
|
3177
|
-
impact: "medium",
|
|
3178
|
-
effort: "medium",
|
|
3179
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3180
|
-
metadata: {
|
|
3181
|
-
ruleIds: [],
|
|
3182
|
-
triggeredBy: {
|
|
3183
|
-
metrics: [],
|
|
3184
|
-
conditions: [`Peak hour usage: ${peakHour[1]} at ${peakHour[0]}:00`]
|
|
3185
|
-
}
|
|
3186
|
-
}
|
|
3187
|
-
});
|
|
3188
|
-
}
|
|
3189
|
-
return recommendations;
|
|
3190
|
-
}
|
|
3191
|
-
analyzeChannelPatterns(metrics) {
|
|
3192
|
-
const channelUsage = /* @__PURE__ */ new Map();
|
|
3193
|
-
for (const metric of metrics) {
|
|
3194
|
-
const channel = metric.dimensions.channel || "unknown";
|
|
3195
|
-
const stats = channelUsage.get(channel) || { sent: 0, delivered: 0, failed: 0 };
|
|
3196
|
-
if (metric.type === "message_sent" /* MESSAGE_SENT */) {
|
|
3197
|
-
stats.sent += metric.aggregations.sum;
|
|
3198
|
-
} else if (metric.type === "message_delivered" /* MESSAGE_DELIVERED */) {
|
|
3199
|
-
stats.delivered += metric.aggregations.sum;
|
|
3200
|
-
} else if (metric.type === "message_failed" /* MESSAGE_FAILED */) {
|
|
3201
|
-
stats.failed += metric.aggregations.sum;
|
|
3202
|
-
}
|
|
3203
|
-
channelUsage.set(channel, stats);
|
|
3204
|
-
}
|
|
3205
|
-
return { channelUsage };
|
|
3206
|
-
}
|
|
3207
|
-
generateChannelBasedRecommendations(channelPatterns) {
|
|
3208
|
-
const recommendations = [];
|
|
3209
|
-
const { channelUsage } = channelPatterns;
|
|
3210
|
-
for (const [channel, stats] of channelUsage.entries()) {
|
|
3211
|
-
const deliveryRate = stats.sent > 0 ? stats.delivered / stats.sent * 100 : 0;
|
|
3212
|
-
const failureRate = stats.sent > 0 ? stats.failed / stats.sent * 100 : 0;
|
|
3213
|
-
if (deliveryRate < 90 && stats.sent > 100) {
|
|
3214
|
-
recommendations.push({
|
|
3215
|
-
id: `channel_delivery_${channel}_${Date.now()}`,
|
|
3216
|
-
category: "reliability",
|
|
3217
|
-
priority: 8,
|
|
3218
|
-
title: `Improve ${channel} Channel Reliability`,
|
|
3219
|
-
description: `${channel} channel has ${deliveryRate.toFixed(1)}% delivery rate`,
|
|
3220
|
-
rationale: "Low delivery rate impacts customer experience and wastes resources",
|
|
3221
|
-
actions: [{
|
|
3222
|
-
type: "reliability",
|
|
3223
|
-
title: "Investigate Channel Issues",
|
|
3224
|
-
description: `Analyze and fix delivery issues for ${channel} channel`,
|
|
3225
|
-
impact: "high",
|
|
3226
|
-
effort: "medium",
|
|
3227
|
-
steps: [
|
|
3228
|
-
`Review ${channel} provider configuration`,
|
|
3229
|
-
"Check template approval status",
|
|
3230
|
-
"Analyze failure patterns",
|
|
3231
|
-
"Implement fallback mechanisms"
|
|
3232
|
-
]
|
|
3233
|
-
}],
|
|
3234
|
-
confidence: 0.9,
|
|
3235
|
-
impact: "high",
|
|
3236
|
-
effort: "medium",
|
|
3237
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3238
|
-
metadata: {
|
|
3239
|
-
ruleIds: [],
|
|
3240
|
-
triggeredBy: {
|
|
3241
|
-
metrics: [],
|
|
3242
|
-
conditions: [`${channel} delivery rate: ${deliveryRate.toFixed(1)}%`]
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
});
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
|
-
return recommendations;
|
|
3249
|
-
}
|
|
3250
|
-
analyzeErrorPatterns(metrics) {
|
|
3251
|
-
const errorsByCode = /* @__PURE__ */ new Map();
|
|
3252
|
-
const errorsByProvider = /* @__PURE__ */ new Map();
|
|
3253
|
-
for (const metric of metrics) {
|
|
3254
|
-
if (metric.type === "message_failed" /* MESSAGE_FAILED */) {
|
|
3255
|
-
const errorCode = metric.dimensions.errorCode || "unknown";
|
|
3256
|
-
const provider = metric.dimensions.provider || "unknown";
|
|
3257
|
-
errorsByCode.set(errorCode, (errorsByCode.get(errorCode) || 0) + metric.aggregations.sum);
|
|
3258
|
-
errorsByProvider.set(provider, (errorsByProvider.get(provider) || 0) + metric.aggregations.sum);
|
|
3259
|
-
}
|
|
3260
|
-
}
|
|
3261
|
-
return { errorsByCode, errorsByProvider };
|
|
3262
|
-
}
|
|
3263
|
-
generateErrorBasedRecommendations(errorPatterns) {
|
|
3264
|
-
const recommendations = [];
|
|
3265
|
-
const { errorsByCode, errorsByProvider } = errorPatterns;
|
|
3266
|
-
if (errorsByCode.size > 0) {
|
|
3267
|
-
const topError = Array.from(errorsByCode.entries()).reduce(
|
|
3268
|
-
(max, curr) => curr[1] > max[1] ? curr : max
|
|
3269
|
-
);
|
|
3270
|
-
if (topError[1] > 10) {
|
|
3271
|
-
recommendations.push({
|
|
3272
|
-
id: `error_${topError[0]}_${Date.now()}`,
|
|
3273
|
-
category: "reliability",
|
|
3274
|
-
priority: 9,
|
|
3275
|
-
title: `Address Frequent Error: ${topError[0]}`,
|
|
3276
|
-
description: `Error code ${topError[0]} occurred ${topError[1]} times`,
|
|
3277
|
-
rationale: "Frequent errors indicate systematic issues that need attention",
|
|
3278
|
-
actions: [{
|
|
3279
|
-
type: "reliability",
|
|
3280
|
-
title: "Fix Recurring Error",
|
|
3281
|
-
description: `Investigate and resolve error code ${topError[0]}`,
|
|
3282
|
-
impact: "high",
|
|
3283
|
-
effort: "high",
|
|
3284
|
-
steps: [
|
|
3285
|
-
"Analyze error logs and patterns",
|
|
3286
|
-
"Identify root cause",
|
|
3287
|
-
"Implement fix or workaround",
|
|
3288
|
-
"Add monitoring for this error type"
|
|
3289
|
-
]
|
|
3290
|
-
}],
|
|
3291
|
-
confidence: 0.95,
|
|
3292
|
-
impact: "high",
|
|
3293
|
-
effort: "high",
|
|
3294
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3295
|
-
metadata: {
|
|
3296
|
-
ruleIds: [],
|
|
3297
|
-
triggeredBy: {
|
|
3298
|
-
metrics: [],
|
|
3299
|
-
conditions: [`Error ${topError[0]}: ${topError[1]} occurrences`]
|
|
3300
|
-
}
|
|
3301
|
-
}
|
|
3302
|
-
});
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
return recommendations;
|
|
3306
|
-
}
|
|
3307
|
-
compareProviderPerformance(metrics) {
|
|
3308
|
-
const providerStats = /* @__PURE__ */ new Map();
|
|
3309
|
-
for (const metric of metrics) {
|
|
3310
|
-
const provider = metric.dimensions.provider;
|
|
3311
|
-
if (!provider) continue;
|
|
3312
|
-
const stats = providerStats.get(provider) || { sent: 0, delivered: 0, avgResponseTime: 0 };
|
|
3313
|
-
if (metric.type === "message_sent" /* MESSAGE_SENT */) {
|
|
3314
|
-
stats.sent += metric.aggregations.sum;
|
|
3315
|
-
} else if (metric.type === "message_delivered" /* MESSAGE_DELIVERED */) {
|
|
3316
|
-
stats.delivered += metric.aggregations.sum;
|
|
3317
|
-
}
|
|
3318
|
-
providerStats.set(provider, stats);
|
|
3319
|
-
}
|
|
3320
|
-
return { providerStats };
|
|
3321
|
-
}
|
|
3322
|
-
generateProviderRecommendations(providerComparison) {
|
|
3323
|
-
const recommendations = [];
|
|
3324
|
-
const { providerStats } = providerComparison;
|
|
3325
|
-
const providers = Array.from(providerStats.entries()).map(([provider, stats]) => ({
|
|
3326
|
-
provider,
|
|
3327
|
-
deliveryRate: stats.sent > 0 ? stats.delivered / stats.sent * 100 : 0,
|
|
3328
|
-
volume: stats.sent
|
|
3329
|
-
}));
|
|
3330
|
-
if (providers.length > 1) {
|
|
3331
|
-
const bestProvider = providers.reduce(
|
|
3332
|
-
(best, curr) => curr.deliveryRate > best.deliveryRate ? curr : best
|
|
3333
|
-
);
|
|
3334
|
-
const worstProvider = providers.reduce(
|
|
3335
|
-
(worst, curr) => curr.deliveryRate < worst.deliveryRate ? curr : worst
|
|
3336
|
-
);
|
|
3337
|
-
if (bestProvider.deliveryRate - worstProvider.deliveryRate > 10) {
|
|
3338
|
-
recommendations.push({
|
|
3339
|
-
id: `provider_optimization_${Date.now()}`,
|
|
3340
|
-
category: "cost",
|
|
3341
|
-
priority: 6,
|
|
3342
|
-
title: "Optimize Provider Usage",
|
|
3343
|
-
description: `${bestProvider.provider} has ${bestProvider.deliveryRate.toFixed(1)}% delivery rate vs ${worstProvider.provider} at ${worstProvider.deliveryRate.toFixed(1)}%`,
|
|
3344
|
-
rationale: "Shifting traffic to better-performing providers can improve delivery rates and reduce costs",
|
|
3345
|
-
actions: [{
|
|
3346
|
-
type: "optimization",
|
|
3347
|
-
title: "Rebalance Provider Traffic",
|
|
3348
|
-
description: "Increase traffic to high-performing providers",
|
|
3349
|
-
impact: "medium",
|
|
3350
|
-
effort: "low",
|
|
3351
|
-
steps: [
|
|
3352
|
-
`Reduce traffic allocation to ${worstProvider.provider}`,
|
|
3353
|
-
`Increase traffic allocation to ${bestProvider.provider}`,
|
|
3354
|
-
"Monitor performance changes",
|
|
3355
|
-
"Adjust allocation based on results"
|
|
3356
|
-
]
|
|
3357
|
-
}],
|
|
3358
|
-
confidence: 0.85,
|
|
3359
|
-
impact: "medium",
|
|
3360
|
-
effort: "low",
|
|
3361
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3362
|
-
metadata: {
|
|
3363
|
-
ruleIds: [],
|
|
3364
|
-
triggeredBy: {
|
|
3365
|
-
metrics: [],
|
|
3366
|
-
conditions: [
|
|
3367
|
-
`${bestProvider.provider}: ${bestProvider.deliveryRate.toFixed(1)}%`,
|
|
3368
|
-
`${worstProvider.provider}: ${worstProvider.deliveryRate.toFixed(1)}%`
|
|
3369
|
-
]
|
|
3370
|
-
}
|
|
3371
|
-
}
|
|
3372
|
-
});
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
return recommendations;
|
|
3376
|
-
}
|
|
3377
|
-
compareChannelEfficiency(metrics) {
|
|
3378
|
-
return {};
|
|
3379
|
-
}
|
|
3380
|
-
generateChannelEfficiencyRecommendations(channelComparison) {
|
|
3381
|
-
return [];
|
|
3382
|
-
}
|
|
3383
|
-
async generateCapacityRecommendations(metrics) {
|
|
3384
|
-
const recommendations = [];
|
|
3385
|
-
const messageSentMetrics = metrics.filter((m) => m.type === "message_sent" /* MESSAGE_SENT */);
|
|
3386
|
-
if (messageSentMetrics.length > 0) {
|
|
3387
|
-
const recentVolume = messageSentMetrics.filter((m) => m.timestamp > new Date(Date.now() - 24 * 60 * 60 * 1e3)).reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3388
|
-
const historicalAverage = messageSentMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0) / messageSentMetrics.length;
|
|
3389
|
-
if (recentVolume > historicalAverage * 1.5) {
|
|
3390
|
-
recommendations.push({
|
|
3391
|
-
id: `capacity_scale_${Date.now()}`,
|
|
3392
|
-
category: "performance",
|
|
3393
|
-
priority: 7,
|
|
3394
|
-
title: "Consider Capacity Scaling",
|
|
3395
|
-
description: `Recent volume (${recentVolume}) is 50% above historical average (${historicalAverage.toFixed(0)})`,
|
|
3396
|
-
rationale: "Sustained high volume may require additional capacity",
|
|
3397
|
-
actions: [{
|
|
3398
|
-
type: "performance",
|
|
3399
|
-
title: "Scale Infrastructure",
|
|
3400
|
-
description: "Prepare for increased load by scaling infrastructure",
|
|
3401
|
-
impact: "high",
|
|
3402
|
-
effort: "high",
|
|
3403
|
-
steps: [
|
|
3404
|
-
"Monitor system resource utilization",
|
|
3405
|
-
"Prepare additional provider connections",
|
|
3406
|
-
"Review rate limiting configurations",
|
|
3407
|
-
"Plan for peak capacity scenarios"
|
|
3408
|
-
]
|
|
3409
|
-
}],
|
|
3410
|
-
confidence: 0.75,
|
|
3411
|
-
impact: "high",
|
|
3412
|
-
effort: "high",
|
|
3413
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
3414
|
-
metadata: {
|
|
3415
|
-
ruleIds: [],
|
|
3416
|
-
triggeredBy: {
|
|
3417
|
-
metrics: [],
|
|
3418
|
-
conditions: [`Recent volume: ${recentVolume}, Historical average: ${historicalAverage.toFixed(0)}`]
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
});
|
|
3422
|
-
}
|
|
3423
|
-
}
|
|
3424
|
-
return recommendations;
|
|
3425
|
-
}
|
|
3426
|
-
calculateRuleConfidence(rule, matchingMetrics) {
|
|
3427
|
-
const baseConfidence = 0.5;
|
|
3428
|
-
const dataQualityBonus = Math.min(0.3, matchingMetrics.length / 10);
|
|
3429
|
-
const priorityBonus = rule.priority / 10 * 0.2;
|
|
3430
|
-
return Math.min(0.99, baseConfidence + dataQualityBonus + priorityBonus);
|
|
3431
|
-
}
|
|
3432
|
-
calculateAggregateImpact(actions) {
|
|
3433
|
-
const impactScores = { low: 1, medium: 2, high: 3 };
|
|
3434
|
-
const avgScore = actions.reduce((sum, action) => sum + impactScores[action.impact], 0) / actions.length;
|
|
3435
|
-
if (avgScore >= 2.5) return "high";
|
|
3436
|
-
if (avgScore >= 1.5) return "medium";
|
|
3437
|
-
return "low";
|
|
3438
|
-
}
|
|
3439
|
-
calculateAggregateEffort(actions) {
|
|
3440
|
-
const effortScores = { low: 1, medium: 2, high: 3 };
|
|
3441
|
-
const avgScore = actions.reduce((sum, action) => sum + effortScores[action.effort], 0) / actions.length;
|
|
3442
|
-
if (avgScore >= 2.5) return "high";
|
|
3443
|
-
if (avgScore >= 1.5) return "medium";
|
|
3444
|
-
return "low";
|
|
3445
|
-
}
|
|
3446
|
-
generateRationale(rule, matchingMetrics) {
|
|
3447
|
-
const metricSummary = matchingMetrics.length > 0 ? `Based on ${matchingMetrics.length} metrics showing concerning patterns` : "Based on rule evaluation";
|
|
3448
|
-
return `${metricSummary}. ${rule.name} conditions have been met, indicating potential optimization opportunities.`;
|
|
3449
|
-
}
|
|
3450
|
-
recordRuleExecution(ruleId) {
|
|
3451
|
-
const history = this.ruleExecutionHistory.get(ruleId) || [];
|
|
3452
|
-
history.push(/* @__PURE__ */ new Date());
|
|
3453
|
-
if (history.length > 100) {
|
|
3454
|
-
history.splice(0, history.length - 100);
|
|
3455
|
-
}
|
|
3456
|
-
this.ruleExecutionHistory.set(ruleId, history);
|
|
3457
|
-
}
|
|
3458
|
-
deduplicateAndPrioritize(recommendations) {
|
|
3459
|
-
const uniqueRecommendations = /* @__PURE__ */ new Map();
|
|
3460
|
-
for (const rec of recommendations) {
|
|
3461
|
-
const key = `${rec.category}_${rec.title}`;
|
|
3462
|
-
if (!uniqueRecommendations.has(key) || uniqueRecommendations.get(key).confidence < rec.confidence) {
|
|
3463
|
-
uniqueRecommendations.set(key, rec);
|
|
3464
|
-
}
|
|
3465
|
-
}
|
|
3466
|
-
return Array.from(uniqueRecommendations.values()).sort((a, b) => {
|
|
3467
|
-
if (a.priority !== b.priority) return b.priority - a.priority;
|
|
3468
|
-
return b.confidence - a.confidence;
|
|
3469
|
-
});
|
|
3470
|
-
}
|
|
3471
|
-
initializeDefaultRules() {
|
|
3472
|
-
this.config.rules.push(
|
|
3473
|
-
{
|
|
3474
|
-
id: "low-delivery-rate",
|
|
3475
|
-
name: "Low Delivery Rate Alert",
|
|
3476
|
-
category: "reliability",
|
|
3477
|
-
priority: 9,
|
|
3478
|
-
conditions: [
|
|
3479
|
-
{ metric: "delivery_rate" /* DELIVERY_RATE */, operator: "lt", value: 90 }
|
|
3480
|
-
],
|
|
3481
|
-
actions: [{
|
|
3482
|
-
type: "reliability",
|
|
3483
|
-
title: "Improve Delivery Rate",
|
|
3484
|
-
description: "Investigate and fix delivery rate issues",
|
|
3485
|
-
impact: "high",
|
|
3486
|
-
effort: "medium",
|
|
3487
|
-
steps: [
|
|
3488
|
-
"Check provider API status",
|
|
3489
|
-
"Review template approval status",
|
|
3490
|
-
"Validate recipient phone numbers",
|
|
3491
|
-
"Consider switching providers"
|
|
3492
|
-
]
|
|
3493
|
-
}],
|
|
3494
|
-
enabled: true
|
|
3495
|
-
},
|
|
3496
|
-
{
|
|
3497
|
-
id: "high-error-rate",
|
|
3498
|
-
name: "High Error Rate Warning",
|
|
3499
|
-
category: "reliability",
|
|
3500
|
-
priority: 8,
|
|
3501
|
-
conditions: [
|
|
3502
|
-
{ metric: "error_rate" /* ERROR_RATE */, operator: "gt", value: 10 }
|
|
3503
|
-
],
|
|
3504
|
-
actions: [{
|
|
3505
|
-
type: "reliability",
|
|
3506
|
-
title: "Reduce Error Rate",
|
|
3507
|
-
description: "Address high error rates",
|
|
3508
|
-
impact: "high",
|
|
3509
|
-
effort: "high",
|
|
3510
|
-
steps: [
|
|
3511
|
-
"Analyze error patterns",
|
|
3512
|
-
"Fix common error causes",
|
|
3513
|
-
"Implement better error handling",
|
|
3514
|
-
"Add monitoring and alerts"
|
|
3515
|
-
]
|
|
3516
|
-
}],
|
|
3517
|
-
enabled: true
|
|
3518
|
-
},
|
|
3519
|
-
{
|
|
3520
|
-
id: "cost-optimization-sms",
|
|
3521
|
-
name: "SMS to AlimTalk Migration",
|
|
3522
|
-
category: "cost",
|
|
3523
|
-
priority: 6,
|
|
3524
|
-
conditions: [
|
|
3525
|
-
{ metric: "channel_usage" /* CHANNEL_USAGE */, operator: "gt", value: 1e3, dimensions: { channel: "sms" } }
|
|
3526
|
-
],
|
|
3527
|
-
actions: [{
|
|
3528
|
-
type: "cost-saving",
|
|
3529
|
-
title: "Migrate to AlimTalk",
|
|
3530
|
-
description: "Switch eligible SMS messages to AlimTalk for cost savings",
|
|
3531
|
-
impact: "medium",
|
|
3532
|
-
effort: "medium",
|
|
3533
|
-
steps: [
|
|
3534
|
-
"Identify AlimTalk-eligible messages",
|
|
3535
|
-
"Create AlimTalk templates",
|
|
3536
|
-
"Implement fallback logic",
|
|
3537
|
-
"Monitor cost savings"
|
|
3538
|
-
],
|
|
3539
|
-
estimatedBenefit: {
|
|
3540
|
-
metric: "message_sent" /* MESSAGE_SENT */,
|
|
3541
|
-
improvement: 30,
|
|
3542
|
-
unit: "percent cost reduction"
|
|
3543
|
-
}
|
|
3544
|
-
}],
|
|
3545
|
-
enabled: true
|
|
3546
|
-
}
|
|
3547
|
-
);
|
|
3548
|
-
}
|
|
3549
|
-
};
|
|
3550
|
-
|
|
3551
|
-
// src/reports/dashboard.ts
|
|
3552
|
-
var DashboardGenerator = class {
|
|
3553
|
-
config;
|
|
3554
|
-
dataCache = /* @__PURE__ */ new Map();
|
|
3555
|
-
defaultConfig = {
|
|
3556
|
-
refreshInterval: 3e4,
|
|
3557
|
-
// 30초
|
|
3558
|
-
timeRange: {
|
|
3559
|
-
default: "1h",
|
|
3560
|
-
options: ["15m", "1h", "4h", "1d", "1w", "1m"]
|
|
3561
|
-
},
|
|
3562
|
-
widgets: [],
|
|
3563
|
-
filters: [
|
|
3564
|
-
{
|
|
3565
|
-
id: "provider",
|
|
3566
|
-
name: "Provider",
|
|
3567
|
-
type: "multi-select",
|
|
3568
|
-
field: "provider",
|
|
3569
|
-
options: []
|
|
3570
|
-
},
|
|
3571
|
-
{
|
|
3572
|
-
id: "channel",
|
|
3573
|
-
name: "Channel",
|
|
3574
|
-
type: "multi-select",
|
|
3575
|
-
field: "channel",
|
|
3576
|
-
options: []
|
|
3577
|
-
}
|
|
3578
|
-
]
|
|
3579
|
-
};
|
|
3580
|
-
constructor(config = {}) {
|
|
3581
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
3582
|
-
this.initializeDefaultWidgets();
|
|
3583
|
-
}
|
|
3584
|
-
/**
|
|
3585
|
-
* 대시보드 데이터 생성
|
|
3586
|
-
*/
|
|
3587
|
-
async generateDashboard(timeRange, filters = {}, metrics = []) {
|
|
3588
|
-
const dashboard = {
|
|
3589
|
-
timestamp: /* @__PURE__ */ new Date(),
|
|
3590
|
-
timeRange,
|
|
3591
|
-
kpis: [],
|
|
3592
|
-
widgets: [],
|
|
3593
|
-
insights: [],
|
|
3594
|
-
filters
|
|
3595
|
-
};
|
|
3596
|
-
try {
|
|
3597
|
-
dashboard.kpis = await this.calculateKPIs(metrics, timeRange, filters);
|
|
3598
|
-
dashboard.widgets = await this.generateWidgetData(metrics, timeRange, filters);
|
|
3599
|
-
dashboard.insights = [];
|
|
3600
|
-
} catch (error) {
|
|
3601
|
-
console.error("Dashboard generation failed:", error);
|
|
3602
|
-
}
|
|
3603
|
-
return dashboard;
|
|
3604
|
-
}
|
|
3605
|
-
/**
|
|
3606
|
-
* 실시간 대시보드 스트림
|
|
3607
|
-
*/
|
|
3608
|
-
async *streamDashboard(timeRange, filters = {}) {
|
|
3609
|
-
while (true) {
|
|
3610
|
-
const dashboard = await this.generateDashboard(timeRange, filters);
|
|
3611
|
-
yield dashboard;
|
|
3612
|
-
await new Promise((resolve) => setTimeout(resolve, this.config.refreshInterval));
|
|
3613
|
-
}
|
|
3614
|
-
}
|
|
3615
|
-
/**
|
|
3616
|
-
* 특정 위젯 데이터 업데이트
|
|
3617
|
-
*/
|
|
3618
|
-
async updateWidget(widgetId, metrics, timeRange, filters = {}) {
|
|
3619
|
-
const widget = this.config.widgets.find((w) => w.id === widgetId);
|
|
3620
|
-
if (!widget) return null;
|
|
3621
|
-
try {
|
|
3622
|
-
const data = await this.generateWidgetData([widget], metrics, timeRange, filters);
|
|
3623
|
-
return data[0] || null;
|
|
3624
|
-
} catch (error) {
|
|
3625
|
-
return {
|
|
3626
|
-
id: widgetId,
|
|
3627
|
-
title: widget.title,
|
|
3628
|
-
type: widget.type,
|
|
3629
|
-
data: null,
|
|
3630
|
-
lastUpdated: /* @__PURE__ */ new Date(),
|
|
3631
|
-
error: error instanceof Error ? error.message : "Unknown error"
|
|
3632
|
-
};
|
|
3633
|
-
}
|
|
3634
|
-
}
|
|
3635
|
-
/**
|
|
3636
|
-
* 대시보드 구성 업데이트
|
|
3637
|
-
*/
|
|
3638
|
-
updateConfig(config) {
|
|
3639
|
-
this.config = { ...this.config, ...config };
|
|
3640
|
-
}
|
|
3641
|
-
/**
|
|
3642
|
-
* 위젯 추가
|
|
3643
|
-
*/
|
|
3644
|
-
addWidget(widget) {
|
|
3645
|
-
this.config.widgets.push(widget);
|
|
3646
|
-
}
|
|
3647
|
-
/**
|
|
3648
|
-
* 위젯 제거
|
|
3649
|
-
*/
|
|
3650
|
-
removeWidget(widgetId) {
|
|
3651
|
-
const index = this.config.widgets.findIndex((w) => w.id === widgetId);
|
|
3652
|
-
if (index >= 0) {
|
|
3653
|
-
this.config.widgets.splice(index, 1);
|
|
3654
|
-
return true;
|
|
3655
|
-
}
|
|
3656
|
-
return false;
|
|
3657
|
-
}
|
|
3658
|
-
async calculateKPIs(metrics, timeRange, filters) {
|
|
3659
|
-
const kpis = [];
|
|
3660
|
-
const duration = timeRange.end.getTime() - timeRange.start.getTime();
|
|
3661
|
-
const previousTimeRange = {
|
|
3662
|
-
start: new Date(timeRange.start.getTime() - duration),
|
|
3663
|
-
end: timeRange.start
|
|
3664
|
-
};
|
|
3665
|
-
const sentMetrics = this.filterMetrics(metrics, "message_sent" /* MESSAGE_SENT */, filters);
|
|
3666
|
-
const totalSent = sentMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3667
|
-
const previousSent = this.calculatePreviousPeriodValue(sentMetrics, previousTimeRange);
|
|
3668
|
-
kpis.push({
|
|
3669
|
-
id: "total_sent",
|
|
3670
|
-
name: "Total Messages Sent",
|
|
3671
|
-
value: totalSent,
|
|
3672
|
-
previousValue: previousSent,
|
|
3673
|
-
change: totalSent - previousSent,
|
|
3674
|
-
changePercent: previousSent > 0 ? (totalSent - previousSent) / previousSent * 100 : 0,
|
|
3675
|
-
trend: totalSent > previousSent ? "up" : totalSent < previousSent ? "down" : "stable",
|
|
3676
|
-
unit: "messages",
|
|
3677
|
-
status: "good"
|
|
3678
|
-
});
|
|
3679
|
-
const deliveredMetrics = this.filterMetrics(metrics, "message_delivered" /* MESSAGE_DELIVERED */, filters);
|
|
3680
|
-
const totalDelivered = deliveredMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3681
|
-
const deliveryRate = totalSent > 0 ? totalDelivered / totalSent * 100 : 0;
|
|
3682
|
-
kpis.push({
|
|
3683
|
-
id: "delivery_rate",
|
|
3684
|
-
name: "Delivery Rate",
|
|
3685
|
-
value: deliveryRate,
|
|
3686
|
-
trend: deliveryRate >= 95 ? "up" : deliveryRate >= 90 ? "stable" : "down",
|
|
3687
|
-
unit: "%",
|
|
3688
|
-
target: 95,
|
|
3689
|
-
status: deliveryRate >= 95 ? "good" : deliveryRate >= 85 ? "warning" : "critical"
|
|
3690
|
-
});
|
|
3691
|
-
const failedMetrics = this.filterMetrics(metrics, "message_failed" /* MESSAGE_FAILED */, filters);
|
|
3692
|
-
const totalFailed = failedMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3693
|
-
const errorRate = totalSent > 0 ? totalFailed / totalSent * 100 : 0;
|
|
3694
|
-
kpis.push({
|
|
3695
|
-
id: "error_rate",
|
|
3696
|
-
name: "Error Rate",
|
|
3697
|
-
value: errorRate,
|
|
3698
|
-
trend: errorRate <= 5 ? "down" : errorRate <= 10 ? "stable" : "up",
|
|
3699
|
-
unit: "%",
|
|
3700
|
-
target: 5,
|
|
3701
|
-
status: errorRate <= 5 ? "good" : errorRate <= 15 ? "warning" : "critical"
|
|
3702
|
-
});
|
|
3703
|
-
const clickedMetrics = this.filterMetrics(metrics, "message_clicked" /* MESSAGE_CLICKED */, filters);
|
|
3704
|
-
const totalClicked = clickedMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3705
|
-
const clickRate = totalDelivered > 0 ? totalClicked / totalDelivered * 100 : 0;
|
|
3706
|
-
kpis.push({
|
|
3707
|
-
id: "click_rate",
|
|
3708
|
-
name: "Click Rate",
|
|
3709
|
-
value: clickRate,
|
|
3710
|
-
trend: "stable",
|
|
3711
|
-
unit: "%",
|
|
3712
|
-
status: "good"
|
|
3713
|
-
});
|
|
3714
|
-
return kpis;
|
|
3715
|
-
}
|
|
3716
|
-
async generateWidgetData(...args) {
|
|
3717
|
-
let widgets;
|
|
3718
|
-
let metrics;
|
|
3719
|
-
let timeRange;
|
|
3720
|
-
let filters;
|
|
3721
|
-
if (args.length === 3) {
|
|
3722
|
-
[metrics, timeRange, filters] = args;
|
|
3723
|
-
widgets = this.config.widgets;
|
|
3724
|
-
} else {
|
|
3725
|
-
[widgets, metrics, timeRange, filters] = args;
|
|
3726
|
-
}
|
|
3727
|
-
const widgetData = [];
|
|
3728
|
-
for (const widget of widgets) {
|
|
3729
|
-
try {
|
|
3730
|
-
const data = await this.generateSingleWidgetData(widget, metrics, timeRange, filters);
|
|
3731
|
-
widgetData.push({
|
|
3732
|
-
id: widget.id,
|
|
3733
|
-
title: widget.title,
|
|
3734
|
-
type: widget.type,
|
|
3735
|
-
data,
|
|
3736
|
-
lastUpdated: /* @__PURE__ */ new Date()
|
|
3737
|
-
});
|
|
3738
|
-
} catch (error) {
|
|
3739
|
-
widgetData.push({
|
|
3740
|
-
id: widget.id,
|
|
3741
|
-
title: widget.title,
|
|
3742
|
-
type: widget.type,
|
|
3743
|
-
data: null,
|
|
3744
|
-
lastUpdated: /* @__PURE__ */ new Date(),
|
|
3745
|
-
error: error instanceof Error ? error.message : "Unknown error"
|
|
3746
|
-
});
|
|
3747
|
-
}
|
|
3748
|
-
}
|
|
3749
|
-
return widgetData;
|
|
3750
|
-
}
|
|
3751
|
-
async generateSingleWidgetData(widget, metrics, timeRange, filters) {
|
|
3752
|
-
const cacheKey = `${widget.id}_${JSON.stringify(filters)}_${timeRange.start.getTime()}_${timeRange.end.getTime()}`;
|
|
3753
|
-
const cached = this.dataCache.get(cacheKey);
|
|
3754
|
-
const cacheExpiration = widget.refreshInterval || this.config.refreshInterval;
|
|
3755
|
-
if (cached && Date.now() - cached.timestamp.getTime() < cacheExpiration) {
|
|
3756
|
-
return cached.data;
|
|
3757
|
-
}
|
|
3758
|
-
const filteredMetrics = this.applyQueryFilters(metrics, widget.query, filters);
|
|
3759
|
-
let data;
|
|
3760
|
-
switch (widget.type) {
|
|
3761
|
-
case "metric":
|
|
3762
|
-
data = this.generateMetricWidgetData(filteredMetrics, widget.visualization);
|
|
3763
|
-
break;
|
|
3764
|
-
case "chart":
|
|
3765
|
-
data = this.generateChartWidgetData(filteredMetrics, widget.visualization);
|
|
3766
|
-
break;
|
|
3767
|
-
case "table":
|
|
3768
|
-
data = this.generateTableWidgetData(filteredMetrics, widget.visualization);
|
|
3769
|
-
break;
|
|
3770
|
-
case "gauge":
|
|
3771
|
-
data = this.generateGaugeWidgetData(filteredMetrics, widget.visualization);
|
|
3772
|
-
break;
|
|
3773
|
-
case "heatmap":
|
|
3774
|
-
data = this.generateHeatmapWidgetData(filteredMetrics, widget.visualization);
|
|
3775
|
-
break;
|
|
3776
|
-
case "trend":
|
|
3777
|
-
data = this.generateTrendWidgetData(filteredMetrics, widget.visualization);
|
|
3778
|
-
break;
|
|
3779
|
-
default:
|
|
3780
|
-
data = null;
|
|
3781
|
-
}
|
|
3782
|
-
this.dataCache.set(cacheKey, { data, timestamp: /* @__PURE__ */ new Date() });
|
|
3783
|
-
return data;
|
|
3784
|
-
}
|
|
3785
|
-
generateMetricWidgetData(metrics, config) {
|
|
3786
|
-
const aggregation = config.aggregation || "sum";
|
|
3787
|
-
let value = 0;
|
|
3788
|
-
switch (aggregation) {
|
|
3789
|
-
case "sum":
|
|
3790
|
-
value = metrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3791
|
-
break;
|
|
3792
|
-
case "avg":
|
|
3793
|
-
value = metrics.reduce((sum, m) => sum + m.aggregations.avg, 0) / Math.max(metrics.length, 1);
|
|
3794
|
-
break;
|
|
3795
|
-
case "min":
|
|
3796
|
-
value = Math.min(...metrics.map((m) => m.aggregations.min));
|
|
3797
|
-
break;
|
|
3798
|
-
case "max":
|
|
3799
|
-
value = Math.max(...metrics.map((m) => m.aggregations.max));
|
|
3800
|
-
break;
|
|
3801
|
-
case "count":
|
|
3802
|
-
value = metrics.length;
|
|
3803
|
-
break;
|
|
3804
|
-
}
|
|
3805
|
-
return {
|
|
3806
|
-
value: isFinite(value) ? value : 0,
|
|
3807
|
-
formatted: this.formatValue(value, config),
|
|
3808
|
-
timestamp: /* @__PURE__ */ new Date()
|
|
3809
|
-
};
|
|
3810
|
-
}
|
|
3811
|
-
generateChartWidgetData(metrics, config) {
|
|
3812
|
-
const chartType = config.chartType || "line";
|
|
3813
|
-
if (config.groupBy && config.groupBy.length > 0) {
|
|
3814
|
-
return this.generateGroupedChartData(metrics, config);
|
|
3815
|
-
}
|
|
3816
|
-
const sortedMetrics = metrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
3817
|
-
const data = sortedMetrics.map((metric) => ({
|
|
3818
|
-
x: metric.timestamp,
|
|
3819
|
-
y: this.getAggregatedValue(metric, config.aggregation || "avg"),
|
|
3820
|
-
label: metric.timestamp.toISOString()
|
|
3821
|
-
}));
|
|
3822
|
-
return {
|
|
3823
|
-
type: chartType,
|
|
3824
|
-
data: [{
|
|
3825
|
-
name: "Series 1",
|
|
3826
|
-
data,
|
|
3827
|
-
color: config.colors?.[0] || "#3b82f6"
|
|
3828
|
-
}],
|
|
3829
|
-
options: {
|
|
3830
|
-
xAxis: config.xAxis,
|
|
3831
|
-
yAxis: config.yAxis,
|
|
3832
|
-
showLegend: config.showLegend ?? true,
|
|
3833
|
-
showGrid: config.showGrid ?? true
|
|
3834
|
-
}
|
|
3835
|
-
};
|
|
3836
|
-
}
|
|
3837
|
-
generateGroupedChartData(metrics, config) {
|
|
3838
|
-
const groupBy = config.groupBy[0];
|
|
3839
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
3840
|
-
for (const metric of metrics) {
|
|
3841
|
-
const key = metric.dimensions[groupBy] || "Unknown";
|
|
3842
|
-
if (!grouped.has(key)) {
|
|
3843
|
-
grouped.set(key, []);
|
|
3844
|
-
}
|
|
3845
|
-
grouped.get(key).push(metric);
|
|
3846
|
-
}
|
|
3847
|
-
const series = Array.from(grouped.entries()).map(([name, groupMetrics], index) => ({
|
|
3848
|
-
name,
|
|
3849
|
-
data: groupMetrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()).map((m) => ({
|
|
3850
|
-
x: m.timestamp,
|
|
3851
|
-
y: this.getAggregatedValue(m, config.aggregation || "avg")
|
|
3852
|
-
})),
|
|
3853
|
-
color: config.colors?.[index % (config.colors?.length || 1)] || this.getDefaultColor(index)
|
|
3854
|
-
}));
|
|
3855
|
-
return {
|
|
3856
|
-
type: config.chartType || "line",
|
|
3857
|
-
data: series,
|
|
3858
|
-
options: {
|
|
3859
|
-
xAxis: config.xAxis,
|
|
3860
|
-
yAxis: config.yAxis,
|
|
3861
|
-
showLegend: config.showLegend ?? true,
|
|
3862
|
-
showGrid: config.showGrid ?? true
|
|
3863
|
-
}
|
|
3864
|
-
};
|
|
3865
|
-
}
|
|
3866
|
-
generateTableWidgetData(metrics, config) {
|
|
3867
|
-
const groupBy = config.groupBy || ["provider", "channel"];
|
|
3868
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
3869
|
-
for (const metric of metrics) {
|
|
3870
|
-
const key = groupBy.map((field) => metric.dimensions[field] || "Unknown").join("|");
|
|
3871
|
-
if (!grouped.has(key)) {
|
|
3872
|
-
grouped.set(key, []);
|
|
3873
|
-
}
|
|
3874
|
-
grouped.get(key).push(metric);
|
|
3875
|
-
}
|
|
3876
|
-
const columns = [
|
|
3877
|
-
...groupBy.map((field) => ({ key: field, title: field.charAt(0).toUpperCase() + field.slice(1) })),
|
|
3878
|
-
{ key: "count", title: "Count" },
|
|
3879
|
-
{ key: "sum", title: "Sum" },
|
|
3880
|
-
{ key: "avg", title: "Average" }
|
|
3881
|
-
];
|
|
3882
|
-
const rows = Array.from(grouped.entries()).map(([key, groupMetrics]) => {
|
|
3883
|
-
const dimensions = key.split("|");
|
|
3884
|
-
const row = {};
|
|
3885
|
-
groupBy.forEach((field, index) => {
|
|
3886
|
-
row[field] = dimensions[index];
|
|
3887
|
-
});
|
|
3888
|
-
row.count = groupMetrics.length;
|
|
3889
|
-
row.sum = groupMetrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3890
|
-
row.avg = groupMetrics.reduce((sum, m) => sum + m.aggregations.avg, 0) / groupMetrics.length;
|
|
3891
|
-
return row;
|
|
3892
|
-
});
|
|
3893
|
-
return {
|
|
3894
|
-
columns,
|
|
3895
|
-
rows: rows.sort((a, b) => b.sum - a.sum)
|
|
3896
|
-
// 합계 기준 내림차순 정렬
|
|
3897
|
-
};
|
|
3898
|
-
}
|
|
3899
|
-
generateGaugeWidgetData(metrics, config) {
|
|
3900
|
-
const value = this.generateMetricWidgetData(metrics, config).value;
|
|
3901
|
-
return {
|
|
3902
|
-
value,
|
|
3903
|
-
min: config.yAxis?.min || 0,
|
|
3904
|
-
max: config.yAxis?.max || 100,
|
|
3905
|
-
thresholds: [
|
|
3906
|
-
{ value: 25, color: "#ef4444" },
|
|
3907
|
-
{ value: 50, color: "#f59e0b" },
|
|
3908
|
-
{ value: 75, color: "#10b981" },
|
|
3909
|
-
{ value: 100, color: "#3b82f6" }
|
|
3910
|
-
]
|
|
3911
|
-
};
|
|
3912
|
-
}
|
|
3913
|
-
generateHeatmapWidgetData(metrics, config) {
|
|
3914
|
-
const heatmapData = [];
|
|
3915
|
-
for (const metric of metrics) {
|
|
3916
|
-
const hour = metric.timestamp.getHours();
|
|
3917
|
-
const dayOfWeek = metric.timestamp.getDay();
|
|
3918
|
-
heatmapData.push({
|
|
3919
|
-
x: hour,
|
|
3920
|
-
y: dayOfWeek,
|
|
3921
|
-
value: this.getAggregatedValue(metric, config.aggregation || "sum")
|
|
3922
|
-
});
|
|
3923
|
-
}
|
|
3924
|
-
return {
|
|
3925
|
-
data: heatmapData,
|
|
3926
|
-
xLabels: Array.from({ length: 24 }, (_, i) => `${i}:00`),
|
|
3927
|
-
yLabels: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
3928
|
-
};
|
|
3929
|
-
}
|
|
3930
|
-
generateTrendWidgetData(metrics, config) {
|
|
3931
|
-
const sortedMetrics = metrics.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
3932
|
-
if (sortedMetrics.length < 2) {
|
|
3933
|
-
return { trend: "stable", change: 0, changePercent: 0 };
|
|
3934
|
-
}
|
|
3935
|
-
const values = sortedMetrics.map((m) => this.getAggregatedValue(m, config.aggregation || "avg"));
|
|
3936
|
-
const firstValue = values[0];
|
|
3937
|
-
const lastValue = values[values.length - 1];
|
|
3938
|
-
const change = lastValue - firstValue;
|
|
3939
|
-
const changePercent = firstValue !== 0 ? change / firstValue * 100 : 0;
|
|
3940
|
-
let trend = "stable";
|
|
3941
|
-
if (Math.abs(changePercent) > 5) {
|
|
3942
|
-
trend = changePercent > 0 ? "up" : "down";
|
|
3943
|
-
}
|
|
3944
|
-
return {
|
|
3945
|
-
trend,
|
|
3946
|
-
change: Math.round(change * 100) / 100,
|
|
3947
|
-
changePercent: Math.round(changePercent * 100) / 100,
|
|
3948
|
-
values: values.slice(-10),
|
|
3949
|
-
// 최근 10개 값
|
|
3950
|
-
sparkline: values.map((value, index) => ({ x: index, y: value }))
|
|
3951
|
-
};
|
|
3952
|
-
}
|
|
3953
|
-
filterMetrics(metrics, type, filters) {
|
|
3954
|
-
return metrics.filter((metric) => {
|
|
3955
|
-
if (metric.type !== type) return false;
|
|
3956
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
3957
|
-
if (Array.isArray(value)) {
|
|
3958
|
-
if (!value.includes(metric.dimensions[key])) return false;
|
|
3959
|
-
} else if (value && metric.dimensions[key] !== value) {
|
|
3960
|
-
return false;
|
|
3961
|
-
}
|
|
3962
|
-
}
|
|
3963
|
-
return true;
|
|
3964
|
-
});
|
|
3965
|
-
}
|
|
3966
|
-
applyQueryFilters(metrics, query, filters) {
|
|
3967
|
-
let filteredMetrics = metrics;
|
|
3968
|
-
if (query.metrics && query.metrics.length > 0) {
|
|
3969
|
-
filteredMetrics = filteredMetrics.filter((m) => query.metrics.includes(m.type));
|
|
3970
|
-
}
|
|
3971
|
-
if (query.dateRange) {
|
|
3972
|
-
filteredMetrics = filteredMetrics.filter(
|
|
3973
|
-
(m) => m.timestamp >= query.dateRange.start && m.timestamp <= query.dateRange.end
|
|
3974
|
-
);
|
|
3975
|
-
}
|
|
3976
|
-
const combinedFilters = { ...query.filters, ...filters };
|
|
3977
|
-
for (const [key, value] of Object.entries(combinedFilters)) {
|
|
3978
|
-
if (value === void 0 || value === null) continue;
|
|
3979
|
-
filteredMetrics = filteredMetrics.filter((m) => {
|
|
3980
|
-
if (Array.isArray(value)) {
|
|
3981
|
-
return value.includes(m.dimensions[key]);
|
|
3982
|
-
}
|
|
3983
|
-
return m.dimensions[key] === value;
|
|
3984
|
-
});
|
|
3985
|
-
}
|
|
3986
|
-
return filteredMetrics;
|
|
3987
|
-
}
|
|
3988
|
-
calculatePreviousPeriodValue(metrics, previousTimeRange) {
|
|
3989
|
-
const currentValue = metrics.reduce((sum, m) => sum + m.aggregations.sum, 0);
|
|
3990
|
-
return currentValue * 0.9;
|
|
3991
|
-
}
|
|
3992
|
-
getAggregatedValue(metric, aggregation) {
|
|
3993
|
-
switch (aggregation) {
|
|
3994
|
-
case "sum":
|
|
3995
|
-
return metric.aggregations.sum;
|
|
3996
|
-
case "avg":
|
|
3997
|
-
return metric.aggregations.avg;
|
|
3998
|
-
case "min":
|
|
3999
|
-
return metric.aggregations.min;
|
|
4000
|
-
case "max":
|
|
4001
|
-
return metric.aggregations.max;
|
|
4002
|
-
case "count":
|
|
4003
|
-
return metric.aggregations.count;
|
|
4004
|
-
default:
|
|
4005
|
-
return metric.aggregations.avg;
|
|
4006
|
-
}
|
|
4007
|
-
}
|
|
4008
|
-
formatValue(value, config) {
|
|
4009
|
-
if (!isFinite(value)) return "0";
|
|
4010
|
-
if (value >= 1e6) {
|
|
4011
|
-
return `${(value / 1e6).toFixed(1)}M`;
|
|
4012
|
-
} else if (value >= 1e3) {
|
|
4013
|
-
return `${(value / 1e3).toFixed(1)}K`;
|
|
4014
|
-
}
|
|
4015
|
-
return value.toFixed(0);
|
|
4016
|
-
}
|
|
4017
|
-
getDefaultColor(index) {
|
|
4018
|
-
const colors = [
|
|
4019
|
-
"#3b82f6",
|
|
4020
|
-
"#ef4444",
|
|
4021
|
-
"#10b981",
|
|
4022
|
-
"#f59e0b",
|
|
4023
|
-
"#8b5cf6",
|
|
4024
|
-
"#06b6d4",
|
|
4025
|
-
"#f97316",
|
|
4026
|
-
"#84cc16"
|
|
4027
|
-
];
|
|
4028
|
-
return colors[index % colors.length];
|
|
4029
|
-
}
|
|
4030
|
-
initializeDefaultWidgets() {
|
|
4031
|
-
this.config.widgets = [
|
|
4032
|
-
{
|
|
4033
|
-
id: "messages_sent_chart",
|
|
4034
|
-
type: "chart",
|
|
4035
|
-
title: "Messages Sent Over Time",
|
|
4036
|
-
position: { x: 0, y: 0, width: 6, height: 4 },
|
|
4037
|
-
query: {
|
|
4038
|
-
metrics: ["message_sent" /* MESSAGE_SENT */],
|
|
4039
|
-
dateRange: { start: /* @__PURE__ */ new Date(), end: /* @__PURE__ */ new Date() },
|
|
4040
|
-
interval: "hour"
|
|
4041
|
-
},
|
|
4042
|
-
visualization: {
|
|
4043
|
-
chartType: "line",
|
|
4044
|
-
aggregation: "sum",
|
|
4045
|
-
showGrid: true
|
|
4046
|
-
}
|
|
4047
|
-
},
|
|
4048
|
-
{
|
|
4049
|
-
id: "delivery_rate_gauge",
|
|
4050
|
-
type: "gauge",
|
|
4051
|
-
title: "Delivery Rate",
|
|
4052
|
-
position: { x: 6, y: 0, width: 3, height: 4 },
|
|
4053
|
-
query: {
|
|
4054
|
-
metrics: ["delivery_rate" /* DELIVERY_RATE */],
|
|
4055
|
-
dateRange: { start: /* @__PURE__ */ new Date(), end: /* @__PURE__ */ new Date() }
|
|
4056
|
-
},
|
|
4057
|
-
visualization: {
|
|
4058
|
-
aggregation: "avg",
|
|
4059
|
-
yAxis: { min: 0, max: 100 }
|
|
4060
|
-
}
|
|
4061
|
-
},
|
|
4062
|
-
{
|
|
4063
|
-
id: "provider_performance_table",
|
|
4064
|
-
type: "table",
|
|
4065
|
-
title: "Provider Performance",
|
|
4066
|
-
position: { x: 0, y: 4, width: 12, height: 4 },
|
|
4067
|
-
query: {
|
|
4068
|
-
metrics: ["message_sent" /* MESSAGE_SENT */, "message_delivered" /* MESSAGE_DELIVERED */, "message_failed" /* MESSAGE_FAILED */],
|
|
4069
|
-
dateRange: { start: /* @__PURE__ */ new Date(), end: /* @__PURE__ */ new Date() }
|
|
4070
|
-
},
|
|
4071
|
-
visualization: {
|
|
4072
|
-
groupBy: ["provider"],
|
|
4073
|
-
aggregation: "sum"
|
|
4074
|
-
}
|
|
4075
|
-
}
|
|
4076
|
-
];
|
|
4077
|
-
}
|
|
4078
|
-
};
|
|
4079
|
-
|
|
4080
|
-
// src/reports/export.manager.ts
|
|
4081
|
-
var ExportManager = class {
|
|
4082
|
-
config;
|
|
4083
|
-
exports = /* @__PURE__ */ new Map();
|
|
4084
|
-
exportQueue = [];
|
|
4085
|
-
defaultConfig = {
|
|
4086
|
-
formats: [
|
|
4087
|
-
{ type: "csv", options: { delimiter: ",", includeHeaders: true } },
|
|
4088
|
-
{ type: "json" },
|
|
4089
|
-
{ type: "pdf", options: { orientation: "portrait", pageSize: "A4" } }
|
|
4090
|
-
],
|
|
4091
|
-
maxFileSize: 50 * 1024 * 1024,
|
|
4092
|
-
// 50MB
|
|
4093
|
-
compressionEnabled: true
|
|
4094
|
-
};
|
|
4095
|
-
constructor(config = {}) {
|
|
4096
|
-
this.config = { ...this.defaultConfig, ...config };
|
|
4097
|
-
}
|
|
4098
|
-
/**
|
|
4099
|
-
* 분석 보고서 내보내기
|
|
4100
|
-
*/
|
|
4101
|
-
async exportReport(report, format, options = {}) {
|
|
4102
|
-
const exportId = this.generateExportId();
|
|
4103
|
-
try {
|
|
4104
|
-
let result;
|
|
4105
|
-
switch (format.type) {
|
|
4106
|
-
case "csv":
|
|
4107
|
-
result = await this.exportToCSV(report, { ...format.options, ...options }, exportId);
|
|
4108
|
-
break;
|
|
4109
|
-
case "excel":
|
|
4110
|
-
result = await this.exportToExcel(report, { ...format.options, ...options }, exportId);
|
|
4111
|
-
break;
|
|
4112
|
-
case "pdf":
|
|
4113
|
-
result = await this.exportToPDF(report, { ...format.options, ...options }, exportId);
|
|
4114
|
-
break;
|
|
4115
|
-
case "json":
|
|
4116
|
-
result = await this.exportToJSON(report, { ...format.options, ...options }, exportId);
|
|
4117
|
-
break;
|
|
4118
|
-
case "xml":
|
|
4119
|
-
result = await this.exportToXML(report, { ...format.options, ...options }, exportId);
|
|
4120
|
-
break;
|
|
4121
|
-
default:
|
|
4122
|
-
throw new Error(`Unsupported export format: ${format.type}`);
|
|
4123
|
-
}
|
|
4124
|
-
this.exports.set(exportId, result);
|
|
4125
|
-
return result;
|
|
4126
|
-
} catch (error) {
|
|
4127
|
-
throw new Error(`Export failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4128
|
-
}
|
|
4129
|
-
}
|
|
4130
|
-
/**
|
|
4131
|
-
* 메트릭 데이터 내보내기
|
|
4132
|
-
*/
|
|
4133
|
-
async exportMetrics(metrics, format, options = {}) {
|
|
4134
|
-
const exportId = this.generateExportId();
|
|
4135
|
-
try {
|
|
4136
|
-
let result;
|
|
4137
|
-
switch (format.type) {
|
|
4138
|
-
case "csv":
|
|
4139
|
-
result = await this.exportMetricsToCSV(metrics, { ...format.options, ...options }, exportId);
|
|
4140
|
-
break;
|
|
4141
|
-
case "json":
|
|
4142
|
-
result = await this.exportMetricsToJSON(metrics, { ...format.options, ...options }, exportId);
|
|
4143
|
-
break;
|
|
4144
|
-
default:
|
|
4145
|
-
throw new Error(`Metrics export not supported for format: ${format.type}`);
|
|
4146
|
-
}
|
|
4147
|
-
this.exports.set(exportId, result);
|
|
4148
|
-
return result;
|
|
4149
|
-
} catch (error) {
|
|
4150
|
-
throw new Error(`Metrics export failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4151
|
-
}
|
|
4152
|
-
}
|
|
4153
|
-
/**
|
|
4154
|
-
* 인사이트 데이터 내보내기
|
|
4155
|
-
*/
|
|
4156
|
-
async exportInsights(insights, format, options = {}) {
|
|
4157
|
-
const exportId = this.generateExportId();
|
|
4158
|
-
try {
|
|
4159
|
-
let result;
|
|
4160
|
-
switch (format.type) {
|
|
4161
|
-
case "csv":
|
|
4162
|
-
result = await this.exportInsightsToCSV(insights, { ...format.options, ...options }, exportId);
|
|
4163
|
-
break;
|
|
4164
|
-
case "json":
|
|
4165
|
-
result = await this.exportInsightsToJSON(insights, { ...format.options, ...options }, exportId);
|
|
4166
|
-
break;
|
|
4167
|
-
case "pdf":
|
|
4168
|
-
result = await this.exportInsightsToPDF(insights, { ...format.options, ...options }, exportId);
|
|
4169
|
-
break;
|
|
4170
|
-
default:
|
|
4171
|
-
throw new Error(`Insights export not supported for format: ${format.type}`);
|
|
4172
|
-
}
|
|
4173
|
-
this.exports.set(exportId, result);
|
|
4174
|
-
return result;
|
|
4175
|
-
} catch (error) {
|
|
4176
|
-
throw new Error(`Insights export failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4177
|
-
}
|
|
4178
|
-
}
|
|
4179
|
-
/**
|
|
4180
|
-
* 내보내기 상태 조회
|
|
4181
|
-
*/
|
|
4182
|
-
getExportStatus(exportId) {
|
|
4183
|
-
return this.exports.get(exportId) || null;
|
|
4184
|
-
}
|
|
4185
|
-
/**
|
|
4186
|
-
* 내보내기 목록 조회
|
|
4187
|
-
*/
|
|
4188
|
-
listExports(limit = 50) {
|
|
4189
|
-
const exports2 = Array.from(this.exports.values()).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
4190
|
-
return exports2.slice(0, limit);
|
|
4191
|
-
}
|
|
4192
|
-
/**
|
|
4193
|
-
* 내보내기 삭제
|
|
4194
|
-
*/
|
|
4195
|
-
deleteExport(exportId) {
|
|
4196
|
-
return this.exports.delete(exportId);
|
|
4197
|
-
}
|
|
4198
|
-
async exportToCSV(report, options, exportId) {
|
|
4199
|
-
const delimiter = options.delimiter || ",";
|
|
4200
|
-
const dateFormat = options.dateFormat || "yyyy-MM-dd HH:mm:ss";
|
|
4201
|
-
let csvContent = "";
|
|
4202
|
-
if (options.includeHeaders) {
|
|
4203
|
-
csvContent += ["Metric Type", "Value", "Change", "Trend", "Breakdown"].join(delimiter) + "\n";
|
|
4204
|
-
}
|
|
4205
|
-
for (const metric of report.metrics) {
|
|
4206
|
-
const row = [
|
|
4207
|
-
metric.type.toString(),
|
|
4208
|
-
metric.value.toString(),
|
|
4209
|
-
(metric.change || 0).toString(),
|
|
4210
|
-
metric.trend || "stable",
|
|
4211
|
-
metric.breakdown ? JSON.stringify(metric.breakdown) : ""
|
|
4212
|
-
];
|
|
4213
|
-
csvContent += row.map((cell) => `"${cell}"`).join(delimiter) + "\n";
|
|
4214
|
-
}
|
|
4215
|
-
const fileName = `report_${report.id}_${Date.now()}.csv`;
|
|
4216
|
-
const filePath = `/exports/${fileName}`;
|
|
4217
|
-
const fileSize = Buffer.byteLength(csvContent, "utf8");
|
|
4218
|
-
if (fileSize > this.config.maxFileSize) {
|
|
4219
|
-
throw new Error(`File size ${fileSize} exceeds maximum ${this.config.maxFileSize}`);
|
|
4220
|
-
}
|
|
4221
|
-
console.log(`Saving CSV to ${filePath}, size: ${fileSize} bytes`);
|
|
4222
|
-
return {
|
|
4223
|
-
id: exportId,
|
|
4224
|
-
format: "csv",
|
|
4225
|
-
fileName,
|
|
4226
|
-
filePath,
|
|
4227
|
-
fileSize,
|
|
4228
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4229
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4230
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4231
|
-
// 24시간
|
|
4232
|
-
};
|
|
4233
|
-
}
|
|
4234
|
-
async exportToExcel(report, options, exportId) {
|
|
4235
|
-
const worksheetData = {
|
|
4236
|
-
name: "Report",
|
|
4237
|
-
data: report.metrics.map((metric) => ({
|
|
4238
|
-
"Metric Type": metric.type,
|
|
4239
|
-
"Value": metric.value,
|
|
4240
|
-
"Change": metric.change || 0,
|
|
4241
|
-
"Trend": metric.trend || "stable"
|
|
4242
|
-
}))
|
|
4243
|
-
};
|
|
4244
|
-
const fileName = `report_${report.id}_${Date.now()}.xlsx`;
|
|
4245
|
-
const filePath = `/exports/${fileName}`;
|
|
4246
|
-
const fileSize = 10240;
|
|
4247
|
-
console.log(`Generating Excel file: ${fileName}`);
|
|
4248
|
-
return {
|
|
4249
|
-
id: exportId,
|
|
4250
|
-
format: "excel",
|
|
4251
|
-
fileName,
|
|
4252
|
-
filePath,
|
|
4253
|
-
fileSize,
|
|
4254
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4255
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4256
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4257
|
-
};
|
|
4258
|
-
}
|
|
4259
|
-
async exportToPDF(report, options, exportId) {
|
|
4260
|
-
const template = options.template || "standard";
|
|
4261
|
-
const orientation = options.orientation || "portrait";
|
|
4262
|
-
console.log(`Generating PDF report with template: ${template}, orientation: ${orientation}`);
|
|
4263
|
-
const fileName = `report_${report.id}_${Date.now()}.pdf`;
|
|
4264
|
-
const filePath = `/exports/${fileName}`;
|
|
4265
|
-
const fileSize = 25600;
|
|
4266
|
-
return {
|
|
4267
|
-
id: exportId,
|
|
4268
|
-
format: "pdf",
|
|
4269
|
-
fileName,
|
|
4270
|
-
filePath,
|
|
4271
|
-
fileSize,
|
|
4272
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4273
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4274
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4275
|
-
};
|
|
4276
|
-
}
|
|
4277
|
-
async exportToJSON(report, options, exportId) {
|
|
4278
|
-
const jsonContent = JSON.stringify(report, null, 2);
|
|
4279
|
-
const fileName = `report_${report.id}_${Date.now()}.json`;
|
|
4280
|
-
const filePath = `/exports/${fileName}`;
|
|
4281
|
-
const fileSize = Buffer.byteLength(jsonContent, "utf8");
|
|
4282
|
-
return {
|
|
4283
|
-
id: exportId,
|
|
4284
|
-
format: "json",
|
|
4285
|
-
fileName,
|
|
4286
|
-
filePath,
|
|
4287
|
-
fileSize,
|
|
4288
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4289
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4290
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4291
|
-
};
|
|
4292
|
-
}
|
|
4293
|
-
async exportToXML(report, options, exportId) {
|
|
4294
|
-
let xmlContent = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
4295
|
-
xmlContent += "<report>\n";
|
|
4296
|
-
xmlContent += ` <id>${report.id}</id>
|
|
4297
|
-
`;
|
|
4298
|
-
xmlContent += ` <name>${report.name}</name>
|
|
4299
|
-
`;
|
|
4300
|
-
xmlContent += ` <generatedAt>${report.generatedAt.toISOString()}</generatedAt>
|
|
4301
|
-
`;
|
|
4302
|
-
xmlContent += " <metrics>\n";
|
|
4303
|
-
for (const metric of report.metrics) {
|
|
4304
|
-
xmlContent += " <metric>\n";
|
|
4305
|
-
xmlContent += ` <type>${metric.type}</type>
|
|
4306
|
-
`;
|
|
4307
|
-
xmlContent += ` <value>${metric.value}</value>
|
|
4308
|
-
`;
|
|
4309
|
-
xmlContent += ` <change>${metric.change || 0}</change>
|
|
4310
|
-
`;
|
|
4311
|
-
xmlContent += ` <trend>${metric.trend || "stable"}</trend>
|
|
4312
|
-
`;
|
|
4313
|
-
xmlContent += " </metric>\n";
|
|
4314
|
-
}
|
|
4315
|
-
xmlContent += " </metrics>\n";
|
|
4316
|
-
xmlContent += "</report>\n";
|
|
4317
|
-
const fileName = `report_${report.id}_${Date.now()}.xml`;
|
|
4318
|
-
const filePath = `/exports/${fileName}`;
|
|
4319
|
-
const fileSize = Buffer.byteLength(xmlContent, "utf8");
|
|
4320
|
-
return {
|
|
4321
|
-
id: exportId,
|
|
4322
|
-
format: "xml",
|
|
4323
|
-
fileName,
|
|
4324
|
-
filePath,
|
|
4325
|
-
fileSize,
|
|
4326
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4327
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4328
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4329
|
-
};
|
|
4330
|
-
}
|
|
4331
|
-
async exportMetricsToCSV(metrics, options, exportId) {
|
|
4332
|
-
const delimiter = options.delimiter || ",";
|
|
4333
|
-
let csvContent = "";
|
|
4334
|
-
if (options.includeHeaders) {
|
|
4335
|
-
csvContent += [
|
|
4336
|
-
"Timestamp",
|
|
4337
|
-
"Type",
|
|
4338
|
-
"Interval",
|
|
4339
|
-
"Count",
|
|
4340
|
-
"Sum",
|
|
4341
|
-
"Average",
|
|
4342
|
-
"Min",
|
|
4343
|
-
"Max",
|
|
4344
|
-
"Dimensions"
|
|
4345
|
-
].join(delimiter) + "\n";
|
|
4346
|
-
}
|
|
4347
|
-
for (const metric of metrics) {
|
|
4348
|
-
const row = [
|
|
4349
|
-
metric.timestamp.toISOString(),
|
|
4350
|
-
metric.type.toString(),
|
|
4351
|
-
metric.interval,
|
|
4352
|
-
metric.aggregations.count.toString(),
|
|
4353
|
-
metric.aggregations.sum.toString(),
|
|
4354
|
-
metric.aggregations.avg.toString(),
|
|
4355
|
-
metric.aggregations.min.toString(),
|
|
4356
|
-
metric.aggregations.max.toString(),
|
|
4357
|
-
JSON.stringify(metric.dimensions)
|
|
4358
|
-
];
|
|
4359
|
-
csvContent += row.map((cell) => `"${cell}"`).join(delimiter) + "\n";
|
|
4360
|
-
}
|
|
4361
|
-
const fileName = `metrics_${Date.now()}.csv`;
|
|
4362
|
-
const filePath = `/exports/${fileName}`;
|
|
4363
|
-
const fileSize = Buffer.byteLength(csvContent, "utf8");
|
|
4364
|
-
return {
|
|
4365
|
-
id: exportId,
|
|
4366
|
-
format: "csv",
|
|
4367
|
-
fileName,
|
|
4368
|
-
filePath,
|
|
4369
|
-
fileSize,
|
|
4370
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4371
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4372
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4373
|
-
};
|
|
4374
|
-
}
|
|
4375
|
-
async exportMetricsToJSON(metrics, options, exportId) {
|
|
4376
|
-
const jsonContent = JSON.stringify({
|
|
4377
|
-
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4378
|
-
count: metrics.length,
|
|
4379
|
-
metrics
|
|
4380
|
-
}, null, 2);
|
|
4381
|
-
const fileName = `metrics_${Date.now()}.json`;
|
|
4382
|
-
const filePath = `/exports/${fileName}`;
|
|
4383
|
-
const fileSize = Buffer.byteLength(jsonContent, "utf8");
|
|
4384
|
-
return {
|
|
4385
|
-
id: exportId,
|
|
4386
|
-
format: "json",
|
|
4387
|
-
fileName,
|
|
4388
|
-
filePath,
|
|
4389
|
-
fileSize,
|
|
4390
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4391
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4392
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4393
|
-
};
|
|
4394
|
-
}
|
|
4395
|
-
async exportInsightsToCSV(insights, options, exportId) {
|
|
4396
|
-
const delimiter = options.delimiter || ",";
|
|
4397
|
-
let csvContent = "";
|
|
4398
|
-
if (options.includeHeaders) {
|
|
4399
|
-
csvContent += [
|
|
4400
|
-
"ID",
|
|
4401
|
-
"Type",
|
|
4402
|
-
"Title",
|
|
4403
|
-
"Description",
|
|
4404
|
-
"Severity",
|
|
4405
|
-
"Metric",
|
|
4406
|
-
"Value",
|
|
4407
|
-
"Confidence",
|
|
4408
|
-
"Detected At",
|
|
4409
|
-
"Recommendations"
|
|
4410
|
-
].join(delimiter) + "\n";
|
|
4411
|
-
}
|
|
4412
|
-
for (const insight of insights) {
|
|
4413
|
-
const row = [
|
|
4414
|
-
insight.id,
|
|
4415
|
-
insight.type,
|
|
4416
|
-
insight.title,
|
|
4417
|
-
insight.description,
|
|
4418
|
-
insight.severity,
|
|
4419
|
-
insight.metric.toString(),
|
|
4420
|
-
insight.value.toString(),
|
|
4421
|
-
insight.confidence.toString(),
|
|
4422
|
-
insight.detectedAt.toISOString(),
|
|
4423
|
-
(insight.recommendations || []).join("; ")
|
|
4424
|
-
];
|
|
4425
|
-
csvContent += row.map((cell) => `"${cell}"`).join(delimiter) + "\n";
|
|
4426
|
-
}
|
|
4427
|
-
const fileName = `insights_${Date.now()}.csv`;
|
|
4428
|
-
const filePath = `/exports/${fileName}`;
|
|
4429
|
-
const fileSize = Buffer.byteLength(csvContent, "utf8");
|
|
4430
|
-
return {
|
|
4431
|
-
id: exportId,
|
|
4432
|
-
format: "csv",
|
|
4433
|
-
fileName,
|
|
4434
|
-
filePath,
|
|
4435
|
-
fileSize,
|
|
4436
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4437
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4438
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4439
|
-
};
|
|
4440
|
-
}
|
|
4441
|
-
async exportInsightsToJSON(insights, options, exportId) {
|
|
4442
|
-
const jsonContent = JSON.stringify({
|
|
4443
|
-
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4444
|
-
count: insights.length,
|
|
4445
|
-
insights
|
|
4446
|
-
}, null, 2);
|
|
4447
|
-
const fileName = `insights_${Date.now()}.json`;
|
|
4448
|
-
const filePath = `/exports/${fileName}`;
|
|
4449
|
-
const fileSize = Buffer.byteLength(jsonContent, "utf8");
|
|
4450
|
-
return {
|
|
4451
|
-
id: exportId,
|
|
4452
|
-
format: "json",
|
|
4453
|
-
fileName,
|
|
4454
|
-
filePath,
|
|
4455
|
-
fileSize,
|
|
4456
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4457
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4458
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4459
|
-
};
|
|
4460
|
-
}
|
|
4461
|
-
async exportInsightsToPDF(insights, options, exportId) {
|
|
4462
|
-
console.log(`Generating insights PDF with ${insights.length} insights`);
|
|
4463
|
-
const fileName = `insights_${Date.now()}.pdf`;
|
|
4464
|
-
const filePath = `/exports/${fileName}`;
|
|
4465
|
-
const fileSize = 15360;
|
|
4466
|
-
return {
|
|
4467
|
-
id: exportId,
|
|
4468
|
-
format: "pdf",
|
|
4469
|
-
fileName,
|
|
4470
|
-
filePath,
|
|
4471
|
-
fileSize,
|
|
4472
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
4473
|
-
downloadUrl: `/api/exports/${exportId}/download`,
|
|
4474
|
-
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3)
|
|
4475
|
-
};
|
|
4476
|
-
}
|
|
4477
|
-
generateExportId() {
|
|
4478
|
-
return `export_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
4479
|
-
}
|
|
4480
|
-
};
|
|
4481
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
4482
|
-
0 && (module.exports = {
|
|
4483
|
-
AnalyticsService,
|
|
4484
|
-
AnomalyDetector,
|
|
4485
|
-
DashboardGenerator,
|
|
4486
|
-
EventCollector,
|
|
4487
|
-
ExportManager,
|
|
4488
|
-
InsightEngine,
|
|
4489
|
-
MetricAggregator,
|
|
4490
|
-
MetricType,
|
|
4491
|
-
MetricsCollector,
|
|
4492
|
-
RecommendationEngine,
|
|
4493
|
-
ReportGenerator,
|
|
4494
|
-
TimeSeriesAggregator,
|
|
4495
|
-
WebhookCollector
|
|
4496
|
-
});
|
|
4497
|
-
//# sourceMappingURL=index.cjs.map
|