silgi 0.1.0-beta.13 → 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/context-bridge.mjs +11 -0
- package/dist/core/handler.mjs +3 -2
- package/dist/core/sse.mjs +1 -1
- package/dist/core/storage.mjs +3 -2
- package/dist/index.mjs +1 -1
- package/dist/integrations/better-auth/index.d.mts +0 -20
- package/dist/integrations/better-auth/index.mjs +3 -4
- package/dist/integrations/drizzle/index.mjs +6 -7
- 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 +38 -20
- package/dist/plugins/analytics.mjs +454 -52
- package/lib/dashboard/index.html +56 -59
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/core/context-bridge.ts
|
|
3
|
+
const ctxStorage = new AsyncLocalStorage();
|
|
4
|
+
function runWithCtx(ctx, fn) {
|
|
5
|
+
return ctxStorage.run(ctx, fn);
|
|
6
|
+
}
|
|
7
|
+
function getCtx() {
|
|
8
|
+
return ctxStorage.getStore();
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { getCtx, runWithCtx };
|
package/dist/core/handler.mjs
CHANGED
|
@@ -1,10 +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";
|
|
7
|
+
import { runWithCtx } from "./context-bridge.mjs";
|
|
6
8
|
import { parseInput } from "./input.mjs";
|
|
7
|
-
import { iteratorToEventStream } from "./sse.mjs";
|
|
8
9
|
//#region src/core/handler.ts
|
|
9
10
|
/**
|
|
10
11
|
* Fetch API handler — single unified request handler.
|
|
@@ -128,7 +129,7 @@ function createFetchHandler(routerDef, contextFactory, hooks) {
|
|
|
128
129
|
path: pathname,
|
|
129
130
|
input: rawInput
|
|
130
131
|
});
|
|
131
|
-
const pipelineResult = route.handler(ctx, rawInput, request.signal);
|
|
132
|
+
const pipelineResult = runWithCtx(ctx, () => route.handler(ctx, rawInput, request.signal));
|
|
132
133
|
const output = pipelineResult instanceof Promise ? await pipelineResult : pipelineResult;
|
|
133
134
|
callHook("response", {
|
|
134
135
|
path: pathname,
|
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";
|
|
@@ -1,24 +1,4 @@
|
|
|
1
1
|
//#region src/integrations/better-auth/index.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* Silgi + Better Auth tracing integration.
|
|
4
|
-
*
|
|
5
|
-
* Provides a Better Auth plugin factory that auto-traces all auth operations
|
|
6
|
-
* (sign-in, sign-up, OAuth, session management, etc.) into silgi analytics.
|
|
7
|
-
*
|
|
8
|
-
* The silgi request context is passed via `request.__silgiCtx`, set by
|
|
9
|
-
* the silgi auth handler before calling `auth.handler(request)`.
|
|
10
|
-
*
|
|
11
|
-
* @example
|
|
12
|
-
* ```ts
|
|
13
|
-
* import { tracing } from 'silgi/better-auth'
|
|
14
|
-
*
|
|
15
|
-
* const auth = betterAuth({
|
|
16
|
-
* plugins: [
|
|
17
|
-
* tracing(), // auto-traces all auth operations
|
|
18
|
-
* ],
|
|
19
|
-
* })
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
2
|
interface TracingConfig {
|
|
23
3
|
/** Capture request body as span input (default: true) */
|
|
24
4
|
captureInput?: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getCtx, runWithCtx } from "../../core/context-bridge.mjs";
|
|
2
2
|
//#region src/integrations/better-auth/index.ts
|
|
3
3
|
/**
|
|
4
4
|
* Silgi + Better Auth tracing integration.
|
|
@@ -20,7 +20,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
20
20
|
* })
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
|
-
const ctxStorage = new AsyncLocalStorage();
|
|
24
23
|
function matchOperation(path) {
|
|
25
24
|
const normalized = path.replace(/^\/+/, "");
|
|
26
25
|
if (normalized.endsWith("/sign-up/email") || normalized === "sign-up/email") return {
|
|
@@ -281,11 +280,11 @@ function instrumentBetterAuth(auth) {
|
|
|
281
280
|
* Run a function with silgi context available to instrumented Better Auth API calls.
|
|
282
281
|
*/
|
|
283
282
|
function withCtx(ctx, fn) {
|
|
284
|
-
return
|
|
283
|
+
return runWithCtx(ctx, fn);
|
|
285
284
|
}
|
|
286
285
|
function wrapApiMethod(originalFn, operation, method) {
|
|
287
286
|
return async function instrumented(...args) {
|
|
288
|
-
const reqTrace =
|
|
287
|
+
const reqTrace = getCtx()?.__analyticsTrace;
|
|
289
288
|
if (!reqTrace) return originalFn.apply(this, args);
|
|
290
289
|
const spanName = `auth.api.${operation}`;
|
|
291
290
|
const start = performance.now();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getCtx, runWithCtx } from "../../core/context-bridge.mjs";
|
|
2
2
|
//#region src/integrations/drizzle/index.ts
|
|
3
3
|
/**
|
|
4
4
|
* Silgi + Drizzle ORM tracing integration.
|
|
@@ -31,7 +31,6 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
31
31
|
* })
|
|
32
32
|
* ```
|
|
33
33
|
*/
|
|
34
|
-
const ctxStorage = new AsyncLocalStorage();
|
|
35
34
|
const INSTRUMENTED = "__silgiDrizzleInstrumented";
|
|
36
35
|
const DEFAULT_DB_SYSTEM = "postgresql";
|
|
37
36
|
const DEFAULT_MAX_QUERY_LENGTH = 1e3;
|
|
@@ -59,7 +58,7 @@ function instrumentDrizzle(db, config) {
|
|
|
59
58
|
* All Drizzle queries inside `fn` will be recorded as trace spans.
|
|
60
59
|
*/
|
|
61
60
|
function withCtx(ctx, fn) {
|
|
62
|
-
return
|
|
61
|
+
return runWithCtx(ctx, fn);
|
|
63
62
|
}
|
|
64
63
|
function resolveConfig(config) {
|
|
65
64
|
return {
|
|
@@ -84,7 +83,7 @@ function patchSession(session, cfg, isTx) {
|
|
|
84
83
|
session.prepareQuery = function patchedPrepareQuery(...args) {
|
|
85
84
|
const prepared = originalPrepareQuery.apply(this, args);
|
|
86
85
|
if (!prepared || typeof prepared.execute !== "function") return prepared;
|
|
87
|
-
const reqTrace =
|
|
86
|
+
const reqTrace = getCtx()?.__analyticsTrace;
|
|
88
87
|
if (!reqTrace) return prepared;
|
|
89
88
|
const queryText = extractQueryText(args[0]) ?? prepared.rawQueryConfig?.text ?? prepared.queryConfig?.text ?? null;
|
|
90
89
|
const originalExecute = prepared.execute.bind(prepared);
|
|
@@ -98,7 +97,7 @@ function patchSession(session, cfg, isTx) {
|
|
|
98
97
|
if (typeof session.query === "function") {
|
|
99
98
|
const originalQuery = session.query.bind(session);
|
|
100
99
|
session.query = function patchedQuery(queryString, params) {
|
|
101
|
-
const reqTrace =
|
|
100
|
+
const reqTrace = getCtx()?.__analyticsTrace;
|
|
102
101
|
if (!reqTrace) return originalQuery.call(this, queryString, params);
|
|
103
102
|
return traceExecution(reqTrace, cfg, queryString ?? null, isTx, originalQuery, this, [queryString, params]);
|
|
104
103
|
};
|
|
@@ -127,7 +126,7 @@ function patchRawClient(client, cfg) {
|
|
|
127
126
|
if (!methodName) return false;
|
|
128
127
|
const originalMethod = client[methodName].bind(client);
|
|
129
128
|
client[methodName] = function patchedClientMethod(...args) {
|
|
130
|
-
const reqTrace =
|
|
129
|
+
const reqTrace = getCtx()?.__analyticsTrace;
|
|
131
130
|
if (!reqTrace) return originalMethod.apply(this, args);
|
|
132
131
|
return traceExecution(reqTrace, cfg, extractQueryText(args[0]) ?? null, false, originalMethod, this, args);
|
|
133
132
|
};
|
|
@@ -141,7 +140,7 @@ function patchSessionExecute(session, cfg) {
|
|
|
141
140
|
if (session[INSTRUMENTED]) return false;
|
|
142
141
|
const originalExecute = session.execute.bind(session);
|
|
143
142
|
session.execute = function patchedDeepExecute(...args) {
|
|
144
|
-
const reqTrace =
|
|
143
|
+
const reqTrace = getCtx()?.__analyticsTrace;
|
|
145
144
|
if (!reqTrace) return originalExecute.apply(this, args);
|
|
146
145
|
return traceExecution(reqTrace, cfg, extractQueryText(args[0]) ?? null, false, originalExecute, this, args);
|
|
147
146
|
};
|
|
@@ -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 };
|