@daemonitor/plugins 1.1.31 → 1.1.33
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
|
@@ -11,6 +11,8 @@ import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
|
|
|
11
11
|
import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
|
|
12
12
|
import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
|
|
13
13
|
import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
|
|
14
|
+
import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
|
|
15
|
+
import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
|
|
14
16
|
// In future, implement other plugin factory functions using the same pattern
|
|
15
17
|
// import { createOSPlugin } from "./monitoring/OSPlugin.js"
|
|
16
18
|
// import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin.js"
|
|
@@ -33,6 +35,8 @@ export default {
|
|
|
33
35
|
ThreeCX: createThreeCXPlugin,
|
|
34
36
|
Tplink: createTplinkPlugin,
|
|
35
37
|
Patroni: createPatroniPlugin,
|
|
38
|
+
Adsense: createAdsensePlugin,
|
|
39
|
+
Ga4: createGa4Plugin,
|
|
36
40
|
// CloudflarePlugin: createCloudflarePlugin,
|
|
37
41
|
// EwelinkPlugin: createEwelinkPlugin,
|
|
38
42
|
// MongoDBPlugin: createMongoDBPlugin,
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
+
// Google AdSense earnings monitor.
|
|
3
|
+
//
|
|
4
|
+
// AdSense has no service-account access, so this runs on a user refresh token
|
|
5
|
+
// (scope adsense.readonly) minted once by hand. Each poll trades it for a
|
|
6
|
+
// one-hour access token and pulls ONE report: earnings and page views per day,
|
|
7
|
+
// from whichever is earlier of the 1st of the month or 29 days ago, through
|
|
8
|
+
// today. Every figure on the tile (today, yesterday, 7 days, month to date, RPM,
|
|
9
|
+
// the daily sparkline) is summed locally from those rows, so the numbers cannot
|
|
10
|
+
// disagree with each other the way separate report calls can mid-update.
|
|
11
|
+
//
|
|
12
|
+
// Dates are the ACCOUNT's time zone, not this host's: "today" for an account in
|
|
13
|
+
// New York is still yesterday on a UTC box for a few hours every night.
|
|
14
|
+
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
15
|
+
const API = "https://adsense.googleapis.com/v2";
|
|
16
|
+
/** YYYY-MM-DD shifted by whole days. */
|
|
17
|
+
export function shiftDate(date, days) {
|
|
18
|
+
const t = new Date(`${date}T00:00:00Z`);
|
|
19
|
+
t.setUTCDate(t.getUTCDate() + days);
|
|
20
|
+
return t.toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
/** Today's date as the account sees it. */
|
|
23
|
+
export function todayIn(timeZone, now = new Date()) {
|
|
24
|
+
// en-CA formats as YYYY-MM-DD.
|
|
25
|
+
return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone || "UTC" }).format(now);
|
|
26
|
+
}
|
|
27
|
+
/** Earlier of the 1st of this month and 29 days back, so one report covers both windows. */
|
|
28
|
+
export function reportStart(today) {
|
|
29
|
+
const monthStart = `${today.slice(0, 7)}-01`;
|
|
30
|
+
const back = shiftDate(today, -29);
|
|
31
|
+
return back < monthStart ? back : monthStart;
|
|
32
|
+
}
|
|
33
|
+
export function reportUrl(account, start, end, bySite = false) {
|
|
34
|
+
const q = new URLSearchParams({ dateRange: "CUSTOM", reportingTimeZone: "ACCOUNT_TIME_ZONE", dimensions: "DATE" });
|
|
35
|
+
// DOMAIN_NAME, not OWNED_SITE_DOMAIN_NAME: the owned-site rows nest (archpaper.com
|
|
36
|
+
// contains www.archpaper.com and jobs.archpaper.com), so they double count.
|
|
37
|
+
if (bySite)
|
|
38
|
+
q.append("dimensions", "DOMAIN_NAME");
|
|
39
|
+
for (const [k, v] of [["startDate", start], ["endDate", end]]) {
|
|
40
|
+
const [y, m, d] = v.split("-").map(Number);
|
|
41
|
+
q.set(`${k}.year`, String(y));
|
|
42
|
+
q.set(`${k}.month`, String(m));
|
|
43
|
+
q.set(`${k}.day`, String(d));
|
|
44
|
+
}
|
|
45
|
+
q.append("metrics", "ESTIMATED_EARNINGS");
|
|
46
|
+
q.append("metrics", "PAGE_VIEWS");
|
|
47
|
+
q.append("metrics", "CLICKS");
|
|
48
|
+
return `${API}/${account}/reports:generate?${q}`;
|
|
49
|
+
}
|
|
50
|
+
/** reports:generate response -> per-day rows. Columns are found by header name, not position. */
|
|
51
|
+
export function parseReport(report) {
|
|
52
|
+
const headers = Array.isArray(report?.headers) ? report.headers : [];
|
|
53
|
+
const col = (name) => headers.findIndex((h) => h?.name === name);
|
|
54
|
+
const [iDate, iEarn, iViews, iClicks] = [col("DATE"), col("ESTIMATED_EARNINGS"), col("PAGE_VIEWS"), col("CLICKS")];
|
|
55
|
+
if (iDate < 0 || iEarn < 0)
|
|
56
|
+
throw new Error("unexpected report shape (no DATE/ESTIMATED_EARNINGS column)");
|
|
57
|
+
// A day with no traffic has no row at all, so an empty report is valid.
|
|
58
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
59
|
+
return {
|
|
60
|
+
currency: headers[iEarn]?.currencyCode || "USD",
|
|
61
|
+
days: rows.map((r) => ({
|
|
62
|
+
date: String(r?.cells?.[iDate]?.value || ""),
|
|
63
|
+
earnings: Number(r?.cells?.[iEarn]?.value) || 0,
|
|
64
|
+
pageViews: iViews < 0 ? 0 : Number(r?.cells?.[iViews]?.value) || 0,
|
|
65
|
+
clicks: iClicks < 0 ? 0 : Number(r?.cells?.[iClicks]?.value) || 0,
|
|
66
|
+
})),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** A DATE x DOMAIN_NAME report -> per-day rows keyed by site (hostname without a leading www). */
|
|
70
|
+
export function parseSiteReport(report) {
|
|
71
|
+
const headers = Array.isArray(report?.headers) ? report.headers : [];
|
|
72
|
+
const iDomain = headers.findIndex((h) => h?.name === "DOMAIN_NAME");
|
|
73
|
+
const out = new Map();
|
|
74
|
+
if (iDomain < 0)
|
|
75
|
+
return out;
|
|
76
|
+
const { days } = parseReport(report);
|
|
77
|
+
const rows = Array.isArray(report?.rows) ? report.rows : [];
|
|
78
|
+
rows.forEach((r, i) => {
|
|
79
|
+
// www.example.com and example.com are one site to a reader, so they merge.
|
|
80
|
+
const domain = String(r?.cells?.[iDomain]?.value || "").replace(/^www\./, "");
|
|
81
|
+
if (!domain)
|
|
82
|
+
return;
|
|
83
|
+
if (!out.has(domain))
|
|
84
|
+
out.set(domain, []);
|
|
85
|
+
const list = out.get(domain);
|
|
86
|
+
const same = list.find((d) => d.date === days[i].date);
|
|
87
|
+
if (same) {
|
|
88
|
+
same.earnings += days[i].earnings;
|
|
89
|
+
same.pageViews += days[i].pageViews;
|
|
90
|
+
same.clicks += days[i].clicks;
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
list.push({ ...days[i] });
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
const MAX_SITES = 20;
|
|
98
|
+
/**
|
|
99
|
+
* Per-site figures, biggest earner first. Domains that earned nothing in the
|
|
100
|
+
* window are dropped: AdSense lists every hostname an ad tag loaded on, which
|
|
101
|
+
* includes admin hosts and previews that are noise here. `daily` is earnings
|
|
102
|
+
* only, to keep the payload small.
|
|
103
|
+
*/
|
|
104
|
+
export function summarizeSites(bySite, today) {
|
|
105
|
+
return [...bySite.entries()]
|
|
106
|
+
.map(([domain, days]) => {
|
|
107
|
+
const sum = summarize(days, today);
|
|
108
|
+
return { domain, ...sum, daily: sum.daily.map((d) => cents(d.earnings)), window: days.reduce((t, d) => t + d.earnings, 0) };
|
|
109
|
+
})
|
|
110
|
+
.filter((s) => s.window > 0)
|
|
111
|
+
.sort((a, b) => b.window - a.window)
|
|
112
|
+
.slice(0, MAX_SITES)
|
|
113
|
+
.map(({ window: _window, ...site }) => site);
|
|
114
|
+
}
|
|
115
|
+
const cents = (n) => Math.round(n * 100) / 100;
|
|
116
|
+
/** Roll per-day rows up into the figures the tile shows. `last7` includes today, as AdSense's own "Last 7 days" does. */
|
|
117
|
+
export function summarize(days, today) {
|
|
118
|
+
const by = new Map(days.map((d) => [d.date, d]));
|
|
119
|
+
const day = (date) => by.get(date) || { date, earnings: 0, pageViews: 0, clicks: 0 };
|
|
120
|
+
const span = (n) => Array.from({ length: n }, (_, i) => day(shiftDate(today, i - n + 1)));
|
|
121
|
+
const week = span(7);
|
|
122
|
+
const last7 = week.reduce((s, d) => s + d.earnings, 0);
|
|
123
|
+
const pageViews7 = week.reduce((s, d) => s + d.pageViews, 0);
|
|
124
|
+
const month = today.slice(0, 7);
|
|
125
|
+
return {
|
|
126
|
+
today: cents(day(today).earnings),
|
|
127
|
+
yesterday: cents(day(shiftDate(today, -1)).earnings),
|
|
128
|
+
last7: cents(last7),
|
|
129
|
+
monthToDate: cents(days.filter((d) => d.date.startsWith(month)).reduce((s, d) => s + d.earnings, 0)),
|
|
130
|
+
pageViews7,
|
|
131
|
+
clicks7: week.reduce((s, d) => s + d.clicks, 0),
|
|
132
|
+
// Page RPM over the same 7 days: earnings per thousand page views.
|
|
133
|
+
rpm: pageViews7 ? cents((last7 / pageViews7) * 1000) : 0,
|
|
134
|
+
// Gap-filled so the sparkline has one point per day, oldest first.
|
|
135
|
+
daily: span(30),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export async function getJson(url, init, timeout) {
|
|
139
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
|
|
140
|
+
const body = await res.json().catch(() => ({}));
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const e = new Error(body?.error_description || body?.error?.message || body?.error || `HTTP ${res.status}`);
|
|
143
|
+
e.code = typeof body?.error === "string" ? body.error : body?.error?.status;
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
return body;
|
|
147
|
+
}
|
|
148
|
+
/** Trade a refresh token for a one-hour access token. Shared with the ga4 plugin. */
|
|
149
|
+
export async function googleAccessToken(cfg, timeout) {
|
|
150
|
+
const tok = await getJson(TOKEN_URL, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
153
|
+
body: new URLSearchParams({
|
|
154
|
+
grant_type: "refresh_token",
|
|
155
|
+
client_id: cfg.clientId || "",
|
|
156
|
+
client_secret: cfg.clientSecret || "",
|
|
157
|
+
refresh_token: cfg.refreshToken || "",
|
|
158
|
+
}).toString(),
|
|
159
|
+
}, timeout);
|
|
160
|
+
return tok.access_token;
|
|
161
|
+
}
|
|
162
|
+
export function createAdsensePlugin() {
|
|
163
|
+
let refreshTimer = null;
|
|
164
|
+
// Resolved once: the account and its time zone do not change between polls.
|
|
165
|
+
let account = null;
|
|
166
|
+
const collect = async (cfg) => {
|
|
167
|
+
const timeout = cfg.timeout || 15000;
|
|
168
|
+
const auth = { headers: { Authorization: `Bearer ${await googleAccessToken(cfg, timeout)}` } };
|
|
169
|
+
if (!account) {
|
|
170
|
+
const list = (await getJson(`${API}/accounts`, auth, timeout))?.accounts || [];
|
|
171
|
+
const a = cfg.accountId ? list.find((x) => x.name === cfg.accountId) : list[0];
|
|
172
|
+
if (!a)
|
|
173
|
+
throw new Error(cfg.accountId ? `account ${cfg.accountId} not visible to this token` : "no AdSense account visible to this token");
|
|
174
|
+
account = { name: a.name, displayName: a.displayName, timeZone: a.timeZone?.id };
|
|
175
|
+
}
|
|
176
|
+
const today = todayIn(account.timeZone);
|
|
177
|
+
const start = reportStart(today);
|
|
178
|
+
const { currency, days } = parseReport(await getJson(reportUrl(account.name, start, today), auth, timeout));
|
|
179
|
+
// The per-site breakdown is extra: if it fails, the account totals still report.
|
|
180
|
+
let sites = [];
|
|
181
|
+
try {
|
|
182
|
+
sites = summarizeSites(parseSiteReport(await getJson(reportUrl(account.name, start, today, true), auth, timeout)), today);
|
|
183
|
+
}
|
|
184
|
+
catch { /* totals only */ }
|
|
185
|
+
return {
|
|
186
|
+
// The account's own name ("The Architect's Newspaper") labels the service.
|
|
187
|
+
// It overrides the plugin's instance name, which only says "AdSense".
|
|
188
|
+
...(account.displayName ? { name: account.displayName } : {}),
|
|
189
|
+
accountId: account.name,
|
|
190
|
+
currency,
|
|
191
|
+
asOf: today,
|
|
192
|
+
...summarize(days, today),
|
|
193
|
+
sites,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const refreshFn = async (plugin) => {
|
|
197
|
+
const cfg = (plugin.config || {});
|
|
198
|
+
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
|
199
|
+
await plugin.send({ error: "adsense plugin needs clientId, clientSecret and refreshToken" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
await plugin.send(await collect(cfg));
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
// invalid_grant = the refresh token was revoked or expired. Nothing the
|
|
207
|
+
// next poll can fix, so it is flagged apart from a transient API error.
|
|
208
|
+
await plugin.send({
|
|
209
|
+
error: String(e?.message || e).slice(0, 160),
|
|
210
|
+
...(e?.code === "invalid_grant" ? { authExpired: true } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
const monitorFn = async (plugin) => {
|
|
215
|
+
await refreshFn(plugin);
|
|
216
|
+
// AdSense figures lag by hours, so 15 minutes loses nothing. The server
|
|
217
|
+
// allows these rows 30 minutes of silence before calling them offline.
|
|
218
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 900000);
|
|
219
|
+
};
|
|
220
|
+
const teardownFn = async () => {
|
|
221
|
+
if (refreshTimer)
|
|
222
|
+
clearInterval(refreshTimer);
|
|
223
|
+
refreshTimer = null;
|
|
224
|
+
};
|
|
225
|
+
return createMonitoringPlugin("adsense", "adsense", "Google AdSense earnings (today, yesterday, 7 days, month to date, page RPM)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
226
|
+
}
|
|
227
|
+
export default createAdsensePlugin;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
2
|
+
import { getJson, googleAccessToken, shiftDate, todayIn } from "./AdsensePlugin.js";
|
|
3
|
+
// Google Analytics 4 overview across every account and property the Google
|
|
4
|
+
// login can see.
|
|
5
|
+
//
|
|
6
|
+
// Runs on a user refresh token with scope analytics.readonly, minted the same
|
|
7
|
+
// way as the adsense plugin's (client/etc/adsense-auth.mjs --source=ga4).
|
|
8
|
+
//
|
|
9
|
+
// Each poll lists the account/property tree from the Admin API, then for each
|
|
10
|
+
// property asks the Data API for: active users right now (realtime), users,
|
|
11
|
+
// sessions and page views for today, yesterday and the last 7 days, and 30
|
|
12
|
+
// days of daily figures. 7-day users come from GA itself, never from summing
|
|
13
|
+
// days: one person on three days is one user, not three.
|
|
14
|
+
//
|
|
15
|
+
// The report carries its own 30-day history, so the server stores no history
|
|
16
|
+
// rows for this type. Hiding properties and nicknames live in the portal.
|
|
17
|
+
const ADMIN = "https://analyticsadmin.googleapis.com/v1beta";
|
|
18
|
+
const DATA = "https://analyticsdata.googleapis.com/v1beta";
|
|
19
|
+
const DAYS = 30;
|
|
20
|
+
// Parallel property requests. GA quotas are per property, so this only bounds
|
|
21
|
+
// how hard one poll hits the network.
|
|
22
|
+
const CONCURRENCY = 4;
|
|
23
|
+
const ZERO = { users: 0, sessions: 0, views: 0 };
|
|
24
|
+
const METRICS = [{ name: "activeUsers" }, { name: "sessions" }, { name: "screenPageViews" }];
|
|
25
|
+
/** accountSummaries response -> accounts with their GA4 properties. */
|
|
26
|
+
export function parseAccounts(summaries) {
|
|
27
|
+
return (summaries || []).map((a) => ({
|
|
28
|
+
id: String(a?.account || "").replace(/^accounts\//, ""),
|
|
29
|
+
name: String(a?.displayName || a?.account || ""),
|
|
30
|
+
properties: (a?.propertySummaries || [])
|
|
31
|
+
.filter((p) => !p?.propertyType || p.propertyType === "PROPERTY_TYPE_ORDINARY")
|
|
32
|
+
.map((p) => ({ id: String(p?.property || "").replace(/^properties\//, ""), name: String(p?.displayName || p?.property || "") })),
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
const num = (v) => Number(v?.value) || 0;
|
|
36
|
+
const totals = (row) => row ? { users: num(row.metricValues?.[0]), sessions: num(row.metricValues?.[1]), views: num(row.metricValues?.[2]) } : { ...ZERO };
|
|
37
|
+
/**
|
|
38
|
+
* batchRunReports response -> the figures the portal shows.
|
|
39
|
+
* Report 0: no dimensions, three named date ranges (today, yesterday, last7).
|
|
40
|
+
* Report 1: one row per date over the last 30 days.
|
|
41
|
+
*/
|
|
42
|
+
export function parseProperty(batch, now = new Date()) {
|
|
43
|
+
const [summary, byDate] = batch?.reports || [];
|
|
44
|
+
// A multi-range report adds a "dateRange" dimension holding each range's name.
|
|
45
|
+
const range = (name) => totals((summary?.rows || []).find((r) => r?.dimensionValues?.[0]?.value === name));
|
|
46
|
+
// Gap-fill the 30 days in the property's own time zone: GA leaves days with
|
|
47
|
+
// no traffic out entirely.
|
|
48
|
+
const today = todayIn(byDate?.metadata?.timeZone || summary?.metadata?.timeZone, now);
|
|
49
|
+
const by = new Map();
|
|
50
|
+
for (const r of byDate?.rows || []) {
|
|
51
|
+
const d = String(r?.dimensionValues?.[0]?.value || "");
|
|
52
|
+
if (d.length === 8)
|
|
53
|
+
by.set(`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6)}`, totals(r));
|
|
54
|
+
}
|
|
55
|
+
const dates = Array.from({ length: DAYS }, (_, i) => shiftDate(today, i - DAYS + 1));
|
|
56
|
+
const days = dates.map((d) => by.get(d) || ZERO);
|
|
57
|
+
return {
|
|
58
|
+
asOf: today,
|
|
59
|
+
today: range("today"),
|
|
60
|
+
yesterday: range("yesterday"),
|
|
61
|
+
last7: range("last7"),
|
|
62
|
+
daily: {
|
|
63
|
+
start: dates[0],
|
|
64
|
+
users: days.map((d) => d.users),
|
|
65
|
+
sessions: days.map((d) => d.sessions),
|
|
66
|
+
views: days.map((d) => d.views),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** runRealtimeReport -> users active in the last 30 minutes. No rows means zero. */
|
|
71
|
+
export const parseRealtime = (r) => num(r?.rows?.[0]?.metricValues?.[0]);
|
|
72
|
+
export const BATCH_BODY = {
|
|
73
|
+
requests: [
|
|
74
|
+
{
|
|
75
|
+
dateRanges: [
|
|
76
|
+
{ startDate: "today", endDate: "today", name: "today" },
|
|
77
|
+
{ startDate: "yesterday", endDate: "yesterday", name: "yesterday" },
|
|
78
|
+
{ startDate: "6daysAgo", endDate: "today", name: "last7" },
|
|
79
|
+
],
|
|
80
|
+
metrics: METRICS,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
dateRanges: [{ startDate: `${DAYS - 1}daysAgo`, endDate: "today" }],
|
|
84
|
+
dimensions: [{ name: "date" }],
|
|
85
|
+
metrics: METRICS,
|
|
86
|
+
limit: DAYS + 5,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
async function inBatches(items, size, fn) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (let i = 0; i < items.length; i += size)
|
|
93
|
+
out.push(...(await Promise.all(items.slice(i, i + size).map(fn))));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
export function createGa4Plugin() {
|
|
97
|
+
let refreshTimer = null;
|
|
98
|
+
const collect = async (cfg) => {
|
|
99
|
+
const timeout = cfg.timeout || 20000;
|
|
100
|
+
const token = await googleAccessToken(cfg, timeout);
|
|
101
|
+
const get = (url) => getJson(url, { headers: { Authorization: `Bearer ${token}` } }, timeout);
|
|
102
|
+
const post = (url, body) => getJson(url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify(body),
|
|
106
|
+
}, timeout);
|
|
107
|
+
// Every account and property this login can see, across pages.
|
|
108
|
+
const summaries = [];
|
|
109
|
+
let page = "";
|
|
110
|
+
do {
|
|
111
|
+
const r = await get(`${ADMIN}/accountSummaries?pageSize=200${page ? `&pageToken=${encodeURIComponent(page)}` : ""}`);
|
|
112
|
+
summaries.push(...(r?.accountSummaries || []));
|
|
113
|
+
page = r?.nextPageToken || "";
|
|
114
|
+
} while (page);
|
|
115
|
+
const allow = Array.isArray(cfg.accounts) && cfg.accounts.length ? new Set(cfg.accounts.map(String)) : null;
|
|
116
|
+
// Filtered before any report is requested: an account outside the list is
|
|
117
|
+
// never queried, so its figures never reach this customer.
|
|
118
|
+
const accounts = parseAccounts(summaries).filter((a) => !allow || allow.has(a.id));
|
|
119
|
+
for (const account of accounts) {
|
|
120
|
+
account.properties = await inBatches(account.properties, CONCURRENCY, async (p) => {
|
|
121
|
+
// One property failing (no access to its data, quota) must not hide the rest.
|
|
122
|
+
try {
|
|
123
|
+
const [batch, realtime] = await Promise.all([
|
|
124
|
+
post(`${DATA}/properties/${p.id}:batchRunReports`, BATCH_BODY),
|
|
125
|
+
post(`${DATA}/properties/${p.id}:runRealtimeReport`, { metrics: [{ name: "activeUsers" }] }).catch(() => null),
|
|
126
|
+
]);
|
|
127
|
+
return { ...p, ...parseProperty(batch), activeNow: realtime ? parseRealtime(realtime) : null };
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return { ...p, error: String(e?.message || e).slice(0, 160) };
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const properties = accounts.reduce((n, a) => n + a.properties.length, 0);
|
|
135
|
+
return { name: "Google Analytics", accounts, properties };
|
|
136
|
+
};
|
|
137
|
+
const refreshFn = async (plugin) => {
|
|
138
|
+
const cfg = (plugin.config || {});
|
|
139
|
+
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
|
|
140
|
+
await plugin.send({ error: "ga4 plugin needs clientId, clientSecret and refreshToken" });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await plugin.send(await collect(cfg));
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
await plugin.send({
|
|
148
|
+
error: String(e?.message || e).slice(0, 160),
|
|
149
|
+
...(e?.code === "invalid_grant" ? { authExpired: true } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const monitorFn = async (plugin) => {
|
|
154
|
+
await refreshFn(plugin);
|
|
155
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 900000);
|
|
156
|
+
};
|
|
157
|
+
const teardownFn = async () => {
|
|
158
|
+
if (refreshTimer)
|
|
159
|
+
clearInterval(refreshTimer);
|
|
160
|
+
refreshTimer = null;
|
|
161
|
+
};
|
|
162
|
+
return createMonitoringPlugin("ga4", "ga4", "Google Analytics 4 overview (active now, users, sessions and views per property)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
163
|
+
}
|
|
164
|
+
export default createGa4Plugin;
|
|
@@ -130,7 +130,7 @@ export function createWebsitePlugin() {
|
|
|
130
130
|
try {
|
|
131
131
|
const ctrl = new AbortController();
|
|
132
132
|
const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
|
|
133
|
-
const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow" });
|
|
133
|
+
const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow", headers: ep.headers });
|
|
134
134
|
if (t)
|
|
135
135
|
clearTimeout(t);
|
|
136
136
|
const body = await res.text();
|
|
@@ -180,6 +180,7 @@ export function createWebsitePlugin() {
|
|
|
180
180
|
unexpectedStrings: cfg.unexpectedStrings,
|
|
181
181
|
expectedStatusCode: cfg.expectedStatusCode,
|
|
182
182
|
timeout: cfg.timeout,
|
|
183
|
+
headers: cfg.headers,
|
|
183
184
|
origin: cfg.origin,
|
|
184
185
|
}];
|
|
185
186
|
}
|