@anyshift/mcp-proxy 0.3.6 → 0.4.0
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/__tests__/unit/timeseries.test.d.ts +1 -0
- package/dist/__tests__/unit/timeseries.test.js +217 -0
- package/dist/fileWriter/writer.js +1 -1
- package/dist/index.js +53 -2
- package/dist/jq/handler.js +11 -56
- package/dist/timeseries/algorithms/index.d.ts +8 -0
- package/dist/timeseries/algorithms/index.js +8 -0
- package/dist/timeseries/algorithms/mad.d.ts +15 -0
- package/dist/timeseries/algorithms/mad.js +44 -0
- package/dist/timeseries/algorithms/moving-average.d.ts +15 -0
- package/dist/timeseries/algorithms/moving-average.js +72 -0
- package/dist/timeseries/algorithms/rolling-quantile.d.ts +16 -0
- package/dist/timeseries/algorithms/rolling-quantile.js +78 -0
- package/dist/timeseries/algorithms/stats.d.ts +49 -0
- package/dist/timeseries/algorithms/stats.js +139 -0
- package/dist/timeseries/algorithms/threshold.d.ts +15 -0
- package/dist/timeseries/algorithms/threshold.js +49 -0
- package/dist/timeseries/handler.d.ts +10 -0
- package/dist/timeseries/handler.js +292 -0
- package/dist/timeseries/index.d.ts +68 -0
- package/dist/timeseries/index.js +26 -0
- package/dist/timeseries/tool.d.ts +71 -0
- package/dist/timeseries/tool.js +170 -0
- package/dist/timeseries/types.d.ts +147 -0
- package/dist/timeseries/types.js +4 -0
- package/dist/utils/jq.d.ts +25 -0
- package/dist/utils/jq.js +90 -0
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling Quantile based anomaly detection (similar to Datadog's Basic algorithm)
|
|
3
|
+
*
|
|
4
|
+
* Uses a sliding window to compute rolling percentiles, then flags points
|
|
5
|
+
* outside the expected range. This is non-parametric and works well for
|
|
6
|
+
* any distribution without assuming normality.
|
|
7
|
+
*/
|
|
8
|
+
import { rollingQuantiles } from './stats.js';
|
|
9
|
+
const DEFAULT_LOWER_QUANTILE = 0.05;
|
|
10
|
+
const DEFAULT_UPPER_QUANTILE = 0.95;
|
|
11
|
+
/**
|
|
12
|
+
* Detect anomalies using Rolling Quantile method
|
|
13
|
+
*
|
|
14
|
+
* @param data - Array of data points (should be in time order)
|
|
15
|
+
* @param params - Rolling Quantile parameters
|
|
16
|
+
* @returns Array of detected anomalies
|
|
17
|
+
*/
|
|
18
|
+
export function detectRollingQuantileAnomalies(data, params) {
|
|
19
|
+
const { window_size: windowSize, lower_quantile: lowerQuantile = DEFAULT_LOWER_QUANTILE, upper_quantile: upperQuantile = DEFAULT_UPPER_QUANTILE, } = params;
|
|
20
|
+
if (windowSize <= 0) {
|
|
21
|
+
throw new Error('window_size must be greater than 0');
|
|
22
|
+
}
|
|
23
|
+
if (windowSize > data.length) {
|
|
24
|
+
throw new Error(`window_size (${windowSize}) cannot be larger than data length (${data.length})`);
|
|
25
|
+
}
|
|
26
|
+
if (lowerQuantile < 0 || lowerQuantile > 1) {
|
|
27
|
+
throw new Error('lower_quantile must be between 0 and 1');
|
|
28
|
+
}
|
|
29
|
+
if (upperQuantile < 0 || upperQuantile > 1) {
|
|
30
|
+
throw new Error('upper_quantile must be between 0 and 1');
|
|
31
|
+
}
|
|
32
|
+
if (lowerQuantile >= upperQuantile) {
|
|
33
|
+
throw new Error('lower_quantile must be less than upper_quantile');
|
|
34
|
+
}
|
|
35
|
+
const values = data.map((d) => d.value);
|
|
36
|
+
const { lower, upper } = rollingQuantiles(values, windowSize, lowerQuantile, upperQuantile);
|
|
37
|
+
const anomalies = [];
|
|
38
|
+
for (let i = 0; i < data.length; i++) {
|
|
39
|
+
// Skip points where we don't have enough data for the window
|
|
40
|
+
if (isNaN(lower[i]) || isNaN(upper[i])) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const point = data[i];
|
|
44
|
+
const lowerBound = lower[i];
|
|
45
|
+
const upperBound = upper[i];
|
|
46
|
+
if (point.value < lowerBound) {
|
|
47
|
+
anomalies.push({
|
|
48
|
+
index: point.index,
|
|
49
|
+
timestamp: point.timestamp,
|
|
50
|
+
value: point.value,
|
|
51
|
+
reason: `Value ${point.value.toFixed(2)} below rolling ${(lowerQuantile * 100).toFixed(0)}th percentile ${lowerBound.toFixed(2)}`,
|
|
52
|
+
metadata: {
|
|
53
|
+
lower_bound: lowerBound,
|
|
54
|
+
upper_bound: upperBound,
|
|
55
|
+
lower_quantile: lowerQuantile,
|
|
56
|
+
upper_quantile: upperQuantile,
|
|
57
|
+
window_size: windowSize,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (point.value > upperBound) {
|
|
62
|
+
anomalies.push({
|
|
63
|
+
index: point.index,
|
|
64
|
+
timestamp: point.timestamp,
|
|
65
|
+
value: point.value,
|
|
66
|
+
reason: `Value ${point.value.toFixed(2)} above rolling ${(upperQuantile * 100).toFixed(0)}th percentile ${upperBound.toFixed(2)}`,
|
|
67
|
+
metadata: {
|
|
68
|
+
lower_bound: lowerBound,
|
|
69
|
+
upper_bound: upperBound,
|
|
70
|
+
lower_quantile: lowerQuantile,
|
|
71
|
+
upper_quantile: upperQuantile,
|
|
72
|
+
window_size: windowSize,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return anomalies;
|
|
78
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistical utility functions for anomaly detection
|
|
3
|
+
* Implemented in pure TypeScript for zero dependencies and fast execution
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Calculate the mean (average) of an array of numbers
|
|
7
|
+
*/
|
|
8
|
+
export declare function mean(values: number[]): number;
|
|
9
|
+
/**
|
|
10
|
+
* Calculate the median of an array of numbers
|
|
11
|
+
*/
|
|
12
|
+
export declare function median(values: number[]): number;
|
|
13
|
+
/**
|
|
14
|
+
* Calculate the standard deviation of an array of numbers
|
|
15
|
+
*/
|
|
16
|
+
export declare function standardDeviation(values: number[]): number;
|
|
17
|
+
/**
|
|
18
|
+
* Calculate the Median Absolute Deviation (MAD)
|
|
19
|
+
* MAD = median(|Xi - median(X)|)
|
|
20
|
+
*/
|
|
21
|
+
export declare function medianAbsoluteDeviation(values: number[]): number;
|
|
22
|
+
/**
|
|
23
|
+
* Calculate modified Z-score using MAD
|
|
24
|
+
* Modified Z-score = 0.6745 * (Xi - median) / MAD
|
|
25
|
+
* The constant 0.6745 makes it comparable to standard Z-scores for normal distributions
|
|
26
|
+
*/
|
|
27
|
+
export declare function modifiedZScore(value: number, med: number, mad: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Calculate a simple moving average for an array at each position
|
|
30
|
+
* Returns an array of the same length, with NaN for positions where window is incomplete
|
|
31
|
+
*/
|
|
32
|
+
export declare function movingAverage(values: number[], windowSize: number): number[];
|
|
33
|
+
/**
|
|
34
|
+
* Calculate the standard deviation of a moving window at each position
|
|
35
|
+
*/
|
|
36
|
+
export declare function movingStandardDeviation(values: number[], windowSize: number): number[];
|
|
37
|
+
/**
|
|
38
|
+
* Calculate a specific quantile (percentile) of an array
|
|
39
|
+
* Uses linear interpolation between closest ranks
|
|
40
|
+
*/
|
|
41
|
+
export declare function quantile(values: number[], q: number): number;
|
|
42
|
+
/**
|
|
43
|
+
* Calculate rolling quantiles (lower and upper bounds) at each position
|
|
44
|
+
* Returns arrays of the same length as input, with NaN for positions where window is incomplete
|
|
45
|
+
*/
|
|
46
|
+
export declare function rollingQuantiles(values: number[], windowSize: number, lowerQuantile: number, upperQuantile: number): {
|
|
47
|
+
lower: number[];
|
|
48
|
+
upper: number[];
|
|
49
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistical utility functions for anomaly detection
|
|
3
|
+
* Implemented in pure TypeScript for zero dependencies and fast execution
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Calculate the mean (average) of an array of numbers
|
|
7
|
+
*/
|
|
8
|
+
export function mean(values) {
|
|
9
|
+
if (values.length === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Calculate the median of an array of numbers
|
|
15
|
+
*/
|
|
16
|
+
export function median(values) {
|
|
17
|
+
if (values.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
20
|
+
const mid = Math.floor(sorted.length / 2);
|
|
21
|
+
return sorted.length % 2 !== 0
|
|
22
|
+
? sorted[mid]
|
|
23
|
+
: (sorted[mid - 1] + sorted[mid]) / 2;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Calculate the standard deviation of an array of numbers
|
|
27
|
+
*/
|
|
28
|
+
export function standardDeviation(values) {
|
|
29
|
+
if (values.length < 2)
|
|
30
|
+
return 0;
|
|
31
|
+
const avg = mean(values);
|
|
32
|
+
const squaredDiffs = values.map((v) => Math.pow(v - avg, 2));
|
|
33
|
+
return Math.sqrt(mean(squaredDiffs));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Calculate the Median Absolute Deviation (MAD)
|
|
37
|
+
* MAD = median(|Xi - median(X)|)
|
|
38
|
+
*/
|
|
39
|
+
export function medianAbsoluteDeviation(values) {
|
|
40
|
+
if (values.length === 0)
|
|
41
|
+
return 0;
|
|
42
|
+
const med = median(values);
|
|
43
|
+
const absoluteDeviations = values.map((v) => Math.abs(v - med));
|
|
44
|
+
return median(absoluteDeviations);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Calculate modified Z-score using MAD
|
|
48
|
+
* Modified Z-score = 0.6745 * (Xi - median) / MAD
|
|
49
|
+
* The constant 0.6745 makes it comparable to standard Z-scores for normal distributions
|
|
50
|
+
*/
|
|
51
|
+
export function modifiedZScore(value, med, mad) {
|
|
52
|
+
if (mad === 0)
|
|
53
|
+
return 0;
|
|
54
|
+
return (0.6745 * (value - med)) / mad;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Calculate a simple moving average for an array at each position
|
|
58
|
+
* Returns an array of the same length, with NaN for positions where window is incomplete
|
|
59
|
+
*/
|
|
60
|
+
export function movingAverage(values, windowSize) {
|
|
61
|
+
if (windowSize <= 0 || values.length === 0)
|
|
62
|
+
return [];
|
|
63
|
+
const result = [];
|
|
64
|
+
for (let i = 0; i < values.length; i++) {
|
|
65
|
+
if (i < windowSize - 1) {
|
|
66
|
+
// Not enough data points yet
|
|
67
|
+
result.push(NaN);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Calculate average of the window
|
|
71
|
+
const window = values.slice(i - windowSize + 1, i + 1);
|
|
72
|
+
result.push(mean(window));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Calculate the standard deviation of a moving window at each position
|
|
79
|
+
*/
|
|
80
|
+
export function movingStandardDeviation(values, windowSize) {
|
|
81
|
+
if (windowSize <= 0 || values.length === 0)
|
|
82
|
+
return [];
|
|
83
|
+
const result = [];
|
|
84
|
+
for (let i = 0; i < values.length; i++) {
|
|
85
|
+
if (i < windowSize - 1) {
|
|
86
|
+
result.push(NaN);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const window = values.slice(i - windowSize + 1, i + 1);
|
|
90
|
+
result.push(standardDeviation(window));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Calculate a specific quantile (percentile) of an array
|
|
97
|
+
* Uses linear interpolation between closest ranks
|
|
98
|
+
*/
|
|
99
|
+
export function quantile(values, q) {
|
|
100
|
+
if (values.length === 0)
|
|
101
|
+
return 0;
|
|
102
|
+
if (q <= 0)
|
|
103
|
+
return Math.min(...values);
|
|
104
|
+
if (q >= 1)
|
|
105
|
+
return Math.max(...values);
|
|
106
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
107
|
+
const pos = (sorted.length - 1) * q;
|
|
108
|
+
const lower = Math.floor(pos);
|
|
109
|
+
const upper = Math.ceil(pos);
|
|
110
|
+
const weight = pos - lower;
|
|
111
|
+
if (upper >= sorted.length)
|
|
112
|
+
return sorted[sorted.length - 1];
|
|
113
|
+
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Calculate rolling quantiles (lower and upper bounds) at each position
|
|
117
|
+
* Returns arrays of the same length as input, with NaN for positions where window is incomplete
|
|
118
|
+
*/
|
|
119
|
+
export function rollingQuantiles(values, windowSize, lowerQuantile, upperQuantile) {
|
|
120
|
+
if (windowSize <= 0 || values.length === 0) {
|
|
121
|
+
return { lower: [], upper: [] };
|
|
122
|
+
}
|
|
123
|
+
const lower = [];
|
|
124
|
+
const upper = [];
|
|
125
|
+
for (let i = 0; i < values.length; i++) {
|
|
126
|
+
if (i < windowSize - 1) {
|
|
127
|
+
// Not enough data points yet
|
|
128
|
+
lower.push(NaN);
|
|
129
|
+
upper.push(NaN);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// Calculate quantiles of the window
|
|
133
|
+
const window = values.slice(i - windowSize + 1, i + 1);
|
|
134
|
+
lower.push(quantile(window, lowerQuantile));
|
|
135
|
+
upper.push(quantile(window, upperQuantile));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { lower, upper };
|
|
139
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threshold-based anomaly detection
|
|
3
|
+
*
|
|
4
|
+
* Detects points that cross one or more named thresholds.
|
|
5
|
+
* Supports multiple thresholds with names and directions (upper/lower).
|
|
6
|
+
*/
|
|
7
|
+
import type { DataPoint, Anomaly, ThresholdParams } from '../types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Detect anomalies using threshold method
|
|
10
|
+
*
|
|
11
|
+
* @param data - Array of data points
|
|
12
|
+
* @param params - Threshold parameters with named thresholds
|
|
13
|
+
* @returns Array of detected anomalies
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectThresholdAnomalies(data: DataPoint[], params: ThresholdParams): Anomaly[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Threshold-based anomaly detection
|
|
3
|
+
*
|
|
4
|
+
* Detects points that cross one or more named thresholds.
|
|
5
|
+
* Supports multiple thresholds with names and directions (upper/lower).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Detect anomalies using threshold method
|
|
9
|
+
*
|
|
10
|
+
* @param data - Array of data points
|
|
11
|
+
* @param params - Threshold parameters with named thresholds
|
|
12
|
+
* @returns Array of detected anomalies
|
|
13
|
+
*/
|
|
14
|
+
export function detectThresholdAnomalies(data, params) {
|
|
15
|
+
const { thresholds } = params;
|
|
16
|
+
if (!thresholds || thresholds.length === 0) {
|
|
17
|
+
throw new Error('At least one threshold must be specified');
|
|
18
|
+
}
|
|
19
|
+
const anomalies = [];
|
|
20
|
+
for (const point of data) {
|
|
21
|
+
const crossedThresholds = [];
|
|
22
|
+
for (const threshold of thresholds) {
|
|
23
|
+
if (threshold.direction === 'upper' && point.value > threshold.value) {
|
|
24
|
+
crossedThresholds.push(threshold);
|
|
25
|
+
}
|
|
26
|
+
else if (threshold.direction === 'lower' && point.value < threshold.value) {
|
|
27
|
+
crossedThresholds.push(threshold);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (crossedThresholds.length > 0) {
|
|
31
|
+
const thresholdNames = crossedThresholds.map((t) => t.name);
|
|
32
|
+
const thresholdDetails = crossedThresholds
|
|
33
|
+
.map((t) => `${t.name} (${t.direction}: ${t.value})`)
|
|
34
|
+
.join(', ');
|
|
35
|
+
anomalies.push({
|
|
36
|
+
index: point.index,
|
|
37
|
+
timestamp: point.timestamp,
|
|
38
|
+
value: point.value,
|
|
39
|
+
reason: `Value ${point.value} crossed threshold(s): ${thresholdDetails}`,
|
|
40
|
+
crossed_thresholds: thresholdNames,
|
|
41
|
+
metadata: crossedThresholds.reduce((acc, t) => {
|
|
42
|
+
acc[`threshold_${t.name}`] = t.value;
|
|
43
|
+
return acc;
|
|
44
|
+
}, {}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return anomalies;
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TimeseriesConfig, DetectionType, DetectionParams } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Main handler for time series anomaly detection
|
|
4
|
+
*/
|
|
5
|
+
export declare function detectTimeseriesAnomalies(config: TimeseriesConfig, filePath: string, jqQuery: string, valueField: string, detectionType: DetectionType, detectionParams?: DetectionParams, timestampField?: string): Promise<{
|
|
6
|
+
content: Array<{
|
|
7
|
+
type: 'text';
|
|
8
|
+
text: string;
|
|
9
|
+
}>;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { validatePathWithinAllowedDirs } from '../utils/pathValidation.js';
|
|
4
|
+
import { validateJqQuery, runJqParsed } from '../utils/jq.js';
|
|
5
|
+
import { detectMadAnomalies, detectMovingAverageAnomalies, detectThresholdAnomalies, detectRollingQuantileAnomalies, } from './algorithms/index.js';
|
|
6
|
+
// Default timeout for jq execution
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
8
|
+
/**
|
|
9
|
+
* Convert a timestamp to UTC time string
|
|
10
|
+
* Handles Unix timestamps (seconds or milliseconds) and ISO strings
|
|
11
|
+
*/
|
|
12
|
+
function toUtcTime(timestamp) {
|
|
13
|
+
if (timestamp === undefined || timestamp === null) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
let date;
|
|
17
|
+
if (typeof timestamp === 'number') {
|
|
18
|
+
// Detect if timestamp is in seconds or milliseconds
|
|
19
|
+
// Unix timestamps in seconds are typically 10 digits (until 2286)
|
|
20
|
+
// Milliseconds are 13 digits
|
|
21
|
+
if (timestamp > 1e12) {
|
|
22
|
+
// Likely milliseconds
|
|
23
|
+
date = new Date(timestamp);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// Likely seconds
|
|
27
|
+
date = new Date(timestamp * 1000);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else if (typeof timestamp === 'string') {
|
|
31
|
+
// Try parsing as number first (string representation of Unix timestamp)
|
|
32
|
+
const numericTimestamp = Number(timestamp);
|
|
33
|
+
if (!isNaN(numericTimestamp)) {
|
|
34
|
+
if (numericTimestamp > 1e12) {
|
|
35
|
+
date = new Date(numericTimestamp);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
date = new Date(numericTimestamp * 1000);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// Try parsing as ISO string or other date format
|
|
43
|
+
date = new Date(timestamp);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
// Check if date is valid
|
|
50
|
+
if (isNaN(date.getTime())) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return date.toISOString();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Pool consecutive anomalies together to reduce output size
|
|
57
|
+
* Anomalies are considered consecutive if their indices differ by 1
|
|
58
|
+
*/
|
|
59
|
+
function poolAnomalies(anomalies) {
|
|
60
|
+
if (anomalies.length === 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
// Sort anomalies by index to ensure proper grouping
|
|
64
|
+
const sorted = [...anomalies].sort((a, b) => a.index - b.index);
|
|
65
|
+
const pools = [];
|
|
66
|
+
let currentPool = [sorted[0]];
|
|
67
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
68
|
+
const current = sorted[i];
|
|
69
|
+
const previous = sorted[i - 1];
|
|
70
|
+
// Check if consecutive (index differs by 1)
|
|
71
|
+
if (current.index === previous.index + 1) {
|
|
72
|
+
currentPool.push(current);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
// Finalize current pool and start new one
|
|
76
|
+
pools.push(createPooledAnomaly(currentPool));
|
|
77
|
+
currentPool = [current];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Don't forget the last pool
|
|
81
|
+
pools.push(createPooledAnomaly(currentPool));
|
|
82
|
+
return pools;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Create a PooledAnomaly from a group of consecutive Anomaly objects
|
|
86
|
+
*/
|
|
87
|
+
function createPooledAnomaly(anomalies) {
|
|
88
|
+
const first = anomalies[0];
|
|
89
|
+
const last = anomalies[anomalies.length - 1];
|
|
90
|
+
const values = anomalies.map((a) => a.value);
|
|
91
|
+
const minValue = Math.min(...values);
|
|
92
|
+
const maxValue = Math.max(...values);
|
|
93
|
+
const avgValue = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
94
|
+
// Collect all crossed thresholds (union)
|
|
95
|
+
const allThresholds = new Set();
|
|
96
|
+
for (const a of anomalies) {
|
|
97
|
+
if (a.crossed_thresholds) {
|
|
98
|
+
for (const t of a.crossed_thresholds) {
|
|
99
|
+
allThresholds.add(t);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Generate summary reason
|
|
104
|
+
let reason;
|
|
105
|
+
if (anomalies.length === 1) {
|
|
106
|
+
reason = first.reason;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Extract the core reason pattern from the first anomaly
|
|
110
|
+
const baseReason = first.reason;
|
|
111
|
+
if (allThresholds.size > 0) {
|
|
112
|
+
reason = `${anomalies.length} consecutive points crossing threshold(s): ${Array.from(allThresholds).join(', ')}`;
|
|
113
|
+
}
|
|
114
|
+
else if (baseReason.includes('percentile')) {
|
|
115
|
+
reason = `${anomalies.length} consecutive points outside rolling quantile bounds`;
|
|
116
|
+
}
|
|
117
|
+
else if (baseReason.includes('Modified Z-score')) {
|
|
118
|
+
reason = `${anomalies.length} consecutive points with high MAD deviation`;
|
|
119
|
+
}
|
|
120
|
+
else if (baseReason.includes('moving average')) {
|
|
121
|
+
reason = `${anomalies.length} consecutive points deviating from moving average`;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
reason = `${anomalies.length} consecutive anomalous points`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const pooled = {
|
|
128
|
+
start_index: first.index,
|
|
129
|
+
end_index: last.index,
|
|
130
|
+
count: anomalies.length,
|
|
131
|
+
min_value: minValue,
|
|
132
|
+
max_value: maxValue,
|
|
133
|
+
avg_value: Math.round(avgValue * 100) / 100, // Round to 2 decimal places
|
|
134
|
+
reason,
|
|
135
|
+
};
|
|
136
|
+
// Add timestamps if available
|
|
137
|
+
if (first.timestamp !== undefined) {
|
|
138
|
+
pooled.start_timestamp = first.timestamp;
|
|
139
|
+
const startUtc = toUtcTime(first.timestamp);
|
|
140
|
+
if (startUtc) {
|
|
141
|
+
pooled.start_utc = startUtc;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (last.timestamp !== undefined) {
|
|
145
|
+
pooled.end_timestamp = last.timestamp;
|
|
146
|
+
const endUtc = toUtcTime(last.timestamp);
|
|
147
|
+
if (endUtc) {
|
|
148
|
+
pooled.end_utc = endUtc;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Add crossed thresholds if any
|
|
152
|
+
if (allThresholds.size > 0) {
|
|
153
|
+
pooled.crossed_thresholds = Array.from(allThresholds);
|
|
154
|
+
}
|
|
155
|
+
return pooled;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Validate that the jq output is tabular (array of objects)
|
|
159
|
+
*/
|
|
160
|
+
function validateTabularData(data) {
|
|
161
|
+
if (!Array.isArray(data)) {
|
|
162
|
+
throw new Error(`jq query must return an array, but got ${typeof data}. ` +
|
|
163
|
+
'Wrap your query in brackets if needed: [.[] | ...]');
|
|
164
|
+
}
|
|
165
|
+
if (data.length === 0) {
|
|
166
|
+
throw new Error('jq query returned an empty array. No data to analyze.');
|
|
167
|
+
}
|
|
168
|
+
// Check first few items are objects
|
|
169
|
+
const sampleSize = Math.min(5, data.length);
|
|
170
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
171
|
+
if (typeof data[i] !== 'object' || data[i] === null || Array.isArray(data[i])) {
|
|
172
|
+
throw new Error(`jq query must return an array of objects, but item ${i} is ${typeof data[i]}. ` +
|
|
173
|
+
'Each item should be an object with fields.');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Extract data points from tabular data
|
|
179
|
+
*/
|
|
180
|
+
function extractDataPoints(data, valueField, timestampField) {
|
|
181
|
+
const points = [];
|
|
182
|
+
for (let i = 0; i < data.length; i++) {
|
|
183
|
+
const item = data[i];
|
|
184
|
+
const rawValue = item[valueField];
|
|
185
|
+
// Skip items without the value field or with null values
|
|
186
|
+
if (rawValue === undefined || rawValue === null) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// Convert to number
|
|
190
|
+
const value = Number(rawValue);
|
|
191
|
+
if (isNaN(value)) {
|
|
192
|
+
throw new Error(`Value at index ${i} is not a valid number: ${JSON.stringify(rawValue)}. ` +
|
|
193
|
+
`Field "${valueField}" must contain numeric values.`);
|
|
194
|
+
}
|
|
195
|
+
const point = {
|
|
196
|
+
index: i,
|
|
197
|
+
value,
|
|
198
|
+
};
|
|
199
|
+
if (timestampField && item[timestampField] !== undefined) {
|
|
200
|
+
const ts = item[timestampField];
|
|
201
|
+
point.timestamp = typeof ts === 'string' || typeof ts === 'number' ? ts : String(ts);
|
|
202
|
+
}
|
|
203
|
+
points.push(point);
|
|
204
|
+
}
|
|
205
|
+
if (points.length === 0) {
|
|
206
|
+
throw new Error(`No valid numeric values found in field "${valueField}". ` +
|
|
207
|
+
'Check that the field exists and contains numbers.');
|
|
208
|
+
}
|
|
209
|
+
return points;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Run the appropriate detection algorithm
|
|
213
|
+
*/
|
|
214
|
+
function runDetection(data, detectionType, params) {
|
|
215
|
+
let rawAnomalies;
|
|
216
|
+
switch (detectionType) {
|
|
217
|
+
case 'rolling_quantile':
|
|
218
|
+
if (!params || !('window_size' in params)) {
|
|
219
|
+
throw new Error('rolling_quantile detection requires window_size parameter');
|
|
220
|
+
}
|
|
221
|
+
rawAnomalies = detectRollingQuantileAnomalies(data, params);
|
|
222
|
+
break;
|
|
223
|
+
case 'mad':
|
|
224
|
+
rawAnomalies = detectMadAnomalies(data, params);
|
|
225
|
+
break;
|
|
226
|
+
case 'moving_average':
|
|
227
|
+
if (!params || !('window_size' in params)) {
|
|
228
|
+
throw new Error('moving_average detection requires window_size parameter');
|
|
229
|
+
}
|
|
230
|
+
rawAnomalies = detectMovingAverageAnomalies(data, params);
|
|
231
|
+
break;
|
|
232
|
+
case 'threshold':
|
|
233
|
+
if (!params || !('thresholds' in params)) {
|
|
234
|
+
throw new Error('threshold detection requires thresholds parameter');
|
|
235
|
+
}
|
|
236
|
+
rawAnomalies = detectThresholdAnomalies(data, params);
|
|
237
|
+
break;
|
|
238
|
+
default:
|
|
239
|
+
throw new Error(`Unknown detection type: ${detectionType}`);
|
|
240
|
+
}
|
|
241
|
+
// Pool consecutive anomalies together
|
|
242
|
+
const pooledAnomalies = poolAnomalies(rawAnomalies);
|
|
243
|
+
return {
|
|
244
|
+
total_points: data.length,
|
|
245
|
+
anomaly_count: rawAnomalies.length,
|
|
246
|
+
anomaly_percentage: Math.round((rawAnomalies.length / data.length) * 10000) / 100, // Round to 2 decimal places
|
|
247
|
+
pool_count: pooledAnomalies.length,
|
|
248
|
+
anomalies: pooledAnomalies,
|
|
249
|
+
detection_type: detectionType,
|
|
250
|
+
parameters: params || {},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Main handler for time series anomaly detection
|
|
255
|
+
*/
|
|
256
|
+
export async function detectTimeseriesAnomalies(config, filePath, jqQuery, valueField, detectionType, detectionParams, timestampField) {
|
|
257
|
+
// Input validation
|
|
258
|
+
if (!filePath || !jqQuery || !valueField || !detectionType) {
|
|
259
|
+
throw new Error('file_path, jq_query, value_field, and detection_type are required');
|
|
260
|
+
}
|
|
261
|
+
// Sanitize jq query
|
|
262
|
+
validateJqQuery(jqQuery);
|
|
263
|
+
// Validate file path
|
|
264
|
+
if (!path.isAbsolute(filePath)) {
|
|
265
|
+
throw new Error(`File path must be an absolute path starting with "/": ${filePath}`);
|
|
266
|
+
}
|
|
267
|
+
if (!existsSync(filePath)) {
|
|
268
|
+
throw new Error(`File not found: ${filePath}`);
|
|
269
|
+
}
|
|
270
|
+
if (!filePath.toLowerCase().endsWith('.json')) {
|
|
271
|
+
throw new Error(`Only JSON files (.json) are supported: ${filePath}`);
|
|
272
|
+
}
|
|
273
|
+
// Validate path is within allowed directories
|
|
274
|
+
validatePathWithinAllowedDirs(filePath, config.allowedPaths);
|
|
275
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
276
|
+
// Execute jq query
|
|
277
|
+
const rawData = await runJqParsed(jqQuery, filePath, timeoutMs);
|
|
278
|
+
// Validate tabular structure
|
|
279
|
+
validateTabularData(rawData);
|
|
280
|
+
// Extract data points
|
|
281
|
+
const dataPoints = extractDataPoints(rawData, valueField, timestampField);
|
|
282
|
+
// Run detection
|
|
283
|
+
const result = runDetection(dataPoints, detectionType, detectionParams);
|
|
284
|
+
return {
|
|
285
|
+
content: [
|
|
286
|
+
{
|
|
287
|
+
type: 'text',
|
|
288
|
+
text: JSON.stringify(result, null, 2),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|