silgi 0.1.0-beta.14 → 0.1.0-beta.15
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/core/handler.mjs +1 -1
- package/dist/core/sse.mjs +1 -1
- package/dist/core/storage.mjs +3 -2
- package/dist/index.mjs +1 -1
- package/dist/plugins/analytics-alerts.d.mts +59 -0
- package/dist/plugins/analytics-alerts.mjs +140 -0
- package/dist/plugins/analytics-cost.d.mts +61 -0
- package/dist/plugins/analytics-cost.mjs +97 -0
- package/dist/plugins/analytics-query.mjs +164 -0
- package/dist/plugins/analytics-sse.d.mts +31 -0
- package/dist/plugins/analytics-sse.mjs +74 -0
- package/dist/plugins/analytics-timeseries.d.mts +50 -0
- package/dist/plugins/analytics-timeseries.mjs +169 -0
- package/dist/plugins/analytics.d.mts +25 -13
- package/dist/plugins/analytics.mjs +76 -21
- package/lib/dashboard/index.html +56 -56
- package/package.json +1 -1
package/dist/core/handler.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { routerCache } from "./router-utils.mjs";
|
|
2
2
|
import { compileRouter, createContext, releaseContext } from "../compile.mjs";
|
|
3
3
|
import { applyContext } from "./dispatch.mjs";
|
|
4
|
+
import { iteratorToEventStream } from "./sse.mjs";
|
|
4
5
|
import { analyticsTraceMap } from "../plugins/analytics.mjs";
|
|
5
6
|
import { detectResponseFormat, encodeResponse, makeErrorResponse } from "./codec.mjs";
|
|
6
7
|
import { runWithCtx } from "./context-bridge.mjs";
|
|
7
8
|
import { parseInput } from "./input.mjs";
|
|
8
|
-
import { iteratorToEventStream } from "./sse.mjs";
|
|
9
9
|
//#region src/core/handler.ts
|
|
10
10
|
/**
|
|
11
11
|
* Fetch API handler — single unified request handler.
|
package/dist/core/sse.mjs
CHANGED
|
@@ -95,4 +95,4 @@ function iteratorToEventStream(iterator, options = {}) {
|
|
|
95
95
|
}).pipeThrough(new TextEncoderStream());
|
|
96
96
|
}
|
|
97
97
|
//#endregion
|
|
98
|
-
export { getEventMeta, iteratorToEventStream, withEventMeta };
|
|
98
|
+
export { encodeEventMessage, getEventMeta, iteratorToEventStream, withEventMeta };
|
package/dist/core/storage.mjs
CHANGED
|
@@ -30,8 +30,9 @@ import memoryDriver from "unstorage/drivers/memory";
|
|
|
30
30
|
function _initStorage(config) {
|
|
31
31
|
if (config && "getItem" in config) return config;
|
|
32
32
|
const storage = createStorage({});
|
|
33
|
-
|
|
34
|
-
storage.mount("
|
|
33
|
+
const configKeys = config ? new Set(Object.keys(config)) : null;
|
|
34
|
+
if (!configKeys?.has("data")) storage.mount("data", memoryDriver());
|
|
35
|
+
if (!configKeys?.has("cache")) storage.mount("cache", memoryDriver());
|
|
35
36
|
if (config) for (const [path, driver] of Object.entries(config)) storage.mount(path, driver);
|
|
36
37
|
return storage;
|
|
37
38
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import { ValidationError, type, validateSchema } from "./core/schema.mjs";
|
|
|
2
2
|
import { collectCronTasks, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs } from "./core/task.mjs";
|
|
3
3
|
import { SilgiError, isDefinedError, toSilgiError } from "./core/error.mjs";
|
|
4
4
|
import { compileProcedure, compileRouter, createContext } from "./compile.mjs";
|
|
5
|
-
import { initStorage, resetStorage, useStorage } from "./core/storage.mjs";
|
|
6
5
|
import { AsyncIteratorClass, mapAsyncIterator } from "./core/iterator.mjs";
|
|
7
6
|
import { getEventMeta, withEventMeta } from "./core/sse.mjs";
|
|
7
|
+
import { initStorage, resetStorage, useStorage } from "./core/storage.mjs";
|
|
8
8
|
import { silgi } from "./silgi.mjs";
|
|
9
9
|
import { callable } from "./callable.mjs";
|
|
10
10
|
import { lifecycleWrap } from "./lifecycle.mjs";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/plugins/analytics-alerts.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Analytics Alerts — configurable alerting with sliding window evaluation.
|
|
4
|
+
*/
|
|
5
|
+
interface AlertRule {
|
|
6
|
+
/** Unique name for this alert. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Condition to evaluate. */
|
|
9
|
+
condition: 'error_rate' | 'latency_p95' | 'latency_avg' | 'error_count' | 'request_count' | 'no_requests';
|
|
10
|
+
/** Threshold value. For rates: percentage (0-100). For latency: milliseconds. For counts: absolute number. */
|
|
11
|
+
threshold: number;
|
|
12
|
+
/** Evaluation window in seconds (default: 300 = 5 minutes). */
|
|
13
|
+
windowSeconds?: number;
|
|
14
|
+
/** Cooldown between repeated alerts in seconds (default: 3600 = 1 hour). */
|
|
15
|
+
cooldownSeconds?: number;
|
|
16
|
+
/** Optional: only evaluate for a specific procedure. */
|
|
17
|
+
procedure?: string;
|
|
18
|
+
/** Actions to execute when alert fires. */
|
|
19
|
+
actions: AlertAction[];
|
|
20
|
+
}
|
|
21
|
+
type AlertAction = {
|
|
22
|
+
type: 'webhook';
|
|
23
|
+
url: string;
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
} | {
|
|
26
|
+
type: 'slack';
|
|
27
|
+
webhookUrl: string;
|
|
28
|
+
channel?: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'console';
|
|
31
|
+
};
|
|
32
|
+
interface AlertEvent {
|
|
33
|
+
rule: string;
|
|
34
|
+
condition: string;
|
|
35
|
+
value: number;
|
|
36
|
+
threshold: number;
|
|
37
|
+
procedure?: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
interface AlertState {
|
|
42
|
+
lastFired: number;
|
|
43
|
+
lastValue: number;
|
|
44
|
+
}
|
|
45
|
+
declare class AlertEngine {
|
|
46
|
+
#private;
|
|
47
|
+
constructor(rules: AlertRule[]);
|
|
48
|
+
/** Record a sample for window evaluation. */
|
|
49
|
+
record(durationMs: number, isError: boolean, procedure: string): void;
|
|
50
|
+
/** Evaluate all rules against current window data. */
|
|
51
|
+
evaluate(): void;
|
|
52
|
+
/** Get alert history. */
|
|
53
|
+
getHistory(): AlertEvent[];
|
|
54
|
+
/** Get current alert states. */
|
|
55
|
+
getStates(): Record<string, AlertState>;
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { AlertEngine, AlertRule };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
//#region src/plugins/analytics-alerts.ts
|
|
2
|
+
var AlertEngine = class {
|
|
3
|
+
#rules;
|
|
4
|
+
#state = /* @__PURE__ */ new Map();
|
|
5
|
+
#samples = [];
|
|
6
|
+
#maxWindowMs;
|
|
7
|
+
#timer = null;
|
|
8
|
+
#history = [];
|
|
9
|
+
constructor(rules) {
|
|
10
|
+
this.#rules = rules;
|
|
11
|
+
this.#maxWindowMs = Math.max(...rules.map((r) => (r.windowSeconds ?? 300) * 1e3), 3e5);
|
|
12
|
+
this.#timer = setInterval(() => this.evaluate(), 3e4);
|
|
13
|
+
if (typeof this.#timer === "object" && "unref" in this.#timer) this.#timer.unref();
|
|
14
|
+
}
|
|
15
|
+
/** Record a sample for window evaluation. */
|
|
16
|
+
record(durationMs, isError, procedure) {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
this.#samples.push({
|
|
19
|
+
timestamp: now,
|
|
20
|
+
durationMs,
|
|
21
|
+
isError,
|
|
22
|
+
procedure
|
|
23
|
+
});
|
|
24
|
+
const cutoff = now - this.#maxWindowMs;
|
|
25
|
+
while (this.#samples.length > 0 && this.#samples[0].timestamp < cutoff) this.#samples.shift();
|
|
26
|
+
}
|
|
27
|
+
/** Evaluate all rules against current window data. */
|
|
28
|
+
evaluate() {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const rule of this.#rules) {
|
|
31
|
+
const windowMs = (rule.windowSeconds ?? 300) * 1e3;
|
|
32
|
+
const cooldownMs = (rule.cooldownSeconds ?? 3600) * 1e3;
|
|
33
|
+
const cutoff = now - windowMs;
|
|
34
|
+
const state = this.#state.get(rule.name);
|
|
35
|
+
if (state && now - state.lastFired < cooldownMs) continue;
|
|
36
|
+
let samples = this.#samples.filter((s) => s.timestamp >= cutoff);
|
|
37
|
+
if (rule.procedure) samples = samples.filter((s) => s.procedure.includes(rule.procedure));
|
|
38
|
+
const value = this.#computeValue(rule.condition, samples);
|
|
39
|
+
if (value === null) continue;
|
|
40
|
+
if (!this.#checkThreshold(rule.condition, value, rule.threshold)) continue;
|
|
41
|
+
const event = {
|
|
42
|
+
rule: rule.name,
|
|
43
|
+
condition: rule.condition,
|
|
44
|
+
value,
|
|
45
|
+
threshold: rule.threshold,
|
|
46
|
+
procedure: rule.procedure,
|
|
47
|
+
timestamp: now,
|
|
48
|
+
message: `Alert "${rule.name}": ${rule.condition} is ${formatValue(rule.condition, value)} (threshold: ${formatValue(rule.condition, rule.threshold)})`
|
|
49
|
+
};
|
|
50
|
+
this.#state.set(rule.name, {
|
|
51
|
+
lastFired: now,
|
|
52
|
+
lastValue: value
|
|
53
|
+
});
|
|
54
|
+
this.#history.push(event);
|
|
55
|
+
if (this.#history.length > 1e3) this.#history.shift();
|
|
56
|
+
for (const action of rule.actions) this.#executeAction(action, event);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
#computeValue(condition, samples) {
|
|
60
|
+
if (samples.length === 0) return condition === "no_requests" ? 0 : null;
|
|
61
|
+
switch (condition) {
|
|
62
|
+
case "error_rate": return samples.filter((s) => s.isError).length / samples.length * 100;
|
|
63
|
+
case "error_count": return samples.filter((s) => s.isError).length;
|
|
64
|
+
case "request_count": return samples.length;
|
|
65
|
+
case "no_requests": return samples.length;
|
|
66
|
+
case "latency_avg": return samples.reduce((sum, s) => sum + s.durationMs, 0) / samples.length;
|
|
67
|
+
case "latency_p95": {
|
|
68
|
+
const sorted = samples.map((s) => s.durationMs).sort((a, b) => a - b);
|
|
69
|
+
const idx = Math.ceil(sorted.length * .95) - 1;
|
|
70
|
+
return sorted[Math.max(0, idx)];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
#checkThreshold(condition, value, threshold) {
|
|
75
|
+
if (condition === "no_requests") return value <= threshold;
|
|
76
|
+
return value >= threshold;
|
|
77
|
+
}
|
|
78
|
+
async #executeAction(action, event) {
|
|
79
|
+
try {
|
|
80
|
+
switch (action.type) {
|
|
81
|
+
case "console":
|
|
82
|
+
console.warn(`[silgi:alert] ${event.message}`);
|
|
83
|
+
break;
|
|
84
|
+
case "webhook":
|
|
85
|
+
await fetch(action.url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
...action.headers
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify(event)
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
case "slack":
|
|
95
|
+
await fetch(action.webhookUrl, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/json" },
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
channel: action.channel,
|
|
100
|
+
text: event.message,
|
|
101
|
+
blocks: [{
|
|
102
|
+
type: "section",
|
|
103
|
+
text: {
|
|
104
|
+
type: "mrkdwn",
|
|
105
|
+
text: `🚨 *${event.rule}*\n${event.message}`
|
|
106
|
+
}
|
|
107
|
+
}]
|
|
108
|
+
})
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`[silgi:alert] Failed to execute ${action.type} action: ${err instanceof Error ? err.message : err}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Get alert history. */
|
|
117
|
+
getHistory() {
|
|
118
|
+
return this.#history;
|
|
119
|
+
}
|
|
120
|
+
/** Get current alert states. */
|
|
121
|
+
getStates() {
|
|
122
|
+
const result = {};
|
|
123
|
+
for (const [name, state] of this.#state) result[name] = state;
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
dispose() {
|
|
127
|
+
if (this.#timer) clearInterval(this.#timer);
|
|
128
|
+
this.#timer = null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
function formatValue(condition, value) {
|
|
132
|
+
switch (condition) {
|
|
133
|
+
case "error_rate": return `${value.toFixed(1)}%`;
|
|
134
|
+
case "latency_avg":
|
|
135
|
+
case "latency_p95": return `${value.toFixed(1)}ms`;
|
|
136
|
+
default: return String(value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
export { AlertEngine };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//#region src/plugins/analytics-cost.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Analytics Cost Tracking — span cost metadata and budget monitoring.
|
|
4
|
+
*/
|
|
5
|
+
interface SpanCost {
|
|
6
|
+
/** Number of tokens/units consumed. */
|
|
7
|
+
units?: number;
|
|
8
|
+
/** Cost in dollars. */
|
|
9
|
+
amount: number;
|
|
10
|
+
/** Cost currency (default: USD). */
|
|
11
|
+
currency?: string;
|
|
12
|
+
/** Provider name (e.g., 'openai', 'anthropic', 'aws'). */
|
|
13
|
+
provider?: string;
|
|
14
|
+
}
|
|
15
|
+
interface CostBucket {
|
|
16
|
+
time: number;
|
|
17
|
+
totalAmount: number;
|
|
18
|
+
byProvider: Record<string, number>;
|
|
19
|
+
byProcedure: Record<string, number>;
|
|
20
|
+
byKind: Record<string, number>;
|
|
21
|
+
}
|
|
22
|
+
interface CostSummary {
|
|
23
|
+
totalAmount: number;
|
|
24
|
+
todayAmount: number;
|
|
25
|
+
byProvider: Record<string, number>;
|
|
26
|
+
byProcedure: Record<string, number>;
|
|
27
|
+
byKind: Record<string, number>;
|
|
28
|
+
dailyBuckets: CostBucket[];
|
|
29
|
+
}
|
|
30
|
+
interface BudgetRule {
|
|
31
|
+
/** Unique name. */
|
|
32
|
+
name: string;
|
|
33
|
+
/** Budget limit in dollars. */
|
|
34
|
+
limit: number;
|
|
35
|
+
/** Period: 'daily' | 'weekly' | 'monthly'. */
|
|
36
|
+
period: 'daily' | 'weekly' | 'monthly';
|
|
37
|
+
/** Optional: scope to a specific provider. */
|
|
38
|
+
provider?: string;
|
|
39
|
+
/** Optional: scope to a specific procedure. */
|
|
40
|
+
procedure?: string;
|
|
41
|
+
}
|
|
42
|
+
declare class CostTracker {
|
|
43
|
+
#private;
|
|
44
|
+
constructor(budgetRules?: BudgetRule[], onBudgetExceeded?: (rule: BudgetRule, current: number) => void);
|
|
45
|
+
/** Record a cost from a span. */
|
|
46
|
+
record(cost: SpanCost, procedure: string, kind: string): void;
|
|
47
|
+
/** Get cost summary. */
|
|
48
|
+
getSummary(): CostSummary;
|
|
49
|
+
/** Export for persistence. */
|
|
50
|
+
toJSON(): {
|
|
51
|
+
dailyBuckets: CostBucket[];
|
|
52
|
+
currentDay: CostBucket | null;
|
|
53
|
+
};
|
|
54
|
+
/** Restore from persistence. */
|
|
55
|
+
hydrate(data: {
|
|
56
|
+
dailyBuckets?: CostBucket[];
|
|
57
|
+
currentDay?: CostBucket | null;
|
|
58
|
+
}): void;
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { BudgetRule, CostTracker, SpanCost };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//#region src/plugins/analytics-cost.ts
|
|
2
|
+
const DAY_MS = 864e5;
|
|
3
|
+
const MAX_DAILY_BUCKETS = 30;
|
|
4
|
+
var CostTracker = class {
|
|
5
|
+
#dailyBuckets = [];
|
|
6
|
+
#currentDay = null;
|
|
7
|
+
#budgetRules;
|
|
8
|
+
#onBudgetExceeded;
|
|
9
|
+
constructor(budgetRules = [], onBudgetExceeded) {
|
|
10
|
+
this.#budgetRules = budgetRules;
|
|
11
|
+
this.#onBudgetExceeded = onBudgetExceeded;
|
|
12
|
+
}
|
|
13
|
+
/** Record a cost from a span. */
|
|
14
|
+
record(cost, procedure, kind) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const dayStart = now - now % DAY_MS;
|
|
17
|
+
if (!this.#currentDay || this.#currentDay.time !== dayStart) {
|
|
18
|
+
if (this.#currentDay) {
|
|
19
|
+
this.#dailyBuckets.push(this.#currentDay);
|
|
20
|
+
if (this.#dailyBuckets.length > MAX_DAILY_BUCKETS) this.#dailyBuckets.shift();
|
|
21
|
+
}
|
|
22
|
+
this.#currentDay = {
|
|
23
|
+
time: dayStart,
|
|
24
|
+
totalAmount: 0,
|
|
25
|
+
byProvider: {},
|
|
26
|
+
byProcedure: {},
|
|
27
|
+
byKind: {}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
this.#currentDay.totalAmount += cost.amount;
|
|
31
|
+
const provider = cost.provider ?? "unknown";
|
|
32
|
+
this.#currentDay.byProvider[provider] = (this.#currentDay.byProvider[provider] ?? 0) + cost.amount;
|
|
33
|
+
this.#currentDay.byProcedure[procedure] = (this.#currentDay.byProcedure[procedure] ?? 0) + cost.amount;
|
|
34
|
+
this.#currentDay.byKind[kind] = (this.#currentDay.byKind[kind] ?? 0) + cost.amount;
|
|
35
|
+
this.#checkBudgets();
|
|
36
|
+
}
|
|
37
|
+
/** Get cost summary. */
|
|
38
|
+
getSummary() {
|
|
39
|
+
const allBuckets = this.#currentDay ? [...this.#dailyBuckets, this.#currentDay] : [...this.#dailyBuckets];
|
|
40
|
+
const summary = {
|
|
41
|
+
totalAmount: 0,
|
|
42
|
+
todayAmount: this.#currentDay?.totalAmount ?? 0,
|
|
43
|
+
byProvider: {},
|
|
44
|
+
byProcedure: {},
|
|
45
|
+
byKind: {},
|
|
46
|
+
dailyBuckets: allBuckets
|
|
47
|
+
};
|
|
48
|
+
for (const bucket of allBuckets) {
|
|
49
|
+
summary.totalAmount += bucket.totalAmount;
|
|
50
|
+
for (const [k, v] of Object.entries(bucket.byProvider)) summary.byProvider[k] = (summary.byProvider[k] ?? 0) + v;
|
|
51
|
+
for (const [k, v] of Object.entries(bucket.byProcedure)) summary.byProcedure[k] = (summary.byProcedure[k] ?? 0) + v;
|
|
52
|
+
for (const [k, v] of Object.entries(bucket.byKind)) summary.byKind[k] = (summary.byKind[k] ?? 0) + v;
|
|
53
|
+
}
|
|
54
|
+
return summary;
|
|
55
|
+
}
|
|
56
|
+
/** Export for persistence. */
|
|
57
|
+
toJSON() {
|
|
58
|
+
return {
|
|
59
|
+
dailyBuckets: this.#dailyBuckets,
|
|
60
|
+
currentDay: this.#currentDay
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Restore from persistence. */
|
|
64
|
+
hydrate(data) {
|
|
65
|
+
if (Array.isArray(data.dailyBuckets)) this.#dailyBuckets = data.dailyBuckets;
|
|
66
|
+
if (data.currentDay) this.#currentDay = data.currentDay;
|
|
67
|
+
}
|
|
68
|
+
#checkBudgets() {
|
|
69
|
+
if (!this.#onBudgetExceeded) return;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
for (const rule of this.#budgetRules) {
|
|
72
|
+
let amount = 0;
|
|
73
|
+
const buckets = this.#currentDay ? [...this.#dailyBuckets, this.#currentDay] : this.#dailyBuckets;
|
|
74
|
+
let cutoff;
|
|
75
|
+
switch (rule.period) {
|
|
76
|
+
case "daily":
|
|
77
|
+
cutoff = now - DAY_MS;
|
|
78
|
+
break;
|
|
79
|
+
case "weekly":
|
|
80
|
+
cutoff = now - 7 * DAY_MS;
|
|
81
|
+
break;
|
|
82
|
+
case "monthly":
|
|
83
|
+
cutoff = now - 30 * DAY_MS;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
for (const bucket of buckets) {
|
|
87
|
+
if (bucket.time < cutoff) continue;
|
|
88
|
+
if (rule.provider) amount += bucket.byProvider[rule.provider] ?? 0;
|
|
89
|
+
else if (rule.procedure) amount += bucket.byProcedure[rule.procedure] ?? 0;
|
|
90
|
+
else amount += bucket.totalAmount;
|
|
91
|
+
}
|
|
92
|
+
if (amount >= rule.limit) this.#onBudgetExceeded(rule, amount);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { CostTracker };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
//#region src/plugins/analytics-query.ts
|
|
2
|
+
function parseQueryParams(params) {
|
|
3
|
+
const q = {};
|
|
4
|
+
const cursor = params.get("cursor");
|
|
5
|
+
if (cursor) q.cursor = Number(cursor);
|
|
6
|
+
const before = params.get("before");
|
|
7
|
+
if (before) q.before = Number(before);
|
|
8
|
+
const limit = params.get("limit");
|
|
9
|
+
q.limit = limit ? Math.max(1, Number(limit)) : 50;
|
|
10
|
+
q.sort = params.get("sort") ?? void 0;
|
|
11
|
+
q.order = params.get("order") ?? void 0;
|
|
12
|
+
q.status = params.get("status") ?? void 0;
|
|
13
|
+
q.path = params.get("path") ?? void 0;
|
|
14
|
+
q.search = params.get("search") ?? void 0;
|
|
15
|
+
q.procedure = params.get("procedure") ?? void 0;
|
|
16
|
+
q.session = params.get("session") ?? void 0;
|
|
17
|
+
const minDuration = params.get("minDuration");
|
|
18
|
+
if (minDuration) q.minDuration = Number(minDuration);
|
|
19
|
+
const maxDuration = params.get("maxDuration");
|
|
20
|
+
if (maxDuration) q.maxDuration = Number(maxDuration);
|
|
21
|
+
return q;
|
|
22
|
+
}
|
|
23
|
+
function matchesStatus(entryStatus, filter) {
|
|
24
|
+
const exact = Number(filter);
|
|
25
|
+
if (Number.isFinite(exact)) return entryStatus === exact;
|
|
26
|
+
if (/^[1-5]xx$/i.test(filter)) {
|
|
27
|
+
const cls = Number(filter[0]);
|
|
28
|
+
return Math.floor(entryStatus / 100) === cls;
|
|
29
|
+
}
|
|
30
|
+
const rangeMatch = filter.match(/^([<>]=?)(\d+)$/);
|
|
31
|
+
if (rangeMatch) {
|
|
32
|
+
const [, op, val] = rangeMatch;
|
|
33
|
+
const n = Number(val);
|
|
34
|
+
switch (op) {
|
|
35
|
+
case ">": return entryStatus > n;
|
|
36
|
+
case ">=": return entryStatus >= n;
|
|
37
|
+
case "<": return entryStatus < n;
|
|
38
|
+
case "<=": return entryStatus <= n;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function queryRequests(entries, params) {
|
|
44
|
+
let filtered = entries;
|
|
45
|
+
if (params.status) {
|
|
46
|
+
const status = params.status;
|
|
47
|
+
filtered = filtered.filter((r) => matchesStatus(r.status, status));
|
|
48
|
+
}
|
|
49
|
+
if (params.path) {
|
|
50
|
+
const prefix = params.path.toLowerCase();
|
|
51
|
+
filtered = filtered.filter((r) => r.path.toLowerCase().includes(prefix));
|
|
52
|
+
}
|
|
53
|
+
if (params.procedure) {
|
|
54
|
+
const proc = params.procedure.toLowerCase();
|
|
55
|
+
filtered = filtered.filter((r) => r.procedures.some((p) => p.procedure.toLowerCase().includes(proc)));
|
|
56
|
+
}
|
|
57
|
+
if (params.session) {
|
|
58
|
+
const session = params.session;
|
|
59
|
+
filtered = filtered.filter((r) => r.sessionId === session);
|
|
60
|
+
}
|
|
61
|
+
if (params.minDuration != null) {
|
|
62
|
+
const min = params.minDuration;
|
|
63
|
+
filtered = filtered.filter((r) => r.durationMs >= min);
|
|
64
|
+
}
|
|
65
|
+
if (params.maxDuration != null) {
|
|
66
|
+
const max = params.maxDuration;
|
|
67
|
+
filtered = filtered.filter((r) => r.durationMs <= max);
|
|
68
|
+
}
|
|
69
|
+
if (params.search) {
|
|
70
|
+
const term = params.search.toLowerCase();
|
|
71
|
+
filtered = filtered.filter((r) => r.path?.toLowerCase().includes(term) || r.procedures?.some((p) => p.procedure.toLowerCase().includes(term)) || r.method?.toLowerCase().includes(term) || r.requestId?.includes(term));
|
|
72
|
+
}
|
|
73
|
+
const sortField = params.sort ?? "timestamp";
|
|
74
|
+
const desc = (params.order ?? "desc") === "desc";
|
|
75
|
+
filtered = sortEntries(filtered, sortField, desc);
|
|
76
|
+
return paginate(filtered, params);
|
|
77
|
+
}
|
|
78
|
+
function queryErrors(entries, params) {
|
|
79
|
+
let filtered = entries;
|
|
80
|
+
if (params.status) {
|
|
81
|
+
const status = params.status;
|
|
82
|
+
filtered = filtered.filter((e) => matchesStatus(e.status, status));
|
|
83
|
+
}
|
|
84
|
+
if (params.procedure) {
|
|
85
|
+
const proc = params.procedure.toLowerCase();
|
|
86
|
+
filtered = filtered.filter((e) => e.procedure.toLowerCase().includes(proc));
|
|
87
|
+
}
|
|
88
|
+
if (params.path) {
|
|
89
|
+
const path = params.path.toLowerCase();
|
|
90
|
+
filtered = filtered.filter((e) => e.procedure.toLowerCase().includes(path));
|
|
91
|
+
}
|
|
92
|
+
if (params.minDuration != null) {
|
|
93
|
+
const min = params.minDuration;
|
|
94
|
+
filtered = filtered.filter((e) => e.durationMs >= min);
|
|
95
|
+
}
|
|
96
|
+
if (params.maxDuration != null) {
|
|
97
|
+
const max = params.maxDuration;
|
|
98
|
+
filtered = filtered.filter((e) => e.durationMs <= max);
|
|
99
|
+
}
|
|
100
|
+
if (params.search) {
|
|
101
|
+
const term = params.search.toLowerCase();
|
|
102
|
+
filtered = filtered.filter((e) => e.procedure.toLowerCase().includes(term) || e.error.toLowerCase().includes(term) || e.code.toLowerCase().includes(term) || e.requestId.includes(term));
|
|
103
|
+
}
|
|
104
|
+
const sortField = params.sort ?? "timestamp";
|
|
105
|
+
const desc = (params.order ?? "desc") === "desc";
|
|
106
|
+
filtered = sortEntries(filtered, sortField, desc);
|
|
107
|
+
return paginate(filtered, params);
|
|
108
|
+
}
|
|
109
|
+
function queryTasks(entries, params) {
|
|
110
|
+
let filtered = entries;
|
|
111
|
+
if (params.status) {
|
|
112
|
+
const status = params.status;
|
|
113
|
+
filtered = filtered.filter((t) => status === "error" ? t.status === "error" : t.status === "success");
|
|
114
|
+
}
|
|
115
|
+
if (params.search) {
|
|
116
|
+
const term = params.search.toLowerCase();
|
|
117
|
+
filtered = filtered.filter((t) => t.taskName.toLowerCase().includes(term) || (t.error?.toLowerCase().includes(term) ?? false));
|
|
118
|
+
}
|
|
119
|
+
if (params.minDuration != null) {
|
|
120
|
+
const min = params.minDuration;
|
|
121
|
+
filtered = filtered.filter((t) => t.durationMs >= min);
|
|
122
|
+
}
|
|
123
|
+
const sortField = params.sort ?? "timestamp";
|
|
124
|
+
const desc = (params.order ?? "desc") === "desc";
|
|
125
|
+
filtered = sortEntries(filtered, sortField, desc);
|
|
126
|
+
return paginate(filtered, params);
|
|
127
|
+
}
|
|
128
|
+
function sortEntries(entries, field, desc) {
|
|
129
|
+
const sorted = [...entries];
|
|
130
|
+
sorted.sort((a, b) => {
|
|
131
|
+
const va = a[field];
|
|
132
|
+
const vb = b[field];
|
|
133
|
+
if (va == null && vb == null) return 0;
|
|
134
|
+
if (va == null) return 1;
|
|
135
|
+
if (vb == null) return -1;
|
|
136
|
+
if (typeof va === "number" && typeof vb === "number") return desc ? vb - va : va - vb;
|
|
137
|
+
if (typeof va === "string" && typeof vb === "string") return desc ? vb.localeCompare(va) : va.localeCompare(vb);
|
|
138
|
+
return 0;
|
|
139
|
+
});
|
|
140
|
+
return sorted;
|
|
141
|
+
}
|
|
142
|
+
function paginate(entries, params) {
|
|
143
|
+
const limit = params.limit ?? 50;
|
|
144
|
+
const total = entries.length;
|
|
145
|
+
let start = 0;
|
|
146
|
+
if (params.cursor != null) {
|
|
147
|
+
const idx = entries.findIndex((e) => e.id === params.cursor);
|
|
148
|
+
start = idx === -1 ? 0 : idx + 1;
|
|
149
|
+
} else if (params.before != null) {
|
|
150
|
+
const idx = entries.findIndex((e) => e.id === params.before);
|
|
151
|
+
start = idx === -1 ? 0 : Math.max(0, idx - limit);
|
|
152
|
+
}
|
|
153
|
+
const data = entries.slice(start, start + limit);
|
|
154
|
+
const hasMore = start + limit < total;
|
|
155
|
+
return {
|
|
156
|
+
data,
|
|
157
|
+
total,
|
|
158
|
+
hasMore,
|
|
159
|
+
nextCursor: hasMore && data.length > 0 ? data[data.length - 1].id : null,
|
|
160
|
+
prevCursor: start > 0 && data.length > 0 ? data[0].id : null
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { parseQueryParams, queryErrors, queryRequests, queryTasks };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ErrorEntry, RequestEntry, TaskExecution } from "./analytics.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/analytics-sse.d.ts
|
|
4
|
+
type AnalyticsEvent = {
|
|
5
|
+
type: 'request';
|
|
6
|
+
data: RequestEntry;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'error';
|
|
9
|
+
data: ErrorEntry;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'task';
|
|
12
|
+
data: TaskExecution;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'stats';
|
|
15
|
+
data: unknown;
|
|
16
|
+
};
|
|
17
|
+
declare class AnalyticsSSEHub {
|
|
18
|
+
#private;
|
|
19
|
+
constructor();
|
|
20
|
+
/** Start periodic stats broadcast. */
|
|
21
|
+
startStatsBroadcast(getStats: () => unknown, intervalMs?: number): void;
|
|
22
|
+
/** Broadcast an event to all connected clients. */
|
|
23
|
+
broadcast(event: AnalyticsEvent): void;
|
|
24
|
+
/** Create an SSE ReadableStream for a new client connection. */
|
|
25
|
+
createStream(): ReadableStream<Uint8Array>;
|
|
26
|
+
/** Number of connected clients. */
|
|
27
|
+
get clientCount(): number;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { AnalyticsSSEHub };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { encodeEventMessage } from "../core/sse.mjs";
|
|
2
|
+
//#region src/plugins/analytics-sse.ts
|
|
3
|
+
/**
|
|
4
|
+
* Analytics SSE — real-time event streaming for the analytics dashboard.
|
|
5
|
+
*/
|
|
6
|
+
var AnalyticsSSEHub = class {
|
|
7
|
+
#clients = /* @__PURE__ */ new Set();
|
|
8
|
+
#statsInterval = null;
|
|
9
|
+
#getStats = null;
|
|
10
|
+
constructor() {}
|
|
11
|
+
/** Start periodic stats broadcast. */
|
|
12
|
+
startStatsBroadcast(getStats, intervalMs = 5e3) {
|
|
13
|
+
this.#getStats = getStats;
|
|
14
|
+
this.#statsInterval = setInterval(() => {
|
|
15
|
+
if (this.#clients.size > 0 && this.#getStats) this.broadcast({
|
|
16
|
+
type: "stats",
|
|
17
|
+
data: this.#getStats()
|
|
18
|
+
});
|
|
19
|
+
}, intervalMs);
|
|
20
|
+
if (typeof this.#statsInterval === "object" && "unref" in this.#statsInterval) this.#statsInterval.unref();
|
|
21
|
+
}
|
|
22
|
+
/** Broadcast an event to all connected clients. */
|
|
23
|
+
broadcast(event) {
|
|
24
|
+
if (this.#clients.size === 0) return;
|
|
25
|
+
const message = encodeEventMessage({
|
|
26
|
+
event: event.type,
|
|
27
|
+
data: JSON.stringify(event.data)
|
|
28
|
+
});
|
|
29
|
+
for (const controller of this.#clients) try {
|
|
30
|
+
controller.enqueue(message);
|
|
31
|
+
} catch {
|
|
32
|
+
this.#clients.delete(controller);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Create an SSE ReadableStream for a new client connection. */
|
|
36
|
+
createStream() {
|
|
37
|
+
let controller;
|
|
38
|
+
let keepAliveTimer;
|
|
39
|
+
return new ReadableStream({
|
|
40
|
+
start: (ctrl) => {
|
|
41
|
+
controller = ctrl;
|
|
42
|
+
this.#clients.add(controller);
|
|
43
|
+
controller.enqueue(encodeEventMessage({ comment: "connected" }));
|
|
44
|
+
keepAliveTimer = setInterval(() => {
|
|
45
|
+
try {
|
|
46
|
+
controller.enqueue(encodeEventMessage({ comment: "keepalive" }));
|
|
47
|
+
} catch {
|
|
48
|
+
clearInterval(keepAliveTimer);
|
|
49
|
+
this.#clients.delete(controller);
|
|
50
|
+
}
|
|
51
|
+
}, 15e3);
|
|
52
|
+
if (typeof keepAliveTimer === "object" && "unref" in keepAliveTimer) keepAliveTimer.unref();
|
|
53
|
+
},
|
|
54
|
+
cancel: () => {
|
|
55
|
+
clearInterval(keepAliveTimer);
|
|
56
|
+
this.#clients.delete(controller);
|
|
57
|
+
}
|
|
58
|
+
}).pipeThrough(new TextEncoderStream());
|
|
59
|
+
}
|
|
60
|
+
/** Number of connected clients. */
|
|
61
|
+
get clientCount() {
|
|
62
|
+
return this.#clients.size;
|
|
63
|
+
}
|
|
64
|
+
dispose() {
|
|
65
|
+
if (this.#statsInterval) clearInterval(this.#statsInterval);
|
|
66
|
+
this.#statsInterval = null;
|
|
67
|
+
for (const controller of this.#clients) try {
|
|
68
|
+
controller.close();
|
|
69
|
+
} catch {}
|
|
70
|
+
this.#clients.clear();
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
//#endregion
|
|
74
|
+
export { AnalyticsSSEHub };
|