@daemonitor/plugins 1.1.34 → 1.1.36
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/plugins/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
|
|
|
15
15
|
import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
|
|
16
16
|
import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
|
|
17
17
|
import { createTplinkSwitchPlugin } from "./monitoring/TplinkSwitchPlugin.js";
|
|
18
|
+
import { createCostPlugin } from "./monitoring/CostPlugin.js";
|
|
18
19
|
// In future, implement other plugin factory functions using the same pattern
|
|
19
20
|
// import { createOSPlugin } from "./monitoring/OSPlugin.js"
|
|
20
21
|
// import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
|
|
@@ -41,6 +42,7 @@ export default {
|
|
|
41
42
|
Ga4: createGa4Plugin,
|
|
42
43
|
Nagios: createNagiosPlugin,
|
|
43
44
|
TplinkSwitch: createTplinkSwitchPlugin,
|
|
45
|
+
Cost: createCostPlugin,
|
|
44
46
|
// CloudflarePlugin: createCloudflarePlugin,
|
|
45
47
|
// EwelinkPlugin: createEwelinkPlugin,
|
|
46
48
|
// MongoDBPlugin: createMongoDBPlugin,
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
+
import { PluginManager } from "../../lib/PluginManager.js";
|
|
3
|
+
import { PROVIDERS } from "./CostProviders.js";
|
|
4
|
+
const DEFAULT_INTERVAL = 3600000;
|
|
5
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
6
|
+
export function shiftDate(date, days) {
|
|
7
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
8
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
9
|
+
return t.toISOString().slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
export const todayUtc = (now = new Date()) => now.toISOString().slice(0, 10);
|
|
12
|
+
/** Earlier of the 1st of this month and 29 days back: one fetch covers the month and a 30-day trace. */
|
|
13
|
+
export function fetchStart(today) {
|
|
14
|
+
const monthStart = `${today.slice(0, 7)}-01`;
|
|
15
|
+
const back = shiftDate(today, -29);
|
|
16
|
+
return back < monthStart ? back : monthStart;
|
|
17
|
+
}
|
|
18
|
+
export function daysInMonth(today) {
|
|
19
|
+
const [y, m] = today.split("-").map(Number);
|
|
20
|
+
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
21
|
+
}
|
|
22
|
+
export function summarizeCost(f, today) {
|
|
23
|
+
const month = today.slice(0, 7);
|
|
24
|
+
const dom = Number(today.slice(8, 10));
|
|
25
|
+
const dim = daysInMonth(today);
|
|
26
|
+
if (!f.days) {
|
|
27
|
+
// ponytail: straight-line projection from MTD over elapsed days; today's
|
|
28
|
+
// partial day drags it low early in the month. Fine for an invoice-level API.
|
|
29
|
+
const mtd = Number(f.monthToDate) || 0;
|
|
30
|
+
return { monthToDate: round2(mtd), projected: round2((mtd / dom) * dim), daily: [] };
|
|
31
|
+
}
|
|
32
|
+
const by = new Map();
|
|
33
|
+
for (const d of f.days)
|
|
34
|
+
by.set(d.date, (by.get(d.date) || 0) + (Number(d.amount) || 0));
|
|
35
|
+
const at = (date) => by.get(date) || 0;
|
|
36
|
+
// Gap-filled, oldest first, one point per day for the trace.
|
|
37
|
+
const daily = [];
|
|
38
|
+
for (let i = 29; i >= 0; i--) {
|
|
39
|
+
const date = shiftDate(today, -i);
|
|
40
|
+
daily.push({ date, amount: round2(at(date)) });
|
|
41
|
+
}
|
|
42
|
+
let mtd = 0;
|
|
43
|
+
for (const [date, amount] of by)
|
|
44
|
+
if (date.startsWith(month) && date <= today)
|
|
45
|
+
mtd += amount;
|
|
46
|
+
const todayAmt = at(today);
|
|
47
|
+
// Project from COMPLETE days only: today is still filling in, and on the 1st
|
|
48
|
+
// there is nothing complete yet, so fall back to the partial day.
|
|
49
|
+
const projected = dom > 1 ? ((mtd - todayAmt) / (dom - 1)) * dim : mtd * dim;
|
|
50
|
+
// The 7 complete days before yesterday, the baseline a spike is judged against.
|
|
51
|
+
let prior = 0;
|
|
52
|
+
for (let i = 2; i <= 8; i++)
|
|
53
|
+
prior += at(shiftDate(today, -i));
|
|
54
|
+
return {
|
|
55
|
+
monthToDate: round2(mtd),
|
|
56
|
+
projected: round2(projected),
|
|
57
|
+
today: round2(todayAmt),
|
|
58
|
+
yesterday: round2(at(shiftDate(today, -1))),
|
|
59
|
+
avg7: round2(prior / 7),
|
|
60
|
+
last30: round2(daily.reduce((s, d) => s + d.amount, 0)),
|
|
61
|
+
daily,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function collectCost(p, today = todayUtc()) {
|
|
65
|
+
// `authFrom` borrows credentials from another config block, e.g. the
|
|
66
|
+
// top-level `workspace` block google-auth.mjs writes.
|
|
67
|
+
if (p.authFrom)
|
|
68
|
+
p = { ...PluginManager.getConfig(p.authFrom), ...p };
|
|
69
|
+
const fetcher = PROVIDERS[p.provider];
|
|
70
|
+
if (!fetcher)
|
|
71
|
+
throw new Error(`unknown cost provider "${p.provider}" (have: ${Object.keys(PROVIDERS).join(", ")})`);
|
|
72
|
+
const f = await fetcher(p, { start: fetchStart(today), end: today }, p.timeout || 20000);
|
|
73
|
+
return {
|
|
74
|
+
currency: (f.currency || "USD").toUpperCase(),
|
|
75
|
+
asOf: today,
|
|
76
|
+
...summarizeCost(f, today),
|
|
77
|
+
breakdown: (f.breakdown || []).filter((b) => b.amount).sort((a, b) => b.amount - a.amount).slice(0, 12)
|
|
78
|
+
.map((b) => ({ name: b.name, amount: round2(b.amount) })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function createCostPlugin() {
|
|
82
|
+
let refreshTimer = null;
|
|
83
|
+
const refreshFn = async (plugin) => {
|
|
84
|
+
const cfg = (plugin.config || {});
|
|
85
|
+
const intervalMs = cfg.refreshInterval || DEFAULT_INTERVAL;
|
|
86
|
+
for (const p of cfg.providers || []) {
|
|
87
|
+
const id = p.id || p.provider;
|
|
88
|
+
const meta = {
|
|
89
|
+
name: p.name || p.provider,
|
|
90
|
+
provider: p.provider,
|
|
91
|
+
intervalMs,
|
|
92
|
+
...(p.budget ? { budget: Number(p.budget) } : {}),
|
|
93
|
+
...(p.spikeFactor ? { spikeFactor: Number(p.spikeFactor) } : {}),
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await plugin.send({ ...meta, ...(await collectCost(p)) }, `cost-${id}`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
await plugin.send({
|
|
100
|
+
...meta,
|
|
101
|
+
error: String(e?.message || e).slice(0, 160),
|
|
102
|
+
...(e?.status === 401 || e?.status === 403 ? { authExpired: true } : {}),
|
|
103
|
+
}, `cost-${id}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const monitorFn = async (plugin) => {
|
|
108
|
+
const cfg = (plugin.config || {});
|
|
109
|
+
if (!cfg.providers?.length) {
|
|
110
|
+
console.error("cost plugin: no providers configured");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
await refreshFn(plugin);
|
|
114
|
+
// Billing data lags by hours and AWS charges per Cost Explorer call, so hourly loses nothing.
|
|
115
|
+
refreshTimer = setInterval(() => refreshFn(plugin), cfg.refreshInterval || DEFAULT_INTERVAL);
|
|
116
|
+
};
|
|
117
|
+
const teardownFn = async () => {
|
|
118
|
+
if (refreshTimer)
|
|
119
|
+
clearInterval(refreshTimer);
|
|
120
|
+
refreshTimer = null;
|
|
121
|
+
};
|
|
122
|
+
return createMonitoringPlugin("cost", "cost", "Service spend per provider (daily, month to date, projection against a budget)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
123
|
+
}
|
|
124
|
+
export default createCostPlugin;
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { googleAccessToken } from "./AdsensePlugin.js";
|
|
3
|
+
/** Thrown for non-2xx; `status` lets the plugin tell a dead key (401/403) from an outage. */
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(message, status) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function call(url, init, timeout) {
|
|
12
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const text = await res.text().catch(() => "");
|
|
15
|
+
let msg = text.slice(0, 160);
|
|
16
|
+
try {
|
|
17
|
+
const j = JSON.parse(text);
|
|
18
|
+
msg = j?.error?.message || j?.errors?.[0]?.message || j?.message || j?.detail || j?.msg || (typeof j?.error === "string" ? j.error : msg);
|
|
19
|
+
}
|
|
20
|
+
catch { /* not JSON */ }
|
|
21
|
+
throw new HttpError(`HTTP ${res.status}${msg ? `: ${msg}` : ""}`, res.status);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
const json = async (url, init, timeout) => (await call(url, init, timeout)).json();
|
|
26
|
+
const need = (cfg, ...keys) => {
|
|
27
|
+
const missing = keys.filter((k) => !cfg[k]);
|
|
28
|
+
if (missing.length)
|
|
29
|
+
throw new Error(`${cfg.provider} needs ${missing.join(", ")}`);
|
|
30
|
+
};
|
|
31
|
+
export const nextDay = (date) => {
|
|
32
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
33
|
+
t.setUTCDate(t.getUTCDate() + 1);
|
|
34
|
+
return t.toISOString().slice(0, 10);
|
|
35
|
+
};
|
|
36
|
+
const unixDay = (date) => Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
|
37
|
+
const isoDay = (unixSec) => new Date(unixSec * 1000).toISOString().slice(0, 10);
|
|
38
|
+
/** Accumulates per-day amounts and per-item month-to-date amounts. */
|
|
39
|
+
export function tally(month) {
|
|
40
|
+
const days = new Map();
|
|
41
|
+
const items = new Map();
|
|
42
|
+
return {
|
|
43
|
+
add(date, amount, item) {
|
|
44
|
+
if (!date || !Number.isFinite(amount))
|
|
45
|
+
return;
|
|
46
|
+
days.set(date, (days.get(date) || 0) + amount);
|
|
47
|
+
if (item && date.startsWith(month))
|
|
48
|
+
items.set(item, (items.get(item) || 0) + amount);
|
|
49
|
+
},
|
|
50
|
+
days: () => [...days].map(([date, amount]) => ({ date, amount })),
|
|
51
|
+
breakdown: () => [...items].map(([name, amount]) => ({ name, amount })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// OpenAI: Costs API, admin key. Amounts are numbers in dollars. End is exclusive.
|
|
55
|
+
const openai = async (cfg, { start, end }, timeout) => {
|
|
56
|
+
need(cfg, "adminKey");
|
|
57
|
+
const t = tally(end.slice(0, 7));
|
|
58
|
+
let page;
|
|
59
|
+
do {
|
|
60
|
+
const q = new URLSearchParams({ start_time: String(unixDay(start)), end_time: String(unixDay(nextDay(end))), bucket_width: "1d", limit: "31" });
|
|
61
|
+
q.append("group_by[]", "line_item");
|
|
62
|
+
if (page)
|
|
63
|
+
q.set("page", page);
|
|
64
|
+
const body = await json(`https://api.openai.com/v1/organization/costs?${q}`, { headers: { Authorization: `Bearer ${cfg.adminKey}` } }, timeout);
|
|
65
|
+
for (const b of body.data || []) {
|
|
66
|
+
for (const r of b.results || [])
|
|
67
|
+
t.add(isoDay(b.start_time), Number(r.amount?.value) || 0, r.line_item || undefined);
|
|
68
|
+
}
|
|
69
|
+
page = body.has_more ? body.next_page : undefined;
|
|
70
|
+
} while (page);
|
|
71
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
72
|
+
};
|
|
73
|
+
// Anthropic: Admin API cost report. Amount is a decimal STRING in CENTS.
|
|
74
|
+
const anthropic = async (cfg, { start, end }, timeout) => {
|
|
75
|
+
need(cfg, "adminKey");
|
|
76
|
+
const t = tally(end.slice(0, 7));
|
|
77
|
+
let page;
|
|
78
|
+
do {
|
|
79
|
+
const q = new URLSearchParams({ starting_at: `${start}T00:00:00Z`, ending_at: `${nextDay(end)}T00:00:00Z`, bucket_width: "1d", limit: "31" });
|
|
80
|
+
q.append("group_by[]", "description");
|
|
81
|
+
if (page)
|
|
82
|
+
q.set("page", page);
|
|
83
|
+
const body = await json(`https://api.anthropic.com/v1/organizations/cost_report?${q}`, {
|
|
84
|
+
headers: { "x-api-key": cfg.adminKey, "anthropic-version": "2023-06-01" },
|
|
85
|
+
}, timeout);
|
|
86
|
+
for (const b of body.data || []) {
|
|
87
|
+
const date = String(b.starting_at || "").slice(0, 10);
|
|
88
|
+
for (const r of b.results || [])
|
|
89
|
+
t.add(date, (parseFloat(r.amount) || 0) / 100, r.model || r.description || r.cost_type || undefined);
|
|
90
|
+
}
|
|
91
|
+
page = body.has_more ? body.next_page : undefined;
|
|
92
|
+
} while (page);
|
|
93
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
94
|
+
};
|
|
95
|
+
/** AWS SigV4 for a JSON POST. Small enough not to pull in the SDK for one call. */
|
|
96
|
+
export function sigv4(opts) {
|
|
97
|
+
const now = opts.now || new Date();
|
|
98
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
99
|
+
const day = amzDate.slice(0, 8);
|
|
100
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
101
|
+
const hmac = (k, s) => createHmac("sha256", k).update(s).digest();
|
|
102
|
+
const headers = {
|
|
103
|
+
"content-type": "application/x-amz-json-1.1",
|
|
104
|
+
host: opts.host,
|
|
105
|
+
"x-amz-date": amzDate,
|
|
106
|
+
"x-amz-target": opts.target,
|
|
107
|
+
...(opts.sessionToken ? { "x-amz-security-token": opts.sessionToken } : {}),
|
|
108
|
+
};
|
|
109
|
+
const names = Object.keys(headers).sort();
|
|
110
|
+
const canonical = ["POST", "/", "", ...names.map((n) => `${n}:${headers[n]}`), "", names.join(";"), sha(opts.body)].join("\n");
|
|
111
|
+
const scope = `${day}/${opts.region}/${opts.service}/aws4_request`;
|
|
112
|
+
const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha(canonical)].join("\n");
|
|
113
|
+
const key = hmac(hmac(hmac(hmac(`AWS4${opts.secretAccessKey}`, day), opts.region), opts.service), "aws4_request");
|
|
114
|
+
const sig = createHmac("sha256", key).update(toSign).digest("hex");
|
|
115
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${opts.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${sig}`;
|
|
116
|
+
return headers;
|
|
117
|
+
}
|
|
118
|
+
// AWS: Cost Explorer GetCostAndUsage. $0.01 per request, so this is one call
|
|
119
|
+
// (plus pages). Amounts are dollar strings. End is exclusive.
|
|
120
|
+
const aws = async (cfg, { start, end }, timeout) => {
|
|
121
|
+
need(cfg, "accessKeyId", "secretAccessKey");
|
|
122
|
+
const t = tally(end.slice(0, 7));
|
|
123
|
+
let token;
|
|
124
|
+
let currency = "USD";
|
|
125
|
+
do {
|
|
126
|
+
const body = JSON.stringify({
|
|
127
|
+
TimePeriod: { Start: start, End: nextDay(end) },
|
|
128
|
+
Granularity: "DAILY",
|
|
129
|
+
Metrics: ["UnblendedCost"],
|
|
130
|
+
GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
|
|
131
|
+
...(token ? { NextPageToken: token } : {}),
|
|
132
|
+
});
|
|
133
|
+
const host = "ce.us-east-1.amazonaws.com";
|
|
134
|
+
const headers = sigv4({
|
|
135
|
+
host, region: "us-east-1", service: "ce", target: "AWSInsightsIndexService.GetCostAndUsage", body,
|
|
136
|
+
accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey, sessionToken: cfg.sessionToken,
|
|
137
|
+
});
|
|
138
|
+
const res = await json(`https://${host}/`, { method: "POST", headers, body }, timeout);
|
|
139
|
+
for (const r of res.ResultsByTime || []) {
|
|
140
|
+
const date = r.TimePeriod?.Start;
|
|
141
|
+
for (const g of r.Groups || []) {
|
|
142
|
+
const m = g.Metrics?.UnblendedCost;
|
|
143
|
+
if (m?.Unit)
|
|
144
|
+
currency = m.Unit;
|
|
145
|
+
t.add(date, parseFloat(m?.Amount) || 0, g.Keys?.[0]);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
token = res.NextPageToken;
|
|
149
|
+
} while (token);
|
|
150
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
151
|
+
};
|
|
152
|
+
// Vercel: FOCUS billing charges, streamed as JSONL, many lines per day (one per SKU).
|
|
153
|
+
const vercel = async (cfg, { start, end }, timeout) => {
|
|
154
|
+
need(cfg, "token", "teamId");
|
|
155
|
+
const t = tally(end.slice(0, 7));
|
|
156
|
+
const q = new URLSearchParams({ teamId: cfg.teamId, from: `${start}T00:00:00Z`, to: `${nextDay(end)}T00:00:00Z` });
|
|
157
|
+
const text = await (await call(`https://api.vercel.com/v1/billing/charges?${q}`, { headers: { Authorization: `Bearer ${cfg.token}` } }, timeout)).text();
|
|
158
|
+
let currency = "USD";
|
|
159
|
+
for (const line of text.split("\n")) {
|
|
160
|
+
if (!line.trim())
|
|
161
|
+
continue;
|
|
162
|
+
const c = JSON.parse(line);
|
|
163
|
+
if (c.BillingCurrency)
|
|
164
|
+
currency = c.BillingCurrency;
|
|
165
|
+
t.add(String(c.ChargePeriodStart || "").slice(0, 10), Number(c.BilledCost) || 0, c.ServiceName || undefined);
|
|
166
|
+
}
|
|
167
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
168
|
+
};
|
|
169
|
+
// Fastly: month-to-date invoice only. No daily data exists in the API.
|
|
170
|
+
const fastly = async (cfg, _range, timeout) => {
|
|
171
|
+
need(cfg, "token");
|
|
172
|
+
const inv = await json("https://api.fastly.com/billing/v3/invoices/month-to-date", { headers: { "Fastly-Key": cfg.token, Accept: "application/json" } }, timeout);
|
|
173
|
+
const items = inv.transaction_line_items || [];
|
|
174
|
+
return {
|
|
175
|
+
currency: inv.currency_code || "USD",
|
|
176
|
+
monthToDate: Number(inv.monthly_transaction_amount) || items.reduce((s, i) => s + (Number(i.amount) || 0), 0),
|
|
177
|
+
breakdown: items.map((i) => ({ name: i.product_name || i.description || "item", amount: Number(i.amount) || 0 })),
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
// Cloudflare: billable usage (alpha, PayGo usage only, not plan fees). Called
|
|
181
|
+
// with no range: it then returns the current billing period, which sidesteps
|
|
182
|
+
// the rule that a range must include the subscription's billing-anchor day.
|
|
183
|
+
const cloudflare = async (cfg, { end }, timeout) => {
|
|
184
|
+
need(cfg, "token", "accountId");
|
|
185
|
+
const t = tally(end.slice(0, 7));
|
|
186
|
+
const body = await json(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/billable-usage`, { headers: { Authorization: `Bearer ${cfg.token}` } }, timeout);
|
|
187
|
+
let currency = "USD";
|
|
188
|
+
for (const r of body.result || []) {
|
|
189
|
+
if (r.BillingCurrency)
|
|
190
|
+
currency = r.BillingCurrency;
|
|
191
|
+
t.add(String(r.ChargePeriodStart || "").slice(0, 10), Number(r.BilledCost) || 0, r.ServiceName || r.ChargeDescription || undefined);
|
|
192
|
+
}
|
|
193
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
194
|
+
};
|
|
195
|
+
// PostHog: daily spend series, personal API key with billing:read. End is INCLUSIVE.
|
|
196
|
+
const posthog = async (cfg, { start, end }, timeout) => {
|
|
197
|
+
need(cfg, "apiKey");
|
|
198
|
+
const host = (cfg.host || "https://us.posthog.com").replace(/\/$/, "");
|
|
199
|
+
const t = tally(end.slice(0, 7));
|
|
200
|
+
const q = new URLSearchParams({ start_date: start, end_date: end, interval: "day", breakdowns: JSON.stringify(["type"]) });
|
|
201
|
+
let url = `${host}/api/billing/spend/?${q}`;
|
|
202
|
+
while (url) {
|
|
203
|
+
const body = await json(url, { headers: { Authorization: `Bearer ${cfg.apiKey}` } }, timeout);
|
|
204
|
+
for (const s of body.results || []) {
|
|
205
|
+
const dates = s.dates || [];
|
|
206
|
+
dates.forEach((d, i) => t.add(d, Number(s.data?.[i]) || 0, s.breakdown_value || s.label || undefined));
|
|
207
|
+
}
|
|
208
|
+
url = body.next || null;
|
|
209
|
+
}
|
|
210
|
+
return { currency: "USD", days: t.days(), breakdown: t.breakdown() };
|
|
211
|
+
};
|
|
212
|
+
// Google Ads: GAQL over searchStream. cost_micros is an int64 string. Dates are
|
|
213
|
+
// the ACCOUNT's time zone, which this treats as UTC days (off by a few hours at most).
|
|
214
|
+
const googleAds = async (cfg, { start, end }, timeout) => {
|
|
215
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "developerToken", "customerId");
|
|
216
|
+
const t = tally(end.slice(0, 7));
|
|
217
|
+
const customer = String(cfg.customerId).replace(/-/g, "");
|
|
218
|
+
const headers = {
|
|
219
|
+
Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}`,
|
|
220
|
+
"developer-token": cfg.developerToken,
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
...(cfg.loginCustomerId ? { "login-customer-id": String(cfg.loginCustomerId).replace(/-/g, "") } : {}),
|
|
223
|
+
};
|
|
224
|
+
const query = `SELECT segments.date, customer.currency_code, campaign.name, metrics.cost_micros FROM campaign WHERE segments.date BETWEEN '${start}' AND '${end}'`;
|
|
225
|
+
const batches = await json(`https://googleads.googleapis.com/${cfg.apiVersion || "v26"}/customers/${customer}/googleAds:searchStream`, {
|
|
226
|
+
method: "POST", headers, body: JSON.stringify({ query }),
|
|
227
|
+
}, timeout);
|
|
228
|
+
let currency = "USD";
|
|
229
|
+
for (const b of Array.isArray(batches) ? batches : [batches]) {
|
|
230
|
+
for (const r of b.results || []) {
|
|
231
|
+
if (r.customer?.currencyCode)
|
|
232
|
+
currency = r.customer.currencyCode;
|
|
233
|
+
t.add(r.segments?.date, (Number(r.metrics?.costMicros) || 0) / 1e6, r.campaign?.name);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
237
|
+
};
|
|
238
|
+
// Google Cloud: there is no spend REST API. The supported source is the Cloud
|
|
239
|
+
// Billing export to BigQuery (standard usage cost), queried here with
|
|
240
|
+
// jobs.query. Net cost = cost + credits (credits are negative). The export
|
|
241
|
+
// lands hours late, so today and yesterday fill in after the fact.
|
|
242
|
+
// ponytail: OAuth refresh token (scope bigquery.readonly), same as the other
|
|
243
|
+
// Google sources. Add service-account JWT auth if a human token is unwelcome.
|
|
244
|
+
const gcp = async (cfg, { start, end }, timeout) => {
|
|
245
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "projectId", "table");
|
|
246
|
+
// `project.dataset.gcp_billing_export_v1_XXXXXX`. Validated because it is
|
|
247
|
+
// spliced into SQL; BigQuery cannot bind a table name as a parameter.
|
|
248
|
+
if (!/^[\w-]+\.[\w]+\.[\w]+$/.test(cfg.table))
|
|
249
|
+
throw new Error("gcp table must look like project.dataset.gcp_billing_export_v1_XXXXXX");
|
|
250
|
+
const t = tally(end.slice(0, 7));
|
|
251
|
+
const query = `SELECT FORMAT_DATE('%F', DATE(usage_start_time)) AS day, service.description AS item, ANY_VALUE(currency) AS currency,
|
|
252
|
+
SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS amount
|
|
253
|
+
FROM \`${cfg.table}\`
|
|
254
|
+
WHERE DATE(_PARTITIONTIME) >= DATE_SUB(@start, INTERVAL 1 DAY) AND DATE(usage_start_time) BETWEEN @start AND @end
|
|
255
|
+
GROUP BY day, item`;
|
|
256
|
+
const body = await json(`https://bigquery.googleapis.com/bigquery/v2/projects/${cfg.projectId}/queries`, {
|
|
257
|
+
method: "POST",
|
|
258
|
+
headers: {
|
|
259
|
+
Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}`,
|
|
260
|
+
"Content-Type": "application/json",
|
|
261
|
+
},
|
|
262
|
+
body: JSON.stringify({
|
|
263
|
+
query, useLegacySql: false, timeoutMs: timeout, parameterMode: "NAMED",
|
|
264
|
+
queryParameters: [
|
|
265
|
+
{ name: "start", parameterType: { type: "DATE" }, parameterValue: { value: start } },
|
|
266
|
+
{ name: "end", parameterType: { type: "DATE" }, parameterValue: { value: end } },
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
}, timeout + 5000);
|
|
270
|
+
if (body.jobComplete === false)
|
|
271
|
+
throw new Error("BigQuery query did not finish in time");
|
|
272
|
+
let currency = "USD";
|
|
273
|
+
// Rows come back as { f: [{ v }] } in SELECT order, every value a string.
|
|
274
|
+
for (const r of body.rows || []) {
|
|
275
|
+
const [day, item, cur, amount] = (r.f || []).map((c) => c?.v);
|
|
276
|
+
if (cur)
|
|
277
|
+
currency = cur;
|
|
278
|
+
t.add(day, Number(amount) || 0, item || undefined);
|
|
279
|
+
}
|
|
280
|
+
return { currency, days: t.days(), breakdown: t.breakdown() };
|
|
281
|
+
};
|
|
282
|
+
// Meta Ads: Marketing API insights, one row per day. spend is a numeric string.
|
|
283
|
+
const metaAds = async (cfg, { start, end }, timeout) => {
|
|
284
|
+
need(cfg, "accessToken", "adAccountId");
|
|
285
|
+
const t = tally(end.slice(0, 7));
|
|
286
|
+
const acct = String(cfg.adAccountId).replace(/^act_/, "");
|
|
287
|
+
const v = cfg.apiVersion || "v26.0";
|
|
288
|
+
const auth = { headers: { Authorization: `Bearer ${cfg.accessToken}` } };
|
|
289
|
+
const { currency } = await json(`https://graph.facebook.com/${v}/act_${acct}?fields=currency`, auth, timeout);
|
|
290
|
+
const q = new URLSearchParams({
|
|
291
|
+
fields: "spend,campaign_name", level: "campaign", time_increment: "1",
|
|
292
|
+
time_range: JSON.stringify({ since: start, until: end }), limit: "500",
|
|
293
|
+
});
|
|
294
|
+
let url = `https://graph.facebook.com/${v}/act_${acct}/insights?${q}`;
|
|
295
|
+
while (url) {
|
|
296
|
+
const body = await json(url, auth, timeout);
|
|
297
|
+
for (const r of body.data || [])
|
|
298
|
+
t.add(r.date_start, parseFloat(r.spend) || 0, r.campaign_name);
|
|
299
|
+
url = body.paging?.next || null;
|
|
300
|
+
}
|
|
301
|
+
return { currency: currency || "USD", days: t.days(), breakdown: t.breakdown() };
|
|
302
|
+
};
|
|
303
|
+
// Google Workspace: there is no invoice API, so this is seats x price. The
|
|
304
|
+
// Licensing API lists every assigned seat with its plan (SKU), and seats are
|
|
305
|
+
// priced from Google's published list prices (USD per seat per month) unless
|
|
306
|
+
// `prices` overrides them by SKU name or id. The bill accrues evenly through the
|
|
307
|
+
// month, so each day carries 1/daysInMonth of it and the projection is the
|
|
308
|
+
// monthly bill itself.
|
|
309
|
+
// ponytail: today's seat count is applied to the whole 30-day trace (no seat
|
|
310
|
+
// history), and Enterprise plans have no public price, so they cost 0 until
|
|
311
|
+
// `prices` names one.
|
|
312
|
+
export const WORKSPACE_LIST_PRICE = {
|
|
313
|
+
"1010020027": { flexible: 8.4, annual: 7 }, // Business Starter
|
|
314
|
+
"1010020028": { flexible: 16.8, annual: 14 }, // Business Standard
|
|
315
|
+
"1010020025": { flexible: 26.4, annual: 22 }, // Business Plus
|
|
316
|
+
};
|
|
317
|
+
const workspace = async (cfg, { start, end }, timeout) => {
|
|
318
|
+
need(cfg, "clientId", "clientSecret", "refreshToken", "customerId");
|
|
319
|
+
const auth = { headers: { Authorization: `Bearer ${await googleAccessToken({ clientId: cfg.clientId, clientSecret: cfg.clientSecret, refreshToken: cfg.refreshToken }, timeout)}` } };
|
|
320
|
+
const seats = new Map();
|
|
321
|
+
for (const product of cfg.products || ["Google-Apps"]) {
|
|
322
|
+
let page = "";
|
|
323
|
+
do {
|
|
324
|
+
const q = new URLSearchParams({ customerId: cfg.customerId, maxResults: "1000", ...(page ? { pageToken: page } : {}) });
|
|
325
|
+
const body = await json(`https://licensing.googleapis.com/apps/licensing/v1/product/${encodeURIComponent(product)}/users?${q}`, auth, timeout);
|
|
326
|
+
for (const a of body.items || []) {
|
|
327
|
+
const e = seats.get(a.skuId) || { name: a.skuName || a.skuId, count: 0 };
|
|
328
|
+
e.count++;
|
|
329
|
+
seats.set(a.skuId, e);
|
|
330
|
+
}
|
|
331
|
+
page = body.nextPageToken || "";
|
|
332
|
+
} while (page);
|
|
333
|
+
}
|
|
334
|
+
const plan = cfg.plan === "annual" ? "annual" : "flexible";
|
|
335
|
+
const priceOf = (sku, name) => Number(cfg.prices?.[sku] ?? cfg.prices?.[name] ?? WORKSPACE_LIST_PRICE[sku]?.[plan] ?? 0);
|
|
336
|
+
const lines = [...seats].map(([sku, { name, count }]) => ({ name: `${name} × ${count}`, monthly: count * priceOf(sku, name) }));
|
|
337
|
+
const monthly = lines.reduce((s, l) => s + l.monthly, 0);
|
|
338
|
+
const [y = 1970, m = 1] = end.split("-").map(Number);
|
|
339
|
+
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
340
|
+
const days = [];
|
|
341
|
+
for (let d = start; d <= end; d = nextDay(d))
|
|
342
|
+
days.push({ date: d, amount: monthly / dim });
|
|
343
|
+
const dom = Number(end.slice(8, 10));
|
|
344
|
+
return {
|
|
345
|
+
currency: "USD",
|
|
346
|
+
days,
|
|
347
|
+
// Month to date per plan, so the breakdown sums to the row's MTD.
|
|
348
|
+
breakdown: lines.map((l) => ({ name: l.name, amount: (l.monthly / dim) * dom })),
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
export const PROVIDERS = {
|
|
352
|
+
openai, anthropic, aws, vercel, fastly, cloudflare, posthog, gcp, workspace, "google-ads": googleAds, "meta-ads": metaAds,
|
|
353
|
+
};
|
|
@@ -43,6 +43,8 @@ export function buildReport(target, raw, rates) {
|
|
|
43
43
|
interfaces,
|
|
44
44
|
loops: interfaces.filter((i) => i.loop === "loop").map((i) => i.name),
|
|
45
45
|
blocked: interfaces.filter((i) => i.loop === "blocked").map((i) => i.name),
|
|
46
|
+
// Every configured uplink, so the dashboard can mark it while it's healthy too.
|
|
47
|
+
uplinks: (target.uplinkPorts || []).map((id) => `port ${id}`),
|
|
46
48
|
downUplinks: downUplinks.map((id) => `port ${id}`),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -129,11 +129,18 @@ export function createWebsitePlugin() {
|
|
|
129
129
|
let publicResult;
|
|
130
130
|
try {
|
|
131
131
|
const ctrl = new AbortController();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Always bounded: with no timer the runtime's own 5 minute limit
|
|
133
|
+
// applied, and a hung page held up every check queued behind it.
|
|
134
|
+
// Cleared after the body, which can stall as well as the headers.
|
|
135
|
+
const t = setTimeout(() => ctrl.abort(), ep.timeout || 30000);
|
|
136
|
+
let res, body;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow", headers: ep.headers });
|
|
139
|
+
body = await res.text();
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
135
142
|
clearTimeout(t);
|
|
136
|
-
|
|
143
|
+
}
|
|
137
144
|
const duration = Date.now() - start;
|
|
138
145
|
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(body, res.status, ep.expectedStrings || [], ep.unexpectedStrings || [], ep.expectedStatusCode);
|
|
139
146
|
publicResult = {
|
|
@@ -226,11 +233,22 @@ export function createWebsitePlugin() {
|
|
|
226
233
|
merged.set(key(e), e);
|
|
227
234
|
return [...merged.values()];
|
|
228
235
|
};
|
|
236
|
+
// One pass at a time. Overlapping passes report out of order, so a check
|
|
237
|
+
// that hung minutes ago lands after a newer success and re-raises the alert.
|
|
238
|
+
let running = false;
|
|
229
239
|
const refreshFn = async (plugin) => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
240
|
+
if (running)
|
|
241
|
+
return;
|
|
242
|
+
running = true;
|
|
243
|
+
try {
|
|
244
|
+
const eps = await endpointsOf(plugin);
|
|
245
|
+
for (const ep of eps) {
|
|
246
|
+
if (ep?.url)
|
|
247
|
+
await check(plugin, ep);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
running = false;
|
|
234
252
|
}
|
|
235
253
|
};
|
|
236
254
|
const monitorFn = async (plugin) => {
|