@getmcpads/google-analytics-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +327 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +4488 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +4471 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,4488 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
// src/core/logger.ts
|
|
10
|
+
var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
11
|
+
var currentLevel = process.env["LOG_LEVEL"] ?? "info";
|
|
12
|
+
function log(level, platform, msg, data) {
|
|
13
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[currentLevel]) return;
|
|
14
|
+
console.error(JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, ...platform && { platform }, msg, ...data !== void 0 && { data } }));
|
|
15
|
+
}
|
|
16
|
+
var logger = {
|
|
17
|
+
debug: (p, m, d) => log("debug", p, m, d),
|
|
18
|
+
info: (p, m, d) => log("info", p, m, d),
|
|
19
|
+
warn: (p, m, d) => log("warn", p, m, d),
|
|
20
|
+
error: (p, m, d) => log("error", p, m, d),
|
|
21
|
+
system: (m, d) => log("info", null, m, d),
|
|
22
|
+
setLevel: (l) => {
|
|
23
|
+
currentLevel = l;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// src/config.ts
|
|
28
|
+
var configSchema = z.object({
|
|
29
|
+
clientId: z.string().min(1, "GA4_CLIENT_ID is required"),
|
|
30
|
+
clientSecret: z.string().min(1, "GA4_CLIENT_SECRET is required"),
|
|
31
|
+
refreshToken: z.string().min(1, "GA4_REFRESH_TOKEN is required"),
|
|
32
|
+
defaultPropertyId: z.string().optional(),
|
|
33
|
+
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info")
|
|
34
|
+
});
|
|
35
|
+
function loadConfig() {
|
|
36
|
+
const raw = {
|
|
37
|
+
clientId: process.env["GA4_CLIENT_ID"] ?? "",
|
|
38
|
+
clientSecret: process.env["GA4_CLIENT_SECRET"] ?? "",
|
|
39
|
+
refreshToken: process.env["GA4_REFRESH_TOKEN"] ?? "",
|
|
40
|
+
defaultPropertyId: process.env["GA4_PROPERTY_ID"] || process.env["GA4_DEFAULT_PROPERTY_ID"] || void 0,
|
|
41
|
+
logLevel: process.env["LOG_LEVEL"] ?? "info"
|
|
42
|
+
};
|
|
43
|
+
const result = configSchema.safeParse(raw);
|
|
44
|
+
if (!result.success) {
|
|
45
|
+
const missing = result.error.issues.map((i) => i.message).join(", ");
|
|
46
|
+
logger.error("config", `Missing credentials: ${missing}`);
|
|
47
|
+
throw new Error(`Missing GA4 credentials: ${missing}`);
|
|
48
|
+
}
|
|
49
|
+
logger.system("GA4 MCP Server configured");
|
|
50
|
+
return result.data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/server.ts
|
|
54
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
55
|
+
|
|
56
|
+
// src/platforms/ga4/tools.ts
|
|
57
|
+
import { z as z3 } from "zod";
|
|
58
|
+
|
|
59
|
+
// src/platforms/ga4/types.ts
|
|
60
|
+
var GA4ApiException = class extends Error {
|
|
61
|
+
code;
|
|
62
|
+
status;
|
|
63
|
+
constructor(message, code, status) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "GA4ApiException";
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.status = status || "UNKNOWN";
|
|
68
|
+
}
|
|
69
|
+
get isAuthError() {
|
|
70
|
+
return this.code === 401 || this.code === 403;
|
|
71
|
+
}
|
|
72
|
+
get isRateLimitError() {
|
|
73
|
+
return this.code === 429;
|
|
74
|
+
}
|
|
75
|
+
get isQuotaError() {
|
|
76
|
+
return this.code === 429 || this.status === "RESOURCE_EXHAUSTED";
|
|
77
|
+
}
|
|
78
|
+
get suggestion() {
|
|
79
|
+
if (this.isAuthError) return "Re-authenticate with Google Analytics";
|
|
80
|
+
if (this.isRateLimitError) return "Rate limit exceeded, wait and retry";
|
|
81
|
+
if (this.isQuotaError) return "Quota exceeded, reduce query frequency";
|
|
82
|
+
return "Check the error details for more information";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var GA4_DATA_API_BASE = "https://analyticsdata.googleapis.com/v1beta";
|
|
86
|
+
var GA4_DATA_API_ALPHA_BASE = "https://analyticsdata.googleapis.com/v1alpha";
|
|
87
|
+
var GA4_ADMIN_API_BASE = "https://analyticsadmin.googleapis.com/v1beta";
|
|
88
|
+
var GA4_ADMIN_API_ALPHA_BASE = "https://analyticsadmin.googleapis.com/v1alpha";
|
|
89
|
+
function resolveDatePreset(preset) {
|
|
90
|
+
const now = /* @__PURE__ */ new Date();
|
|
91
|
+
const today = now.toISOString().split("T")[0];
|
|
92
|
+
const daysAgo = (n) => {
|
|
93
|
+
const d = new Date(now);
|
|
94
|
+
d.setDate(d.getDate() - n);
|
|
95
|
+
return d.toISOString().split("T")[0];
|
|
96
|
+
};
|
|
97
|
+
switch (preset) {
|
|
98
|
+
case "today":
|
|
99
|
+
return { startDate: "today", endDate: "today" };
|
|
100
|
+
case "yesterday":
|
|
101
|
+
return { startDate: "yesterday", endDate: "yesterday" };
|
|
102
|
+
case "last7days":
|
|
103
|
+
return { startDate: "7daysAgo", endDate: "today" };
|
|
104
|
+
case "last28days":
|
|
105
|
+
return { startDate: "28daysAgo", endDate: "today" };
|
|
106
|
+
case "last30days":
|
|
107
|
+
return { startDate: "30daysAgo", endDate: "today" };
|
|
108
|
+
case "last90days":
|
|
109
|
+
return { startDate: "90daysAgo", endDate: "today" };
|
|
110
|
+
case "last12months":
|
|
111
|
+
return { startDate: daysAgo(365), endDate: today };
|
|
112
|
+
case "thisMonth": {
|
|
113
|
+
const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split("T")[0];
|
|
114
|
+
return { startDate: firstOfMonth, endDate: today };
|
|
115
|
+
}
|
|
116
|
+
case "lastMonth": {
|
|
117
|
+
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
118
|
+
const last = new Date(now.getFullYear(), now.getMonth(), 0);
|
|
119
|
+
return { startDate: first.toISOString().split("T")[0], endDate: last.toISOString().split("T")[0] };
|
|
120
|
+
}
|
|
121
|
+
case "thisYear": {
|
|
122
|
+
const firstOfYear = new Date(now.getFullYear(), 0, 1).toISOString().split("T")[0];
|
|
123
|
+
return { startDate: firstOfYear, endDate: today };
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
return { startDate: "28daysAgo", endDate: "today" };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/core/errors.ts
|
|
131
|
+
var PlatformApiError = class extends Error {
|
|
132
|
+
constructor(platform, code, message, isRateLimit = false, isAuth = false, isPermission = false, suggestion = "", retryAfter) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.platform = platform;
|
|
135
|
+
this.code = code;
|
|
136
|
+
this.isRateLimit = isRateLimit;
|
|
137
|
+
this.isAuth = isAuth;
|
|
138
|
+
this.isPermission = isPermission;
|
|
139
|
+
this.suggestion = suggestion;
|
|
140
|
+
this.retryAfter = retryAfter;
|
|
141
|
+
this.name = "PlatformApiError";
|
|
142
|
+
}
|
|
143
|
+
platform;
|
|
144
|
+
code;
|
|
145
|
+
isRateLimit;
|
|
146
|
+
isAuth;
|
|
147
|
+
isPermission;
|
|
148
|
+
suggestion;
|
|
149
|
+
retryAfter;
|
|
150
|
+
toMcpError() {
|
|
151
|
+
return {
|
|
152
|
+
error: this.message,
|
|
153
|
+
platform: this.platform,
|
|
154
|
+
code: this.code,
|
|
155
|
+
isRateLimit: this.isRateLimit,
|
|
156
|
+
isAuth: this.isAuth,
|
|
157
|
+
suggestion: this.suggestion,
|
|
158
|
+
...this.retryAfter !== void 0 && { retryAfter: this.retryAfter }
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
var RateLimitError = class extends PlatformApiError {
|
|
163
|
+
constructor(retryAfter) {
|
|
164
|
+
super("ga4", 429, "Rate limit exceeded", true, false, false, "Wait and retry with backoff", retryAfter);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function formatMcpToolError(error) {
|
|
168
|
+
if (error instanceof PlatformApiError) return { content: [{ type: "text", text: JSON.stringify(error.toMcpError(), null, 2) }], isError: true };
|
|
169
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
170
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: msg }, null, 2) }], isError: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/core/rate-limiter.ts
|
|
174
|
+
var RateLimiter = class {
|
|
175
|
+
timestamps = [];
|
|
176
|
+
async acquire() {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
this.timestamps = this.timestamps.filter((t) => now - t < 6e4);
|
|
179
|
+
const lastSec = this.timestamps.filter((t) => now - t < 1e3);
|
|
180
|
+
if (lastSec.length >= 10) {
|
|
181
|
+
const w = 1e3 - (now - lastSec[0]) + 50;
|
|
182
|
+
logger.debug("ga4", `Rate limit: ${w}ms`);
|
|
183
|
+
await new Promise((r) => setTimeout(r, w));
|
|
184
|
+
}
|
|
185
|
+
if (this.timestamps.length >= 600) {
|
|
186
|
+
const w = 6e4 - (now - this.timestamps[0]) + 100;
|
|
187
|
+
await new Promise((r) => setTimeout(r, w));
|
|
188
|
+
}
|
|
189
|
+
this.timestamps.push(Date.now());
|
|
190
|
+
}
|
|
191
|
+
async execute(fn) {
|
|
192
|
+
for (let i = 0; i <= 3; i++) {
|
|
193
|
+
await this.acquire();
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
const isRL = e instanceof RateLimitError || e instanceof Error && e.message.toLowerCase().includes("rate limit");
|
|
198
|
+
if (isRL && i < 3) {
|
|
199
|
+
await new Promise((r) => setTimeout(r, Math.min(1e3 * Math.pow(2, i) + Math.floor(Math.random() * 500), 3e4)));
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
throw e;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new RateLimitError();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/platforms/ga4/client.ts
|
|
210
|
+
var GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
211
|
+
var GA4_ADMIN_COLLECTIONS = {
|
|
212
|
+
accessBindings: { version: "v1alpha", collectionKey: "accessBindings" },
|
|
213
|
+
adSenseLinks: { version: "v1alpha", collectionKey: "adSenseLinks" },
|
|
214
|
+
audiences: { version: "v1alpha", collectionKey: "audiences" },
|
|
215
|
+
bigQueryLinks: { version: "v1alpha", collectionKey: "bigQueryLinks" },
|
|
216
|
+
calculatedMetrics: { version: "v1alpha", collectionKey: "calculatedMetrics" },
|
|
217
|
+
channelGroups: { version: "v1alpha", collectionKey: "channelGroups" },
|
|
218
|
+
customDimensions: { version: "v1beta", collectionKey: "customDimensions" },
|
|
219
|
+
customMetrics: { version: "v1beta", collectionKey: "customMetrics" },
|
|
220
|
+
dataStreams: { version: "v1beta", collectionKey: "dataStreams" },
|
|
221
|
+
displayVideo360AdvertiserLinkProposals: { version: "v1alpha", collectionKey: "displayVideo360AdvertiserLinkProposals" },
|
|
222
|
+
displayVideo360AdvertiserLinks: { version: "v1alpha", collectionKey: "displayVideo360AdvertiserLinks" },
|
|
223
|
+
expandedDataSets: { version: "v1alpha", collectionKey: "expandedDataSets" },
|
|
224
|
+
firebaseLinks: { version: "v1beta", collectionKey: "firebaseLinks" },
|
|
225
|
+
googleAdsLinks: { version: "v1beta", collectionKey: "googleAdsLinks" },
|
|
226
|
+
keyEvents: { version: "v1beta", collectionKey: "keyEvents" },
|
|
227
|
+
reportingDataAnnotations: { version: "v1alpha", collectionKey: "reportingDataAnnotations" },
|
|
228
|
+
rollupPropertySourceLinks: { version: "v1alpha", collectionKey: "rollupPropertySourceLinks" },
|
|
229
|
+
searchAds360Links: { version: "v1alpha", collectionKey: "searchAds360Links" },
|
|
230
|
+
subpropertyEventFilters: { version: "v1alpha", collectionKey: "subpropertyEventFilters" },
|
|
231
|
+
subpropertySyncConfigs: { version: "v1alpha", collectionKey: "subpropertySyncConfigs" }
|
|
232
|
+
};
|
|
233
|
+
var GA4Client = class {
|
|
234
|
+
accessToken = "";
|
|
235
|
+
tokenExpiresAt = 0;
|
|
236
|
+
clientId;
|
|
237
|
+
clientSecret;
|
|
238
|
+
refreshToken;
|
|
239
|
+
rateLimiter;
|
|
240
|
+
constructor(config) {
|
|
241
|
+
this.clientId = config.clientId;
|
|
242
|
+
this.clientSecret = config.clientSecret;
|
|
243
|
+
this.refreshToken = config.refreshToken;
|
|
244
|
+
this.rateLimiter = new RateLimiter();
|
|
245
|
+
}
|
|
246
|
+
// ============================================
|
|
247
|
+
// PRIVATE METHODS
|
|
248
|
+
// ============================================
|
|
249
|
+
/**
|
|
250
|
+
* Refresh access token using refresh token.
|
|
251
|
+
* Google refresh tokens never expire.
|
|
252
|
+
*/
|
|
253
|
+
async refreshAccessToken() {
|
|
254
|
+
logger.debug("ga4", "Refreshing access token");
|
|
255
|
+
const response = await fetch(GOOGLE_OAUTH_TOKEN_URL, {
|
|
256
|
+
method: "POST",
|
|
257
|
+
redirect: "error",
|
|
258
|
+
headers: {
|
|
259
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
260
|
+
},
|
|
261
|
+
body: new URLSearchParams({
|
|
262
|
+
grant_type: "refresh_token",
|
|
263
|
+
refresh_token: this.refreshToken,
|
|
264
|
+
client_id: this.clientId,
|
|
265
|
+
client_secret: this.clientSecret
|
|
266
|
+
})
|
|
267
|
+
});
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
const error = await response.json().catch(() => ({}));
|
|
270
|
+
logger.error("ga4", "Token refresh failed", { status: response.status, error });
|
|
271
|
+
throw new GA4ApiException(
|
|
272
|
+
error.error_description || error.error || "Failed to refresh token",
|
|
273
|
+
response.status,
|
|
274
|
+
error.error
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const data = await response.json();
|
|
278
|
+
this.accessToken = data.access_token;
|
|
279
|
+
this.tokenExpiresAt = Date.now() + (data.expires_in - 60) * 1e3;
|
|
280
|
+
logger.debug("ga4", "Access token refreshed successfully");
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Ensure we have a valid access token before making API calls.
|
|
284
|
+
*/
|
|
285
|
+
async ensureValidToken() {
|
|
286
|
+
if (!this.accessToken || Date.now() >= this.tokenExpiresAt) {
|
|
287
|
+
await this.refreshAccessToken();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async request(url, options = {}) {
|
|
291
|
+
await this.ensureValidToken();
|
|
292
|
+
const controller = new AbortController();
|
|
293
|
+
const timeout = setTimeout(() => controller.abort(), 9e4);
|
|
294
|
+
try {
|
|
295
|
+
const response = await fetch(url, {
|
|
296
|
+
...options,
|
|
297
|
+
signal: controller.signal,
|
|
298
|
+
// Forced after the spread: once a bearer token is attached, a redirect
|
|
299
|
+
// must never be followed, or the credential would be forwarded to
|
|
300
|
+
// whatever host the redirect names.
|
|
301
|
+
redirect: "error",
|
|
302
|
+
headers: {
|
|
303
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
...options.headers
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
if (!response.ok) {
|
|
309
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
310
|
+
const errorDetails = errorBody?.error;
|
|
311
|
+
logger.error("ga4", "API Error", { status: response.status, error: errorBody });
|
|
312
|
+
const detailedMessage = errorDetails?.message || `Request failed: ${response.statusText}`;
|
|
313
|
+
throw new GA4ApiException(
|
|
314
|
+
detailedMessage,
|
|
315
|
+
response.status,
|
|
316
|
+
errorDetails?.status
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return response.json();
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
322
|
+
throw new GA4ApiException(
|
|
323
|
+
"GA4 API request timed out after 90 seconds",
|
|
324
|
+
408,
|
|
325
|
+
"TIMEOUT"
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
} finally {
|
|
330
|
+
clearTimeout(timeout);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
cleanPropertyId(propertyId) {
|
|
334
|
+
return propertyId.replace("properties/", "");
|
|
335
|
+
}
|
|
336
|
+
cleanResourceName(resourceName) {
|
|
337
|
+
return resourceName.replace(/^\/+/, "");
|
|
338
|
+
}
|
|
339
|
+
async listPagedRecords(baseUrl, collectionKey, pageSize, maxPages = 50) {
|
|
340
|
+
const records = [];
|
|
341
|
+
let pageToken;
|
|
342
|
+
let pagesRead = 0;
|
|
343
|
+
do {
|
|
344
|
+
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
|
345
|
+
if (pageToken) {
|
|
346
|
+
params.set("pageToken", pageToken);
|
|
347
|
+
}
|
|
348
|
+
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
349
|
+
const response = await this.rateLimiter.execute(
|
|
350
|
+
() => this.request(`${baseUrl}${separator}${params.toString()}`)
|
|
351
|
+
);
|
|
352
|
+
const collection = response[collectionKey];
|
|
353
|
+
if (Array.isArray(collection)) {
|
|
354
|
+
records.push(...collection.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)));
|
|
355
|
+
}
|
|
356
|
+
pageToken = typeof response.nextPageToken === "string" ? response.nextPageToken : void 0;
|
|
357
|
+
pagesRead += 1;
|
|
358
|
+
} while (pageToken && pagesRead < maxPages);
|
|
359
|
+
return records;
|
|
360
|
+
}
|
|
361
|
+
redactSecretRecord(record) {
|
|
362
|
+
const safeRecord = { ...record };
|
|
363
|
+
if ("secretValue" in safeRecord) {
|
|
364
|
+
safeRecord.secretValue = "[REDACTED]";
|
|
365
|
+
safeRecord.secretValueRedacted = true;
|
|
366
|
+
}
|
|
367
|
+
return safeRecord;
|
|
368
|
+
}
|
|
369
|
+
// ============================================
|
|
370
|
+
// PROPERTY MANAGEMENT
|
|
371
|
+
// ============================================
|
|
372
|
+
/**
|
|
373
|
+
* List all GA4 properties accessible to the authenticated user
|
|
374
|
+
* Calls GET /v1beta/accountSummaries with pagination
|
|
375
|
+
* Returns flattened array of GA4Property
|
|
376
|
+
*/
|
|
377
|
+
async listProperties() {
|
|
378
|
+
const properties = [];
|
|
379
|
+
let pageToken;
|
|
380
|
+
do {
|
|
381
|
+
const params = new URLSearchParams({ pageSize: "200" });
|
|
382
|
+
if (pageToken) {
|
|
383
|
+
params.set("pageToken", pageToken);
|
|
384
|
+
}
|
|
385
|
+
const url = `${GA4_ADMIN_API_BASE}/accountSummaries?${params.toString()}`;
|
|
386
|
+
const response = await this.rateLimiter.execute(
|
|
387
|
+
() => this.request(url)
|
|
388
|
+
);
|
|
389
|
+
if (response.accountSummaries) {
|
|
390
|
+
for (const account of response.accountSummaries) {
|
|
391
|
+
if (account.propertySummaries) {
|
|
392
|
+
for (const prop of account.propertySummaries) {
|
|
393
|
+
const propertyId = prop.property.replace("properties/", "");
|
|
394
|
+
properties.push({
|
|
395
|
+
propertyId,
|
|
396
|
+
displayName: prop.displayName,
|
|
397
|
+
timeZone: "",
|
|
398
|
+
currencyCode: "",
|
|
399
|
+
propertyType: prop.propertyType,
|
|
400
|
+
parent: account.account
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
pageToken = response.nextPageToken;
|
|
407
|
+
} while (pageToken);
|
|
408
|
+
await Promise.all(
|
|
409
|
+
properties.map(async (prop) => {
|
|
410
|
+
try {
|
|
411
|
+
const detail = await this.rateLimiter.execute(
|
|
412
|
+
() => this.request(`${GA4_ADMIN_API_BASE}/properties/${prop.propertyId}`)
|
|
413
|
+
);
|
|
414
|
+
prop.currencyCode = detail.currencyCode || "";
|
|
415
|
+
prop.timeZone = detail.timeZone || "";
|
|
416
|
+
if (detail.industryCategory) {
|
|
417
|
+
prop.industryCategory = detail.industryCategory;
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
logger.warn("ga4", `Failed to enrich property ${prop.propertyId}`);
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
);
|
|
424
|
+
return properties;
|
|
425
|
+
}
|
|
426
|
+
// ============================================
|
|
427
|
+
// REPORTING
|
|
428
|
+
// ============================================
|
|
429
|
+
/**
|
|
430
|
+
* Run a GA4 report with automatic pagination
|
|
431
|
+
* POST /v1beta/properties/{propertyId}:runReport
|
|
432
|
+
* Handles pagination via offset, capped at 250,000 rows per request
|
|
433
|
+
*/
|
|
434
|
+
async runReport(propertyId, request) {
|
|
435
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
436
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:runReport`;
|
|
437
|
+
const MAX_ROWS_PER_REQUEST = 25e4;
|
|
438
|
+
const firstResponse = await this.rateLimiter.execute(
|
|
439
|
+
() => this.request(url, {
|
|
440
|
+
method: "POST",
|
|
441
|
+
body: JSON.stringify({
|
|
442
|
+
...request,
|
|
443
|
+
limit: request.limit ?? MAX_ROWS_PER_REQUEST,
|
|
444
|
+
offset: request.offset ?? 0
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
const totalRows = firstResponse.rowCount ?? 0;
|
|
449
|
+
const returnedRows = firstResponse.rows?.length ?? 0;
|
|
450
|
+
const startOffset = request.offset ?? 0;
|
|
451
|
+
if (request.limit) {
|
|
452
|
+
return firstResponse;
|
|
453
|
+
}
|
|
454
|
+
if (startOffset + returnedRows >= totalRows) {
|
|
455
|
+
return firstResponse;
|
|
456
|
+
}
|
|
457
|
+
const allRows = [...firstResponse.rows ?? []];
|
|
458
|
+
let currentOffset = startOffset + returnedRows;
|
|
459
|
+
while (currentOffset < totalRows) {
|
|
460
|
+
const pageResponse = await this.rateLimiter.execute(
|
|
461
|
+
() => this.request(url, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
...request,
|
|
465
|
+
limit: MAX_ROWS_PER_REQUEST,
|
|
466
|
+
offset: currentOffset
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
if (pageResponse.rows) {
|
|
471
|
+
allRows.push(...pageResponse.rows);
|
|
472
|
+
}
|
|
473
|
+
const pageRowCount = pageResponse.rows?.length ?? 0;
|
|
474
|
+
if (pageRowCount === 0) break;
|
|
475
|
+
currentOffset += pageRowCount;
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
...firstResponse,
|
|
479
|
+
rows: allRows
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Run a GA4 realtime report.
|
|
484
|
+
* POST /v1beta/properties/{propertyId}:runRealtimeReport
|
|
485
|
+
*/
|
|
486
|
+
async runRealtimeReport(propertyId, request) {
|
|
487
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
488
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:runRealtimeReport`;
|
|
489
|
+
return this.rateLimiter.execute(
|
|
490
|
+
() => this.request(url, {
|
|
491
|
+
method: "POST",
|
|
492
|
+
body: JSON.stringify(request)
|
|
493
|
+
})
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
/** POST /v1beta/properties/{propertyId}:batchRunReports (maximum five reports). */
|
|
497
|
+
async batchRunReports(propertyId, requests) {
|
|
498
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
499
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:batchRunReports`;
|
|
500
|
+
return this.rateLimiter.execute(
|
|
501
|
+
() => this.request(url, {
|
|
502
|
+
method: "POST",
|
|
503
|
+
body: JSON.stringify({ requests })
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
/** POST /v1beta/properties/{propertyId}:runPivotReport. */
|
|
508
|
+
async runPivotReport(propertyId, request) {
|
|
509
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
510
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:runPivotReport`;
|
|
511
|
+
return this.rateLimiter.execute(
|
|
512
|
+
() => this.request(url, {
|
|
513
|
+
method: "POST",
|
|
514
|
+
body: JSON.stringify(request)
|
|
515
|
+
})
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
/** POST /v1beta/properties/{propertyId}:batchRunPivotReports (maximum five reports). */
|
|
519
|
+
async batchRunPivotReports(propertyId, requests) {
|
|
520
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
521
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:batchRunPivotReports`;
|
|
522
|
+
return this.rateLimiter.execute(
|
|
523
|
+
() => this.request(url, {
|
|
524
|
+
method: "POST",
|
|
525
|
+
body: JSON.stringify({ requests })
|
|
526
|
+
})
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
/** POST /v1beta/properties/{propertyId}:checkCompatibility for Core reports. */
|
|
530
|
+
async checkCompatibility(propertyId, request) {
|
|
531
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
532
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}:checkCompatibility`;
|
|
533
|
+
return this.rateLimiter.execute(
|
|
534
|
+
() => this.request(url, {
|
|
535
|
+
method: "POST",
|
|
536
|
+
body: JSON.stringify(request)
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
/** GET /v1alpha/properties/{propertyId}/propertyQuotasSnapshot. */
|
|
541
|
+
async getPropertyQuotasSnapshot(propertyId) {
|
|
542
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
543
|
+
return this.rateLimiter.execute(
|
|
544
|
+
() => this.request(
|
|
545
|
+
`${GA4_DATA_API_ALPHA_BASE}/properties/${cleanPropertyId}/propertyQuotasSnapshot`
|
|
546
|
+
)
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Run a GA4 funnel report.
|
|
551
|
+
* POST /v1alpha/properties/{propertyId}:runFunnelReport
|
|
552
|
+
*/
|
|
553
|
+
async runFunnelReport(propertyId, request) {
|
|
554
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
555
|
+
const url = `${GA4_DATA_API_ALPHA_BASE}/properties/${cleanPropertyId}:runFunnelReport`;
|
|
556
|
+
return this.rateLimiter.execute(
|
|
557
|
+
() => this.request(url, {
|
|
558
|
+
method: "POST",
|
|
559
|
+
body: JSON.stringify(request)
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
// ============================================
|
|
564
|
+
// METADATA
|
|
565
|
+
// ============================================
|
|
566
|
+
/**
|
|
567
|
+
* Get metadata for a GA4 property (available dimensions & metrics)
|
|
568
|
+
* GET /v1beta/properties/{propertyId}/metadata
|
|
569
|
+
*/
|
|
570
|
+
async getMetadata(propertyId) {
|
|
571
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
572
|
+
const url = `${GA4_DATA_API_BASE}/properties/${cleanPropertyId}/metadata`;
|
|
573
|
+
return this.rateLimiter.execute(
|
|
574
|
+
() => this.request(url)
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Get Admin API property details.
|
|
579
|
+
* GET /v1beta/properties/{propertyId}
|
|
580
|
+
*/
|
|
581
|
+
async getProperty(propertyId) {
|
|
582
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
583
|
+
const url = `${GA4_ADMIN_API_BASE}/properties/${cleanPropertyId}`;
|
|
584
|
+
return this.rateLimiter.execute(
|
|
585
|
+
() => this.request(url)
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
/** List raw Analytics Admin accounts accessible to the caller. */
|
|
589
|
+
async listAccounts(pageSize = 200) {
|
|
590
|
+
return this.listPagedRecords(
|
|
591
|
+
`${GA4_ADMIN_API_BASE}/accounts`,
|
|
592
|
+
"accounts",
|
|
593
|
+
pageSize
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* List an allowlisted Analytics Admin collection for a property.
|
|
598
|
+
* Every route in the allowlist is a GET-only list method.
|
|
599
|
+
*/
|
|
600
|
+
async listAdminPropertyResources(propertyId, collection, pageSize = 200) {
|
|
601
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
602
|
+
const definition = GA4_ADMIN_COLLECTIONS[collection];
|
|
603
|
+
const base = definition.version === "v1beta" ? GA4_ADMIN_API_BASE : GA4_ADMIN_API_ALPHA_BASE;
|
|
604
|
+
return this.listPagedRecords(
|
|
605
|
+
`${base}/properties/${cleanPropertyId}/${collection}`,
|
|
606
|
+
definition.collectionKey,
|
|
607
|
+
pageSize
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
/** Get an allowlisted property singleton setting through a GET-only Admin API method. */
|
|
611
|
+
async getPropertySetting(propertyId, setting) {
|
|
612
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
613
|
+
const endpoints = {
|
|
614
|
+
attributionSettings: `${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/attributionSettings`,
|
|
615
|
+
dataRetentionSettings: `${GA4_ADMIN_API_BASE}/properties/${cleanPropertyId}/dataRetentionSettings`,
|
|
616
|
+
googleSignalsSettings: `${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/googleSignalsSettings`,
|
|
617
|
+
reportingIdentitySettings: `${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/reportingIdentitySettings`,
|
|
618
|
+
userProvidedDataSettings: `${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/userProvidedDataSettings`
|
|
619
|
+
};
|
|
620
|
+
return this.rateLimiter.execute(() => this.request(endpoints[setting]));
|
|
621
|
+
}
|
|
622
|
+
// ============================================
|
|
623
|
+
// AUDIENCES & AUDIENCE EXPORTS
|
|
624
|
+
// ============================================
|
|
625
|
+
/**
|
|
626
|
+
* List Audience Export snapshots for a property.
|
|
627
|
+
* GET /v1beta/properties/{propertyId}/audienceExports
|
|
628
|
+
*/
|
|
629
|
+
async listAudienceExports(propertyId, pageSize = 100) {
|
|
630
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
631
|
+
return this.listPagedRecords(
|
|
632
|
+
`${GA4_DATA_API_BASE}/properties/${cleanPropertyId}/audienceExports`,
|
|
633
|
+
"audienceExports",
|
|
634
|
+
pageSize
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Get Audience Export metadata.
|
|
639
|
+
* GET /v1beta/properties/{propertyId}/audienceExports/{audienceExport}
|
|
640
|
+
*/
|
|
641
|
+
async getAudienceExport(audienceExportName) {
|
|
642
|
+
const resourceName = this.cleanResourceName(audienceExportName);
|
|
643
|
+
return this.rateLimiter.execute(
|
|
644
|
+
() => this.request(`${GA4_DATA_API_BASE}/${resourceName}`)
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Query rows from an existing Audience Export. This is read-only but can contain user identifiers.
|
|
649
|
+
* POST /v1beta/properties/{propertyId}/audienceExports/{audienceExport}:query
|
|
650
|
+
*/
|
|
651
|
+
async queryAudienceExport(audienceExportName, request) {
|
|
652
|
+
const resourceName = this.cleanResourceName(audienceExportName);
|
|
653
|
+
return this.rateLimiter.execute(
|
|
654
|
+
() => this.request(`${GA4_DATA_API_BASE}/${resourceName}:query`, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
body: JSON.stringify(request)
|
|
657
|
+
})
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* List configured Admin API Audiences for a property.
|
|
662
|
+
* GET /v1alpha/properties/{propertyId}/audiences
|
|
663
|
+
*/
|
|
664
|
+
async listAudiences(propertyId, pageSize = 200) {
|
|
665
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
666
|
+
return this.listPagedRecords(
|
|
667
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/audiences`,
|
|
668
|
+
"audiences",
|
|
669
|
+
pageSize
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* List Recurring Audience Lists for a property.
|
|
674
|
+
* GET /v1alpha/properties/{propertyId}/recurringAudienceLists
|
|
675
|
+
*/
|
|
676
|
+
async listRecurringAudienceLists(propertyId, pageSize = 100) {
|
|
677
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
678
|
+
return this.listPagedRecords(
|
|
679
|
+
`${GA4_DATA_API_ALPHA_BASE}/properties/${cleanPropertyId}/recurringAudienceLists`,
|
|
680
|
+
"recurringAudienceLists",
|
|
681
|
+
pageSize
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Get Recurring Audience List metadata.
|
|
686
|
+
* GET /v1alpha/properties/{propertyId}/recurringAudienceLists/{recurringAudienceList}
|
|
687
|
+
*/
|
|
688
|
+
async getRecurringAudienceList(recurringAudienceListName) {
|
|
689
|
+
const resourceName = this.cleanResourceName(recurringAudienceListName);
|
|
690
|
+
return this.rateLimiter.execute(
|
|
691
|
+
() => this.request(`${GA4_DATA_API_ALPHA_BASE}/${resourceName}`)
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
// ============================================
|
|
695
|
+
// EXPORTS & TAGGING DIAGNOSTICS
|
|
696
|
+
// ============================================
|
|
697
|
+
/**
|
|
698
|
+
* List BigQuery links configured for a property.
|
|
699
|
+
* GET /v1alpha/properties/{propertyId}/bigQueryLinks
|
|
700
|
+
*/
|
|
701
|
+
async listBigQueryLinks(propertyId, pageSize = 200) {
|
|
702
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
703
|
+
return this.listPagedRecords(
|
|
704
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/bigQueryLinks`,
|
|
705
|
+
"bigQueryLinks",
|
|
706
|
+
pageSize
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* List data streams configured for a property.
|
|
711
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams
|
|
712
|
+
*/
|
|
713
|
+
async listDataStreams(propertyId, pageSize = 200) {
|
|
714
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
715
|
+
return this.listPagedRecords(
|
|
716
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/properties/${cleanPropertyId}/dataStreams`,
|
|
717
|
+
"dataStreams",
|
|
718
|
+
pageSize
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* List Measurement Protocol secrets for a stream with secret values redacted.
|
|
723
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams/{dataStream}/measurementProtocolSecrets
|
|
724
|
+
*/
|
|
725
|
+
async listMeasurementProtocolSecrets(dataStreamName, pageSize = 10) {
|
|
726
|
+
const resourceName = this.cleanResourceName(dataStreamName);
|
|
727
|
+
const secrets = await this.listPagedRecords(
|
|
728
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/${resourceName}/measurementProtocolSecrets`,
|
|
729
|
+
"measurementProtocolSecrets",
|
|
730
|
+
pageSize
|
|
731
|
+
);
|
|
732
|
+
return secrets.map((secret) => this.redactSecretRecord(secret));
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Get enhanced measurement settings for a web stream.
|
|
736
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams/{dataStream}/enhancedMeasurementSettings
|
|
737
|
+
*/
|
|
738
|
+
async getEnhancedMeasurementSettings(dataStreamName) {
|
|
739
|
+
const resourceName = this.cleanResourceName(dataStreamName);
|
|
740
|
+
return this.rateLimiter.execute(
|
|
741
|
+
() => this.request(`${GA4_ADMIN_API_ALPHA_BASE}/${resourceName}/enhancedMeasurementSettings`)
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Get data redaction settings for a web stream.
|
|
746
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams/{dataStream}/dataRedactionSettings
|
|
747
|
+
*/
|
|
748
|
+
async getDataRedactionSettings(dataStreamName) {
|
|
749
|
+
const resourceName = this.cleanResourceName(dataStreamName);
|
|
750
|
+
return this.rateLimiter.execute(
|
|
751
|
+
() => this.request(`${GA4_ADMIN_API_ALPHA_BASE}/${resourceName}/dataRedactionSettings`)
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* List event create rules for a web stream.
|
|
756
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams/{dataStream}/eventCreateRules
|
|
757
|
+
*/
|
|
758
|
+
async listEventCreateRules(dataStreamName, pageSize = 200) {
|
|
759
|
+
const resourceName = this.cleanResourceName(dataStreamName);
|
|
760
|
+
return this.listPagedRecords(
|
|
761
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/${resourceName}/eventCreateRules`,
|
|
762
|
+
"eventCreateRules",
|
|
763
|
+
pageSize
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* List event edit rules for a web stream.
|
|
768
|
+
* GET /v1alpha/properties/{propertyId}/dataStreams/{dataStream}/eventEditRules
|
|
769
|
+
*/
|
|
770
|
+
async listEventEditRules(dataStreamName, pageSize = 200) {
|
|
771
|
+
const resourceName = this.cleanResourceName(dataStreamName);
|
|
772
|
+
return this.listPagedRecords(
|
|
773
|
+
`${GA4_ADMIN_API_ALPHA_BASE}/${resourceName}/eventEditRules`,
|
|
774
|
+
"eventEditRules",
|
|
775
|
+
pageSize
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
// ============================================
|
|
779
|
+
// CHANNEL GROUPS
|
|
780
|
+
// ============================================
|
|
781
|
+
/**
|
|
782
|
+
* List custom channel groups for a GA4 property
|
|
783
|
+
* GET /v1alpha/properties/{propertyId}/channelGroups
|
|
784
|
+
* NOTE: channelGroups endpoint is only available in v1alpha, not v1beta
|
|
785
|
+
*/
|
|
786
|
+
async listChannelGroups(propertyId) {
|
|
787
|
+
const cleanPropertyId = this.cleanPropertyId(propertyId);
|
|
788
|
+
const url = `https://analyticsadmin.googleapis.com/v1alpha/properties/${cleanPropertyId}/channelGroups`;
|
|
789
|
+
const response = await this.rateLimiter.execute(
|
|
790
|
+
() => this.request(url)
|
|
791
|
+
);
|
|
792
|
+
return response.channelGroups ?? [];
|
|
793
|
+
}
|
|
794
|
+
// ============================================
|
|
795
|
+
// RESPONSE FLATTENING
|
|
796
|
+
// ============================================
|
|
797
|
+
/**
|
|
798
|
+
* Convert a GA4RunReportResponse into flat GA4InsightRow[]
|
|
799
|
+
* Maps dimension and metric headers to row values
|
|
800
|
+
* Parses numeric strings to numbers for metric values
|
|
801
|
+
*
|
|
802
|
+
* Input:
|
|
803
|
+
* dimensionHeaders: [{ name: "date" }, { name: "sessionSource" }]
|
|
804
|
+
* metricHeaders: [{ name: "sessions", type: "TYPE_INTEGER" }, { name: "totalRevenue", type: "TYPE_CURRENCY" }]
|
|
805
|
+
* rows: [{ dimensionValues: [{ value: "20260301" }, { value: "google" }], metricValues: [{ value: "150" }, { value: "1234.56" }] }]
|
|
806
|
+
*
|
|
807
|
+
* Output:
|
|
808
|
+
* [{ date: "20260301", sessionSource: "google", sessions: 150, totalRevenue: 1234.56 }]
|
|
809
|
+
*/
|
|
810
|
+
flattenResponse(response) {
|
|
811
|
+
if (!response.rows || response.rows.length === 0) {
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
const dimensionHeaders = response.dimensionHeaders ?? [];
|
|
815
|
+
const metricHeaders = response.metricHeaders ?? [];
|
|
816
|
+
return response.rows.map((row) => {
|
|
817
|
+
const flat = {};
|
|
818
|
+
if (row.dimensionValues) {
|
|
819
|
+
for (let i = 0; i < dimensionHeaders.length; i++) {
|
|
820
|
+
const header = dimensionHeaders[i];
|
|
821
|
+
const value = row.dimensionValues[i]?.value ?? null;
|
|
822
|
+
flat[header.name] = value;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (row.metricValues) {
|
|
826
|
+
for (let i = 0; i < metricHeaders.length; i++) {
|
|
827
|
+
const header = metricHeaders[i];
|
|
828
|
+
const rawValue = row.metricValues[i]?.value ?? null;
|
|
829
|
+
if (rawValue === null) {
|
|
830
|
+
flat[header.name] = null;
|
|
831
|
+
} else if (/^-?\d+(\.\d+)?$/.test(rawValue)) {
|
|
832
|
+
flat[header.name] = parseFloat(rawValue);
|
|
833
|
+
} else {
|
|
834
|
+
flat[header.name] = rawValue;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return flat;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// src/platforms/ga4/metric-catalog.ts
|
|
844
|
+
var TRAFFIC_METRICS = [
|
|
845
|
+
{
|
|
846
|
+
key: "sessions",
|
|
847
|
+
name: "Sessions",
|
|
848
|
+
description: "The number of sessions that began on your site or app.",
|
|
849
|
+
category: "traffic",
|
|
850
|
+
format: "number",
|
|
851
|
+
apiField: "sessions",
|
|
852
|
+
type: "api"
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
key: "activeUsers",
|
|
856
|
+
name: "Active Users",
|
|
857
|
+
description: "The number of distinct users who visited your site or app.",
|
|
858
|
+
category: "traffic",
|
|
859
|
+
format: "number",
|
|
860
|
+
apiField: "activeUsers",
|
|
861
|
+
type: "api"
|
|
862
|
+
},
|
|
863
|
+
{
|
|
864
|
+
key: "newUsers",
|
|
865
|
+
name: "New Users",
|
|
866
|
+
description: "The number of users who interacted with your site or app for the first time.",
|
|
867
|
+
category: "traffic",
|
|
868
|
+
format: "number",
|
|
869
|
+
apiField: "newUsers",
|
|
870
|
+
type: "api"
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
key: "totalUsers",
|
|
874
|
+
name: "Total Users",
|
|
875
|
+
description: "The total number of unique users who logged an event.",
|
|
876
|
+
category: "traffic",
|
|
877
|
+
format: "number",
|
|
878
|
+
apiField: "totalUsers",
|
|
879
|
+
type: "api"
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
key: "screenPageViews",
|
|
883
|
+
name: "Page Views",
|
|
884
|
+
description: "The number of app screens or web pages your users viewed. Includes repeated views of a single page or screen.",
|
|
885
|
+
category: "traffic",
|
|
886
|
+
format: "number",
|
|
887
|
+
apiField: "screenPageViews",
|
|
888
|
+
type: "api"
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
key: "bounceRate",
|
|
892
|
+
name: "Bounce Rate",
|
|
893
|
+
description: "The percentage of sessions that were not engaged (lasted less than 10 seconds, had no conversion events, and had fewer than 2 page/screen views).",
|
|
894
|
+
category: "traffic",
|
|
895
|
+
format: "percent",
|
|
896
|
+
apiField: "bounceRate",
|
|
897
|
+
type: "api"
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
key: "sessionsPerUser",
|
|
901
|
+
name: "Sessions per User",
|
|
902
|
+
description: "The average number of sessions per user.",
|
|
903
|
+
category: "traffic",
|
|
904
|
+
format: "ratio",
|
|
905
|
+
apiField: "sessionsPerUser",
|
|
906
|
+
type: "api"
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
key: "engagedSessions",
|
|
910
|
+
name: "Engaged Sessions",
|
|
911
|
+
description: "The number of sessions that lasted 10 seconds or longer, had a conversion event, or had 2 or more page/screen views.",
|
|
912
|
+
category: "traffic",
|
|
913
|
+
format: "number",
|
|
914
|
+
apiField: "engagedSessions",
|
|
915
|
+
type: "api"
|
|
916
|
+
},
|
|
917
|
+
{
|
|
918
|
+
key: "crashAffectedUsers",
|
|
919
|
+
name: "Crash Affected Users",
|
|
920
|
+
description: "The number of users who experienced a crash in your app.",
|
|
921
|
+
category: "traffic",
|
|
922
|
+
format: "number",
|
|
923
|
+
apiField: "crashAffectedUsers",
|
|
924
|
+
type: "api"
|
|
925
|
+
}
|
|
926
|
+
];
|
|
927
|
+
var ENGAGEMENT_METRICS = [
|
|
928
|
+
{
|
|
929
|
+
key: "averageSessionDuration",
|
|
930
|
+
name: "Avg. Session Duration",
|
|
931
|
+
description: "The average duration (in seconds) of users' sessions.",
|
|
932
|
+
category: "engagement",
|
|
933
|
+
format: "duration",
|
|
934
|
+
apiField: "averageSessionDuration",
|
|
935
|
+
type: "api"
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
key: "engagementRate",
|
|
939
|
+
name: "Engagement Rate",
|
|
940
|
+
description: "The percentage of engaged sessions (sessions that lasted 10 seconds or longer, had a conversion event, or had 2 or more page/screen views).",
|
|
941
|
+
category: "engagement",
|
|
942
|
+
format: "percent",
|
|
943
|
+
apiField: "engagementRate",
|
|
944
|
+
type: "api"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
key: "screenPageViewsPerSession",
|
|
948
|
+
name: "Pages per Session",
|
|
949
|
+
description: "The average number of pages viewed per session.",
|
|
950
|
+
category: "engagement",
|
|
951
|
+
format: "ratio",
|
|
952
|
+
apiField: "screenPageViewsPerSession",
|
|
953
|
+
type: "api"
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
key: "eventCount",
|
|
957
|
+
name: "Event Count",
|
|
958
|
+
description: "The total number of events triggered by users.",
|
|
959
|
+
category: "engagement",
|
|
960
|
+
format: "number",
|
|
961
|
+
apiField: "eventCount",
|
|
962
|
+
type: "api"
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
key: "eventsPerSession",
|
|
966
|
+
name: "Events per Session",
|
|
967
|
+
description: "The average number of events triggered per session.",
|
|
968
|
+
category: "engagement",
|
|
969
|
+
format: "ratio",
|
|
970
|
+
apiField: "eventsPerSession",
|
|
971
|
+
type: "api"
|
|
972
|
+
},
|
|
973
|
+
{
|
|
974
|
+
key: "userEngagementDuration",
|
|
975
|
+
name: "User Engagement Duration",
|
|
976
|
+
description: "The total amount of time (in seconds) your app was in the foreground or your website had focus in the browser.",
|
|
977
|
+
category: "engagement",
|
|
978
|
+
format: "duration",
|
|
979
|
+
apiField: "userEngagementDuration",
|
|
980
|
+
type: "api"
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
key: "scrolledUsers",
|
|
984
|
+
name: "Scrolled Users",
|
|
985
|
+
description: "The number of unique users who scrolled down at least 90% of the page.",
|
|
986
|
+
category: "engagement",
|
|
987
|
+
format: "number",
|
|
988
|
+
apiField: "scrolledUsers",
|
|
989
|
+
type: "api"
|
|
990
|
+
}
|
|
991
|
+
];
|
|
992
|
+
var REVENUE_METRICS = [
|
|
993
|
+
{
|
|
994
|
+
key: "totalRevenue",
|
|
995
|
+
name: "Total Revenue",
|
|
996
|
+
description: "The sum of revenue from purchases, subscriptions, and advertising (purchase revenue plus subscription revenue plus ad revenue).",
|
|
997
|
+
category: "revenue",
|
|
998
|
+
format: "currency",
|
|
999
|
+
apiField: "totalRevenue",
|
|
1000
|
+
type: "api"
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
key: "purchaseRevenue",
|
|
1004
|
+
name: "Purchase Revenue",
|
|
1005
|
+
description: "The sum of revenue from purchases made on your site or app.",
|
|
1006
|
+
category: "revenue",
|
|
1007
|
+
format: "currency",
|
|
1008
|
+
apiField: "purchaseRevenue",
|
|
1009
|
+
type: "api"
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
key: "ecommercePurchases",
|
|
1013
|
+
name: "Purchases",
|
|
1014
|
+
description: "The number of times users completed a purchase.",
|
|
1015
|
+
category: "revenue",
|
|
1016
|
+
format: "number",
|
|
1017
|
+
apiField: "ecommercePurchases",
|
|
1018
|
+
type: "api"
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
key: "transactions",
|
|
1022
|
+
name: "Transactions",
|
|
1023
|
+
description: "The count of transaction events with purchase revenue.",
|
|
1024
|
+
category: "revenue",
|
|
1025
|
+
format: "number",
|
|
1026
|
+
apiField: "transactions",
|
|
1027
|
+
type: "api"
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
key: "averagePurchaseRevenue",
|
|
1031
|
+
name: "Avg. Purchase Revenue",
|
|
1032
|
+
description: "The average revenue per purchase (total purchase revenue divided by the number of purchases).",
|
|
1033
|
+
category: "revenue",
|
|
1034
|
+
format: "currency",
|
|
1035
|
+
apiField: "averagePurchaseRevenue",
|
|
1036
|
+
type: "api"
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
key: "averageRevenuePerUser",
|
|
1040
|
+
name: "ARPU",
|
|
1041
|
+
description: "Average revenue per user (total revenue divided by total users).",
|
|
1042
|
+
category: "revenue",
|
|
1043
|
+
format: "currency",
|
|
1044
|
+
apiField: "averageRevenuePerUser",
|
|
1045
|
+
type: "api"
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
key: "itemRevenue",
|
|
1049
|
+
name: "Item Revenue",
|
|
1050
|
+
description: "The total revenue from items only (product sales revenue).",
|
|
1051
|
+
category: "revenue",
|
|
1052
|
+
format: "currency",
|
|
1053
|
+
apiField: "itemRevenue",
|
|
1054
|
+
type: "api"
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
key: "itemsPurchased",
|
|
1058
|
+
name: "Items Purchased",
|
|
1059
|
+
description: "The number of units purchased across all items.",
|
|
1060
|
+
category: "revenue",
|
|
1061
|
+
format: "number",
|
|
1062
|
+
apiField: "itemsPurchased",
|
|
1063
|
+
type: "api"
|
|
1064
|
+
},
|
|
1065
|
+
{
|
|
1066
|
+
key: "refundAmount",
|
|
1067
|
+
name: "Refund Amount",
|
|
1068
|
+
description: "The total amount of refunds issued.",
|
|
1069
|
+
category: "revenue",
|
|
1070
|
+
format: "currency",
|
|
1071
|
+
apiField: "refundAmount",
|
|
1072
|
+
type: "api"
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
key: "shippingAmount",
|
|
1076
|
+
name: "Shipping Amount",
|
|
1077
|
+
description: "The total shipping amount associated with transactions.",
|
|
1078
|
+
category: "revenue",
|
|
1079
|
+
format: "currency",
|
|
1080
|
+
apiField: "shippingAmount",
|
|
1081
|
+
type: "api"
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
key: "taxAmount",
|
|
1085
|
+
name: "Tax Amount",
|
|
1086
|
+
description: "The total tax amount associated with transactions.",
|
|
1087
|
+
category: "revenue",
|
|
1088
|
+
format: "currency",
|
|
1089
|
+
apiField: "taxAmount",
|
|
1090
|
+
type: "api"
|
|
1091
|
+
}
|
|
1092
|
+
];
|
|
1093
|
+
var ECOMMERCE_METRICS = [
|
|
1094
|
+
{
|
|
1095
|
+
key: "addToCarts",
|
|
1096
|
+
name: "Add to Carts",
|
|
1097
|
+
description: "The number of times users added items to their shopping carts.",
|
|
1098
|
+
category: "ecommerce",
|
|
1099
|
+
format: "number",
|
|
1100
|
+
apiField: "addToCarts",
|
|
1101
|
+
type: "api"
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
key: "checkouts",
|
|
1105
|
+
name: "Checkouts",
|
|
1106
|
+
description: "The number of times users started the checkout process.",
|
|
1107
|
+
category: "ecommerce",
|
|
1108
|
+
format: "number",
|
|
1109
|
+
apiField: "checkouts",
|
|
1110
|
+
type: "api"
|
|
1111
|
+
},
|
|
1112
|
+
{
|
|
1113
|
+
key: "itemsViewed",
|
|
1114
|
+
name: "Items Viewed",
|
|
1115
|
+
description: "The number of items viewed by users.",
|
|
1116
|
+
category: "ecommerce",
|
|
1117
|
+
format: "number",
|
|
1118
|
+
apiField: "itemsViewed",
|
|
1119
|
+
type: "api"
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
key: "itemsAddedToCart",
|
|
1123
|
+
name: "Items Added to Cart",
|
|
1124
|
+
description: "The number of units added to cart across all items.",
|
|
1125
|
+
category: "ecommerce",
|
|
1126
|
+
format: "number",
|
|
1127
|
+
apiField: "itemsAddedToCart",
|
|
1128
|
+
type: "api"
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
key: "itemsCheckedOut",
|
|
1132
|
+
name: "Items Checked Out",
|
|
1133
|
+
description: "The number of units included in checkout across all items.",
|
|
1134
|
+
category: "ecommerce",
|
|
1135
|
+
format: "number",
|
|
1136
|
+
apiField: "itemsCheckedOut",
|
|
1137
|
+
type: "api"
|
|
1138
|
+
},
|
|
1139
|
+
{
|
|
1140
|
+
key: "cartToViewRate",
|
|
1141
|
+
name: "Cart-to-View Rate",
|
|
1142
|
+
description: "The percentage of users who added items to their cart after viewing them.",
|
|
1143
|
+
category: "ecommerce",
|
|
1144
|
+
format: "percent",
|
|
1145
|
+
apiField: "cartToViewRate",
|
|
1146
|
+
type: "api"
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
key: "purchaseToViewRate",
|
|
1150
|
+
name: "Purchase-to-View Rate",
|
|
1151
|
+
description: "The percentage of users who purchased items after viewing them.",
|
|
1152
|
+
category: "ecommerce",
|
|
1153
|
+
format: "percent",
|
|
1154
|
+
apiField: "purchaseToViewRate",
|
|
1155
|
+
type: "api"
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
key: "itemViewEvents",
|
|
1159
|
+
name: "Item View Events",
|
|
1160
|
+
description: "The number of times item details were viewed (view_item events).",
|
|
1161
|
+
category: "ecommerce",
|
|
1162
|
+
format: "number",
|
|
1163
|
+
apiField: "itemViewEvents",
|
|
1164
|
+
type: "api"
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
key: "itemListClickEvents",
|
|
1168
|
+
name: "Item List Click Events",
|
|
1169
|
+
description: "The number of times users clicked an item in an item list (select_item events).",
|
|
1170
|
+
category: "ecommerce",
|
|
1171
|
+
format: "number",
|
|
1172
|
+
apiField: "itemListClickEvents",
|
|
1173
|
+
type: "api"
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
key: "itemListViewEvents",
|
|
1177
|
+
name: "Item List View Events",
|
|
1178
|
+
description: "The number of times an item list was viewed (view_item_list events).",
|
|
1179
|
+
category: "ecommerce",
|
|
1180
|
+
format: "number",
|
|
1181
|
+
apiField: "itemListViewEvents",
|
|
1182
|
+
type: "api"
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
key: "itemListClickThroughRate",
|
|
1186
|
+
name: "Item List CTR",
|
|
1187
|
+
description: "The percentage of users who clicked through after viewing an item list.",
|
|
1188
|
+
category: "ecommerce",
|
|
1189
|
+
format: "percent",
|
|
1190
|
+
apiField: "itemListClickThroughRate",
|
|
1191
|
+
type: "api"
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
key: "itemPromotionClickThroughRate",
|
|
1195
|
+
name: "Promotion CTR",
|
|
1196
|
+
description: "The percentage of users who clicked through after viewing an item promotion.",
|
|
1197
|
+
category: "ecommerce",
|
|
1198
|
+
format: "percent",
|
|
1199
|
+
apiField: "itemPromotionClickThroughRate",
|
|
1200
|
+
type: "api"
|
|
1201
|
+
}
|
|
1202
|
+
];
|
|
1203
|
+
var CONVERSION_METRICS = [
|
|
1204
|
+
{
|
|
1205
|
+
key: "conversions",
|
|
1206
|
+
name: "Conversions",
|
|
1207
|
+
description: "The total count of conversion events.",
|
|
1208
|
+
category: "conversions",
|
|
1209
|
+
format: "number",
|
|
1210
|
+
apiField: "conversions",
|
|
1211
|
+
type: "api"
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
key: "keyEvents",
|
|
1215
|
+
name: "Key Events",
|
|
1216
|
+
description: "The count of key events (formerly conversions) triggered by users.",
|
|
1217
|
+
category: "conversions",
|
|
1218
|
+
format: "number",
|
|
1219
|
+
apiField: "keyEvents",
|
|
1220
|
+
type: "api"
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
key: "firstTimePurchasers",
|
|
1224
|
+
name: "First-time Purchasers",
|
|
1225
|
+
description: "The number of users who completed their first purchase.",
|
|
1226
|
+
category: "conversions",
|
|
1227
|
+
format: "number",
|
|
1228
|
+
apiField: "firstTimePurchasers",
|
|
1229
|
+
type: "api"
|
|
1230
|
+
},
|
|
1231
|
+
{
|
|
1232
|
+
key: "firstTimePurchaserRate",
|
|
1233
|
+
name: "First-time Purchaser Rate",
|
|
1234
|
+
description: "The percentage of active users who made their first purchase.",
|
|
1235
|
+
category: "conversions",
|
|
1236
|
+
format: "percent",
|
|
1237
|
+
apiField: "firstTimePurchaserConversionRate",
|
|
1238
|
+
type: "api"
|
|
1239
|
+
}
|
|
1240
|
+
];
|
|
1241
|
+
var ADS_METRICS = [
|
|
1242
|
+
{
|
|
1243
|
+
key: "publisherAdClicks",
|
|
1244
|
+
name: "Publisher Ad Clicks",
|
|
1245
|
+
description: "The number of times users clicked on an ad served by a publisher on your site or app.",
|
|
1246
|
+
category: "ads",
|
|
1247
|
+
format: "number",
|
|
1248
|
+
apiField: "publisherAdClicks",
|
|
1249
|
+
type: "api"
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
key: "publisherAdImpressions",
|
|
1253
|
+
name: "Publisher Ad Impressions",
|
|
1254
|
+
description: "The number of ad impressions served by a publisher on your site or app.",
|
|
1255
|
+
category: "ads",
|
|
1256
|
+
format: "number",
|
|
1257
|
+
apiField: "publisherAdImpressions",
|
|
1258
|
+
type: "api"
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
key: "totalAdRevenue",
|
|
1262
|
+
name: "Total Ad Revenue",
|
|
1263
|
+
description: "The total revenue earned from ads served on your site or app.",
|
|
1264
|
+
category: "ads",
|
|
1265
|
+
format: "currency",
|
|
1266
|
+
apiField: "totalAdRevenue",
|
|
1267
|
+
type: "api"
|
|
1268
|
+
},
|
|
1269
|
+
{
|
|
1270
|
+
key: "returnOnAdSpend",
|
|
1271
|
+
name: "Return on Ad Spend",
|
|
1272
|
+
description: "The return on ad spend (total revenue divided by advertiser ad cost).",
|
|
1273
|
+
category: "ads",
|
|
1274
|
+
format: "ratio",
|
|
1275
|
+
apiField: "returnOnAdSpend",
|
|
1276
|
+
type: "api"
|
|
1277
|
+
}
|
|
1278
|
+
];
|
|
1279
|
+
var USER_METRICS = [
|
|
1280
|
+
{
|
|
1281
|
+
key: "dauPerMau",
|
|
1282
|
+
name: "DAU/MAU",
|
|
1283
|
+
description: "The rolling ratio of daily active users to monthly active users. A measure of user stickiness.",
|
|
1284
|
+
category: "user",
|
|
1285
|
+
format: "ratio",
|
|
1286
|
+
apiField: "dauPerMau",
|
|
1287
|
+
type: "api"
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
key: "dauPerWau",
|
|
1291
|
+
name: "DAU/WAU",
|
|
1292
|
+
description: "The rolling ratio of daily active users to weekly active users.",
|
|
1293
|
+
category: "user",
|
|
1294
|
+
format: "ratio",
|
|
1295
|
+
apiField: "dauPerWau",
|
|
1296
|
+
type: "api"
|
|
1297
|
+
},
|
|
1298
|
+
{
|
|
1299
|
+
key: "wauPerMau",
|
|
1300
|
+
name: "WAU/MAU",
|
|
1301
|
+
description: "The rolling ratio of weekly active users to monthly active users.",
|
|
1302
|
+
category: "user",
|
|
1303
|
+
format: "ratio",
|
|
1304
|
+
apiField: "wauPerMau",
|
|
1305
|
+
type: "api"
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
key: "crashFreeUsersRate",
|
|
1309
|
+
name: "Crash-free Users Rate",
|
|
1310
|
+
description: "The percentage of users who did not experience a crash during the selected time period.",
|
|
1311
|
+
category: "user",
|
|
1312
|
+
format: "percent",
|
|
1313
|
+
apiField: "crashFreeUsersRate",
|
|
1314
|
+
type: "api"
|
|
1315
|
+
}
|
|
1316
|
+
];
|
|
1317
|
+
var GA4_METRIC_CATALOG = [
|
|
1318
|
+
...TRAFFIC_METRICS,
|
|
1319
|
+
...ENGAGEMENT_METRICS,
|
|
1320
|
+
...REVENUE_METRICS,
|
|
1321
|
+
...ECOMMERCE_METRICS,
|
|
1322
|
+
...CONVERSION_METRICS,
|
|
1323
|
+
...ADS_METRICS,
|
|
1324
|
+
...USER_METRICS
|
|
1325
|
+
];
|
|
1326
|
+
function getMetricByKey(key) {
|
|
1327
|
+
return GA4_METRIC_CATALOG.find((m) => m.key === key || m.apiField === key);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/platforms/ga4/dimension-catalog.ts
|
|
1331
|
+
var TIME_DIMENSIONS = [
|
|
1332
|
+
{
|
|
1333
|
+
key: "date",
|
|
1334
|
+
name: "Date",
|
|
1335
|
+
description: "The date of the event (YYYYMMDD format)",
|
|
1336
|
+
category: "time",
|
|
1337
|
+
apiField: "date"
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
key: "dateHour",
|
|
1341
|
+
name: "Date + Hour",
|
|
1342
|
+
description: "The combined date and hour of the event (YYYYMMDDHH format)",
|
|
1343
|
+
category: "time",
|
|
1344
|
+
apiField: "dateHour"
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
key: "dayOfWeek",
|
|
1348
|
+
name: "Day of Week",
|
|
1349
|
+
description: "The day of the week as a number (0 = Sunday through 6 = Saturday)",
|
|
1350
|
+
category: "time",
|
|
1351
|
+
apiField: "dayOfWeek"
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
key: "dayOfWeekName",
|
|
1355
|
+
name: "Day Name",
|
|
1356
|
+
description: "The name of the day of the week (e.g., Sunday, Monday)",
|
|
1357
|
+
category: "time",
|
|
1358
|
+
apiField: "dayOfWeekName"
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
key: "month",
|
|
1362
|
+
name: "Month",
|
|
1363
|
+
description: "The month of the event (two-digit number, 01-12)",
|
|
1364
|
+
category: "time",
|
|
1365
|
+
apiField: "month"
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
key: "year",
|
|
1369
|
+
name: "Year",
|
|
1370
|
+
description: "The four-digit year of the event (e.g., 2026)",
|
|
1371
|
+
category: "time",
|
|
1372
|
+
apiField: "year"
|
|
1373
|
+
},
|
|
1374
|
+
{
|
|
1375
|
+
key: "nthDay",
|
|
1376
|
+
name: "Nth Day",
|
|
1377
|
+
description: "The number of days since the start of the date range",
|
|
1378
|
+
category: "time",
|
|
1379
|
+
apiField: "nthDay"
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
key: "hour",
|
|
1383
|
+
name: "Hour",
|
|
1384
|
+
description: "The hour of the event (two-digit number, 00-23)",
|
|
1385
|
+
category: "time",
|
|
1386
|
+
apiField: "hour"
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
key: "isoWeek",
|
|
1390
|
+
name: "ISO Week",
|
|
1391
|
+
description: "The ISO week number of the year (01-53)",
|
|
1392
|
+
category: "time",
|
|
1393
|
+
apiField: "isoWeek"
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
key: "isoYearIsoWeek",
|
|
1397
|
+
name: "Year + ISO Week",
|
|
1398
|
+
description: "The combined ISO year and ISO week number (e.g., 202610)",
|
|
1399
|
+
category: "time",
|
|
1400
|
+
apiField: "isoYearIsoWeek"
|
|
1401
|
+
},
|
|
1402
|
+
{
|
|
1403
|
+
key: "yearMonth",
|
|
1404
|
+
name: "Year + Month",
|
|
1405
|
+
description: "The combined year and month of the event (YYYYMM format)",
|
|
1406
|
+
category: "time",
|
|
1407
|
+
apiField: "yearMonth"
|
|
1408
|
+
}
|
|
1409
|
+
];
|
|
1410
|
+
var TRAFFIC_SOURCE_DIMENSIONS = [
|
|
1411
|
+
{
|
|
1412
|
+
key: "sessionSource",
|
|
1413
|
+
name: "Session Source",
|
|
1414
|
+
description: "The source that initiated the session (e.g., google, facebook, direct)",
|
|
1415
|
+
category: "traffic_source",
|
|
1416
|
+
apiField: "sessionSource"
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
key: "sessionMedium",
|
|
1420
|
+
name: "Session Medium",
|
|
1421
|
+
description: "The medium of the session (e.g., organic, cpc, referral, email)",
|
|
1422
|
+
category: "traffic_source",
|
|
1423
|
+
apiField: "sessionMedium"
|
|
1424
|
+
},
|
|
1425
|
+
{
|
|
1426
|
+
key: "sessionCampaignName",
|
|
1427
|
+
name: "Session Campaign",
|
|
1428
|
+
description: "The campaign name associated with the session",
|
|
1429
|
+
category: "traffic_source",
|
|
1430
|
+
apiField: "sessionCampaignName"
|
|
1431
|
+
},
|
|
1432
|
+
{
|
|
1433
|
+
key: "sessionDefaultChannelGroup",
|
|
1434
|
+
name: "Session Default Channel Group",
|
|
1435
|
+
description: "The default channel grouping for the session (e.g., Organic Search, Paid Search, Direct)",
|
|
1436
|
+
category: "traffic_source",
|
|
1437
|
+
apiField: "sessionDefaultChannelGroup"
|
|
1438
|
+
},
|
|
1439
|
+
{
|
|
1440
|
+
key: "sessionPrimaryChannelGroup",
|
|
1441
|
+
name: "Session Primary Channel Group (Custom)",
|
|
1442
|
+
description: "The custom/primary channel group for the session. Uses custom channel group rules configured in GA4 Admin. Use this dimension (not sessionDefaultChannelGroup) when the user asks for custom or personalized channel groups.",
|
|
1443
|
+
category: "traffic_source",
|
|
1444
|
+
apiField: "sessionPrimaryChannelGroup"
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
key: "sessionSourceMedium",
|
|
1448
|
+
name: "Session Source / Medium",
|
|
1449
|
+
description: "The combined source and medium of the session (e.g., google / organic)",
|
|
1450
|
+
category: "traffic_source",
|
|
1451
|
+
apiField: "sessionSourceMedium"
|
|
1452
|
+
},
|
|
1453
|
+
{
|
|
1454
|
+
key: "firstUserSource",
|
|
1455
|
+
name: "First User Source",
|
|
1456
|
+
description: "The source that first acquired the user",
|
|
1457
|
+
category: "traffic_source",
|
|
1458
|
+
apiField: "firstUserSource"
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
key: "firstUserMedium",
|
|
1462
|
+
name: "First User Medium",
|
|
1463
|
+
description: "The medium that first acquired the user",
|
|
1464
|
+
category: "traffic_source",
|
|
1465
|
+
apiField: "firstUserMedium"
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
key: "firstUserCampaignName",
|
|
1469
|
+
name: "First User Campaign",
|
|
1470
|
+
description: "The campaign that first acquired the user",
|
|
1471
|
+
category: "traffic_source",
|
|
1472
|
+
apiField: "firstUserCampaignName"
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
key: "firstUserDefaultChannelGroup",
|
|
1476
|
+
name: "First User Default Channel Group",
|
|
1477
|
+
description: "The default channel grouping that first acquired the user",
|
|
1478
|
+
category: "traffic_source",
|
|
1479
|
+
apiField: "firstUserDefaultChannelGroup"
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
key: "firstUserPrimaryChannelGroup",
|
|
1483
|
+
name: "First User Primary Channel Group (Custom)",
|
|
1484
|
+
description: "The custom/primary channel group that first acquired the user. Uses custom channel group rules configured in GA4 Admin.",
|
|
1485
|
+
category: "traffic_source",
|
|
1486
|
+
apiField: "firstUserPrimaryChannelGroup"
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
key: "firstUserSourceMedium",
|
|
1490
|
+
name: "First User Source / Medium",
|
|
1491
|
+
description: "The combined source and medium that first acquired the user",
|
|
1492
|
+
category: "traffic_source",
|
|
1493
|
+
apiField: "firstUserSourceMedium"
|
|
1494
|
+
}
|
|
1495
|
+
];
|
|
1496
|
+
var GEOGRAPHY_DIMENSIONS = [
|
|
1497
|
+
{
|
|
1498
|
+
key: "country",
|
|
1499
|
+
name: "Country",
|
|
1500
|
+
description: "The country from which the user activity originated",
|
|
1501
|
+
category: "geography",
|
|
1502
|
+
apiField: "country"
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
key: "city",
|
|
1506
|
+
name: "City",
|
|
1507
|
+
description: "The city from which the user activity originated",
|
|
1508
|
+
category: "geography",
|
|
1509
|
+
apiField: "city"
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
key: "region",
|
|
1513
|
+
name: "Region",
|
|
1514
|
+
description: "The geographic region (state/province) from which the user activity originated",
|
|
1515
|
+
category: "geography",
|
|
1516
|
+
apiField: "region"
|
|
1517
|
+
},
|
|
1518
|
+
{
|
|
1519
|
+
key: "continent",
|
|
1520
|
+
name: "Continent",
|
|
1521
|
+
description: "The continent from which the user activity originated",
|
|
1522
|
+
category: "geography",
|
|
1523
|
+
apiField: "continent"
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
key: "countryId",
|
|
1527
|
+
name: "Country ID",
|
|
1528
|
+
description: "The ISO 3166 country code (e.g., US, GB, FR)",
|
|
1529
|
+
category: "geography",
|
|
1530
|
+
apiField: "countryId"
|
|
1531
|
+
},
|
|
1532
|
+
{
|
|
1533
|
+
key: "subContinent",
|
|
1534
|
+
name: "Sub Continent",
|
|
1535
|
+
description: "The sub-continent from which the user activity originated (e.g., Northern America, Western Europe)",
|
|
1536
|
+
category: "geography",
|
|
1537
|
+
apiField: "subContinent"
|
|
1538
|
+
}
|
|
1539
|
+
];
|
|
1540
|
+
var DEVICE_DIMENSIONS = [
|
|
1541
|
+
{
|
|
1542
|
+
key: "deviceCategory",
|
|
1543
|
+
name: "Device Category",
|
|
1544
|
+
description: "The type of device (desktop, mobile, or tablet)",
|
|
1545
|
+
category: "device",
|
|
1546
|
+
apiField: "deviceCategory"
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
key: "operatingSystem",
|
|
1550
|
+
name: "Operating System",
|
|
1551
|
+
description: "The operating system used by the visitor (e.g., Android, iOS, Windows, Macintosh)",
|
|
1552
|
+
category: "device",
|
|
1553
|
+
apiField: "operatingSystem"
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
key: "browser",
|
|
1557
|
+
name: "Browser",
|
|
1558
|
+
description: "The browser used by the visitor (e.g., Chrome, Safari, Firefox, Edge)",
|
|
1559
|
+
category: "device",
|
|
1560
|
+
apiField: "browser"
|
|
1561
|
+
},
|
|
1562
|
+
{
|
|
1563
|
+
key: "screenResolution",
|
|
1564
|
+
name: "Screen Resolution",
|
|
1565
|
+
description: "The screen resolution of the user's device (e.g., 1920x1080)",
|
|
1566
|
+
category: "device",
|
|
1567
|
+
apiField: "screenResolution"
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
key: "mobileDeviceModel",
|
|
1571
|
+
name: "Mobile Device Model",
|
|
1572
|
+
description: "The model name of the mobile device (e.g., iPhone 15, Pixel 8)",
|
|
1573
|
+
category: "device",
|
|
1574
|
+
apiField: "mobileDeviceModel"
|
|
1575
|
+
},
|
|
1576
|
+
{
|
|
1577
|
+
key: "mobileDeviceBranding",
|
|
1578
|
+
name: "Mobile Device Brand",
|
|
1579
|
+
description: "The brand or manufacturer of the mobile device (e.g., Apple, Samsung, Google)",
|
|
1580
|
+
category: "device",
|
|
1581
|
+
apiField: "mobileDeviceBranding"
|
|
1582
|
+
},
|
|
1583
|
+
{
|
|
1584
|
+
key: "platform",
|
|
1585
|
+
name: "Platform",
|
|
1586
|
+
description: "The platform on which the app or website was accessed (web, iOS, Android)",
|
|
1587
|
+
category: "device",
|
|
1588
|
+
apiField: "platform"
|
|
1589
|
+
},
|
|
1590
|
+
{
|
|
1591
|
+
key: "language",
|
|
1592
|
+
name: "Language",
|
|
1593
|
+
description: "The language setting of the user's browser or device (e.g., en-us, fr, de)",
|
|
1594
|
+
category: "device",
|
|
1595
|
+
apiField: "language"
|
|
1596
|
+
}
|
|
1597
|
+
];
|
|
1598
|
+
var PAGE_DIMENSIONS = [
|
|
1599
|
+
{
|
|
1600
|
+
key: "pagePath",
|
|
1601
|
+
name: "Page Path",
|
|
1602
|
+
description: "The URL path of the page (without query string or hostname)",
|
|
1603
|
+
category: "page",
|
|
1604
|
+
apiField: "pagePath"
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
key: "pageTitle",
|
|
1608
|
+
name: "Page Title",
|
|
1609
|
+
description: "The title of the page as set in the HTML <title> tag",
|
|
1610
|
+
category: "page",
|
|
1611
|
+
apiField: "pageTitle"
|
|
1612
|
+
},
|
|
1613
|
+
{
|
|
1614
|
+
key: "landingPage",
|
|
1615
|
+
name: "Landing Page",
|
|
1616
|
+
description: "The page path of the first page viewed in a session",
|
|
1617
|
+
category: "page",
|
|
1618
|
+
apiField: "landingPage"
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
key: "hostname",
|
|
1622
|
+
name: "Hostname",
|
|
1623
|
+
description: "The hostname of the URL (e.g., www.example.com)",
|
|
1624
|
+
category: "page",
|
|
1625
|
+
apiField: "hostname"
|
|
1626
|
+
},
|
|
1627
|
+
{
|
|
1628
|
+
key: "pageReferrer",
|
|
1629
|
+
name: "Page Referrer",
|
|
1630
|
+
description: "The full referring URL including hostname and path",
|
|
1631
|
+
category: "page",
|
|
1632
|
+
apiField: "pageReferrer"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
key: "landingPagePlusQueryString",
|
|
1636
|
+
name: "Landing Page + Query",
|
|
1637
|
+
description: "The landing page path including the query string parameters",
|
|
1638
|
+
category: "page",
|
|
1639
|
+
apiField: "landingPagePlusQueryString"
|
|
1640
|
+
},
|
|
1641
|
+
{
|
|
1642
|
+
key: "pagePathPlusQueryString",
|
|
1643
|
+
name: "Page Path + Query",
|
|
1644
|
+
description: "The page path including the query string parameters",
|
|
1645
|
+
category: "page",
|
|
1646
|
+
apiField: "pagePathPlusQueryString"
|
|
1647
|
+
}
|
|
1648
|
+
];
|
|
1649
|
+
var EVENT_DIMENSIONS = [
|
|
1650
|
+
{
|
|
1651
|
+
key: "eventName",
|
|
1652
|
+
name: "Event Name",
|
|
1653
|
+
description: "The name of the event (e.g., page_view, purchase, click, scroll)",
|
|
1654
|
+
category: "event",
|
|
1655
|
+
apiField: "eventName"
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
key: "isConversionEvent",
|
|
1659
|
+
name: "Is Conversion Event",
|
|
1660
|
+
description: "Whether the event is marked as a conversion (true or false)",
|
|
1661
|
+
category: "event",
|
|
1662
|
+
apiField: "isConversionEvent"
|
|
1663
|
+
}
|
|
1664
|
+
];
|
|
1665
|
+
var ECOMMERCE_DIMENSIONS = [
|
|
1666
|
+
{
|
|
1667
|
+
key: "itemId",
|
|
1668
|
+
name: "Item ID",
|
|
1669
|
+
description: "The ID of the ecommerce item",
|
|
1670
|
+
category: "ecommerce",
|
|
1671
|
+
apiField: "itemId"
|
|
1672
|
+
},
|
|
1673
|
+
{
|
|
1674
|
+
key: "itemName",
|
|
1675
|
+
name: "Item Name",
|
|
1676
|
+
description: "The name of the ecommerce item",
|
|
1677
|
+
category: "ecommerce",
|
|
1678
|
+
apiField: "itemName"
|
|
1679
|
+
},
|
|
1680
|
+
{
|
|
1681
|
+
key: "itemBrand",
|
|
1682
|
+
name: "Item Brand",
|
|
1683
|
+
description: "The brand of the ecommerce item",
|
|
1684
|
+
category: "ecommerce",
|
|
1685
|
+
apiField: "itemBrand"
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
key: "itemCategory",
|
|
1689
|
+
name: "Item Category",
|
|
1690
|
+
description: "The primary category of the ecommerce item",
|
|
1691
|
+
category: "ecommerce",
|
|
1692
|
+
apiField: "itemCategory"
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
key: "itemCategory2",
|
|
1696
|
+
name: "Item Category 2",
|
|
1697
|
+
description: "The second-level category of the ecommerce item",
|
|
1698
|
+
category: "ecommerce",
|
|
1699
|
+
apiField: "itemCategory2"
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
key: "itemCategory3",
|
|
1703
|
+
name: "Item Category 3",
|
|
1704
|
+
description: "The third-level category of the ecommerce item",
|
|
1705
|
+
category: "ecommerce",
|
|
1706
|
+
apiField: "itemCategory3"
|
|
1707
|
+
},
|
|
1708
|
+
{
|
|
1709
|
+
key: "itemVariant",
|
|
1710
|
+
name: "Item Variant",
|
|
1711
|
+
description: "The variant of the ecommerce item (e.g., size, color)",
|
|
1712
|
+
category: "ecommerce",
|
|
1713
|
+
apiField: "itemVariant"
|
|
1714
|
+
},
|
|
1715
|
+
{
|
|
1716
|
+
key: "itemListName",
|
|
1717
|
+
name: "Item List Name",
|
|
1718
|
+
description: "The name of the list in which the item was presented to the user",
|
|
1719
|
+
category: "ecommerce",
|
|
1720
|
+
apiField: "itemListName"
|
|
1721
|
+
},
|
|
1722
|
+
{
|
|
1723
|
+
key: "itemPromotionName",
|
|
1724
|
+
name: "Item Promotion Name",
|
|
1725
|
+
description: "The name of the promotion applied to the ecommerce item",
|
|
1726
|
+
category: "ecommerce",
|
|
1727
|
+
apiField: "itemPromotionName"
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
key: "transactionId",
|
|
1731
|
+
name: "Transaction ID",
|
|
1732
|
+
description: "The unique identifier of the ecommerce transaction",
|
|
1733
|
+
category: "ecommerce",
|
|
1734
|
+
apiField: "transactionId"
|
|
1735
|
+
},
|
|
1736
|
+
{
|
|
1737
|
+
key: "orderCoupon",
|
|
1738
|
+
name: "Order Coupon",
|
|
1739
|
+
description: "The coupon code applied to the order",
|
|
1740
|
+
category: "ecommerce",
|
|
1741
|
+
apiField: "orderCoupon"
|
|
1742
|
+
},
|
|
1743
|
+
{
|
|
1744
|
+
key: "shippingTier",
|
|
1745
|
+
name: "Shipping Tier",
|
|
1746
|
+
description: "The shipping tier selected for the order (e.g., Ground, Express, Next Day)",
|
|
1747
|
+
category: "ecommerce",
|
|
1748
|
+
apiField: "shippingTier"
|
|
1749
|
+
}
|
|
1750
|
+
];
|
|
1751
|
+
var USER_DIMENSIONS = [
|
|
1752
|
+
{
|
|
1753
|
+
key: "newVsReturning",
|
|
1754
|
+
name: "New vs Returning",
|
|
1755
|
+
description: "Whether the user is new or returning to the property",
|
|
1756
|
+
category: "user",
|
|
1757
|
+
apiField: "newVsReturning"
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
key: "userAgeBracket",
|
|
1761
|
+
name: "User Age Bracket",
|
|
1762
|
+
description: "The age bracket of the user (e.g., 18-24, 25-34, 35-44)",
|
|
1763
|
+
category: "user",
|
|
1764
|
+
apiField: "userAgeBracket"
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
key: "userGender",
|
|
1768
|
+
name: "User Gender",
|
|
1769
|
+
description: "The gender of the user (male, female)",
|
|
1770
|
+
category: "user",
|
|
1771
|
+
apiField: "userGender"
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
key: "audienceName",
|
|
1775
|
+
name: "Audience Name",
|
|
1776
|
+
description: "The name of the audience the user belongs to",
|
|
1777
|
+
category: "user",
|
|
1778
|
+
apiField: "audienceName"
|
|
1779
|
+
}
|
|
1780
|
+
];
|
|
1781
|
+
var GA4_DIMENSION_CATALOG = [
|
|
1782
|
+
...TIME_DIMENSIONS,
|
|
1783
|
+
...TRAFFIC_SOURCE_DIMENSIONS,
|
|
1784
|
+
...GEOGRAPHY_DIMENSIONS,
|
|
1785
|
+
...DEVICE_DIMENSIONS,
|
|
1786
|
+
...PAGE_DIMENSIONS,
|
|
1787
|
+
...EVENT_DIMENSIONS,
|
|
1788
|
+
...ECOMMERCE_DIMENSIONS,
|
|
1789
|
+
...USER_DIMENSIONS
|
|
1790
|
+
];
|
|
1791
|
+
function getDimensionByKey(key) {
|
|
1792
|
+
return GA4_DIMENSION_CATALOG.find((d) => d.key === key || d.apiField === key);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// src/platforms/ga4/compatibility-rules.ts
|
|
1796
|
+
var ECOMMERCE_ONLY_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
1797
|
+
"itemId",
|
|
1798
|
+
"itemName",
|
|
1799
|
+
"itemBrand",
|
|
1800
|
+
"itemCategory",
|
|
1801
|
+
"itemCategory2",
|
|
1802
|
+
"itemCategory3",
|
|
1803
|
+
"itemVariant",
|
|
1804
|
+
"itemListName",
|
|
1805
|
+
"itemListId",
|
|
1806
|
+
"itemPromotionName",
|
|
1807
|
+
"itemPromotionId",
|
|
1808
|
+
"orderCoupon",
|
|
1809
|
+
"shippingTier",
|
|
1810
|
+
"itemAffiliation",
|
|
1811
|
+
"transactionId"
|
|
1812
|
+
]);
|
|
1813
|
+
var ECOMMERCE_METRICS2 = /* @__PURE__ */ new Set([
|
|
1814
|
+
"addToCarts",
|
|
1815
|
+
"checkouts",
|
|
1816
|
+
"ecommercePurchases",
|
|
1817
|
+
"itemRevenue",
|
|
1818
|
+
"itemsPurchased",
|
|
1819
|
+
"itemsViewed",
|
|
1820
|
+
"itemsAddedToCart",
|
|
1821
|
+
"itemsCheckedOut",
|
|
1822
|
+
"cartToViewRate",
|
|
1823
|
+
"purchaseToViewRate",
|
|
1824
|
+
"itemViewEvents",
|
|
1825
|
+
"itemListClickEvents",
|
|
1826
|
+
"itemListViewEvents",
|
|
1827
|
+
"itemListClickThroughRate",
|
|
1828
|
+
"itemPromotionClickThroughRate",
|
|
1829
|
+
"totalRevenue",
|
|
1830
|
+
"purchaseRevenue",
|
|
1831
|
+
"averagePurchaseRevenue",
|
|
1832
|
+
"transactions",
|
|
1833
|
+
"refundAmount",
|
|
1834
|
+
"shippingAmount",
|
|
1835
|
+
"taxAmount"
|
|
1836
|
+
]);
|
|
1837
|
+
var INCOMPATIBLE_DIMENSION_PAIRS = [
|
|
1838
|
+
// Session-scoped vs user-scoped source dimensions
|
|
1839
|
+
["sessionSource", "firstUserSource"],
|
|
1840
|
+
["sessionMedium", "firstUserMedium"],
|
|
1841
|
+
["sessionCampaignName", "firstUserCampaignName"],
|
|
1842
|
+
["sessionDefaultChannelGroup", "firstUserDefaultChannelGroup"]
|
|
1843
|
+
];
|
|
1844
|
+
function validateGA4QuerySelection(metricKeys, dimensionKeys) {
|
|
1845
|
+
const errors = [];
|
|
1846
|
+
const warnings = [];
|
|
1847
|
+
const hasEcommerceDimensions = dimensionKeys.some((d) => ECOMMERCE_ONLY_DIMENSIONS.has(d));
|
|
1848
|
+
const hasEcommerceMetrics = metricKeys.some((m) => ECOMMERCE_METRICS2.has(m));
|
|
1849
|
+
if (hasEcommerceDimensions && !hasEcommerceMetrics) {
|
|
1850
|
+
warnings.push(
|
|
1851
|
+
"Ecommerce dimensions (itemName, itemBrand, etc.) require ecommerce metrics to return data. Add metrics like itemRevenue, addToCarts, or ecommercePurchases."
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
for (const [dim1, dim2] of INCOMPATIBLE_DIMENSION_PAIRS) {
|
|
1855
|
+
if (dimensionKeys.includes(dim1) && dimensionKeys.includes(dim2)) {
|
|
1856
|
+
warnings.push(
|
|
1857
|
+
`Dimensions "${dim1}" and "${dim2}" are session-scoped vs user-scoped. Using both may produce unexpected results.`
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
const highCardinalityDims = ["pagePath", "pageTitle", "pagePathPlusQueryString", "landingPagePlusQueryString"];
|
|
1862
|
+
const hasHighCardinality = dimensionKeys.some((d) => highCardinalityDims.includes(d));
|
|
1863
|
+
if (hasHighCardinality && dimensionKeys.length > 2) {
|
|
1864
|
+
warnings.push(
|
|
1865
|
+
"High-cardinality dimensions (pagePath, pageTitle) combined with other dimensions may produce very large result sets."
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
if (metricKeys.length === 0) {
|
|
1869
|
+
errors.push("At least one metric is required.");
|
|
1870
|
+
}
|
|
1871
|
+
if (dimensionKeys.length > 9) {
|
|
1872
|
+
errors.push(
|
|
1873
|
+
`GA4 supports a maximum of 9 dimensions per request. You selected ${dimensionKeys.length}.`
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
if (metricKeys.length > 10) {
|
|
1877
|
+
errors.push(
|
|
1878
|
+
`GA4 supports a maximum of 10 metrics per request. You selected ${metricKeys.length}.`
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
const timeDimensions = ["date", "dateHour", "dayOfWeek", "month", "year", "hour", "nthDay", "isoWeek", "yearMonth"];
|
|
1882
|
+
if (!dimensionKeys.some((d) => timeDimensions.includes(d))) {
|
|
1883
|
+
warnings.push(
|
|
1884
|
+
"No time dimension selected. Results will be aggregated over the entire date range."
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
return {
|
|
1888
|
+
valid: errors.length === 0,
|
|
1889
|
+
errors,
|
|
1890
|
+
warnings
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/platforms/ga4/calculated-metrics.ts
|
|
1895
|
+
var GA4_CALCULATED_METRIC_DEFINITIONS = [
|
|
1896
|
+
{
|
|
1897
|
+
key: "revenuePerSession",
|
|
1898
|
+
name: "Revenue per Session",
|
|
1899
|
+
description: "Total revenue divided by total sessions",
|
|
1900
|
+
category: "calculated",
|
|
1901
|
+
format: "currency",
|
|
1902
|
+
apiField: "",
|
|
1903
|
+
type: "calculated",
|
|
1904
|
+
formula: "totalRevenue / sessions",
|
|
1905
|
+
dependencies: ["totalRevenue", "sessions"]
|
|
1906
|
+
},
|
|
1907
|
+
{
|
|
1908
|
+
key: "conversionRate",
|
|
1909
|
+
name: "Conversion Rate",
|
|
1910
|
+
description: "Conversions divided by sessions (percentage)",
|
|
1911
|
+
category: "calculated",
|
|
1912
|
+
format: "percent",
|
|
1913
|
+
apiField: "",
|
|
1914
|
+
type: "calculated",
|
|
1915
|
+
formula: "(conversions / sessions) * 100",
|
|
1916
|
+
dependencies: ["conversions", "sessions"]
|
|
1917
|
+
},
|
|
1918
|
+
{
|
|
1919
|
+
key: "pagesPerSession",
|
|
1920
|
+
name: "Pages per Session",
|
|
1921
|
+
description: "Page views divided by sessions",
|
|
1922
|
+
category: "calculated",
|
|
1923
|
+
format: "ratio",
|
|
1924
|
+
apiField: "",
|
|
1925
|
+
type: "calculated",
|
|
1926
|
+
formula: "screenPageViews / sessions",
|
|
1927
|
+
dependencies: ["screenPageViews", "sessions"]
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
key: "aov",
|
|
1931
|
+
name: "Average Order Value",
|
|
1932
|
+
description: "Purchase revenue divided by number of purchases",
|
|
1933
|
+
category: "calculated",
|
|
1934
|
+
format: "currency",
|
|
1935
|
+
apiField: "",
|
|
1936
|
+
type: "calculated",
|
|
1937
|
+
formula: "purchaseRevenue / ecommercePurchases",
|
|
1938
|
+
dependencies: ["purchaseRevenue", "ecommercePurchases"]
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
key: "revenuePerUser",
|
|
1942
|
+
name: "Revenue per User",
|
|
1943
|
+
description: "Total revenue divided by total users",
|
|
1944
|
+
category: "calculated",
|
|
1945
|
+
format: "currency",
|
|
1946
|
+
apiField: "",
|
|
1947
|
+
type: "calculated",
|
|
1948
|
+
formula: "totalRevenue / totalUsers",
|
|
1949
|
+
dependencies: ["totalRevenue", "totalUsers"]
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
key: "addToCartRate",
|
|
1953
|
+
name: "Add to Cart Rate",
|
|
1954
|
+
description: "Add to carts divided by sessions (percentage)",
|
|
1955
|
+
category: "calculated",
|
|
1956
|
+
format: "percent",
|
|
1957
|
+
apiField: "",
|
|
1958
|
+
type: "calculated",
|
|
1959
|
+
formula: "(addToCarts / sessions) * 100",
|
|
1960
|
+
dependencies: ["addToCarts", "sessions"]
|
|
1961
|
+
},
|
|
1962
|
+
{
|
|
1963
|
+
key: "checkoutRate",
|
|
1964
|
+
name: "Checkout Rate",
|
|
1965
|
+
description: "Checkouts divided by sessions (percentage)",
|
|
1966
|
+
category: "calculated",
|
|
1967
|
+
format: "percent",
|
|
1968
|
+
apiField: "",
|
|
1969
|
+
type: "calculated",
|
|
1970
|
+
formula: "(checkouts / sessions) * 100",
|
|
1971
|
+
dependencies: ["checkouts", "sessions"]
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
key: "purchaseRate",
|
|
1975
|
+
name: "Purchase Rate",
|
|
1976
|
+
description: "Purchases divided by sessions (percentage)",
|
|
1977
|
+
category: "calculated",
|
|
1978
|
+
format: "percent",
|
|
1979
|
+
apiField: "",
|
|
1980
|
+
type: "calculated",
|
|
1981
|
+
formula: "(ecommercePurchases / sessions) * 100",
|
|
1982
|
+
dependencies: ["ecommercePurchases", "sessions"]
|
|
1983
|
+
},
|
|
1984
|
+
{
|
|
1985
|
+
key: "cartAbandonmentRate",
|
|
1986
|
+
name: "Cart Abandonment Rate",
|
|
1987
|
+
description: "Percentage of checkouts not completed as purchases",
|
|
1988
|
+
category: "calculated",
|
|
1989
|
+
format: "percent",
|
|
1990
|
+
apiField: "",
|
|
1991
|
+
type: "calculated",
|
|
1992
|
+
formula: "(1 - ecommercePurchases / checkouts) * 100",
|
|
1993
|
+
dependencies: ["ecommercePurchases", "checkouts"]
|
|
1994
|
+
}
|
|
1995
|
+
];
|
|
1996
|
+
function calcRevenuePerSession(row) {
|
|
1997
|
+
const revenue = row["totalRevenue"];
|
|
1998
|
+
const sessions = row["sessions"];
|
|
1999
|
+
if (typeof revenue !== "number" || typeof sessions !== "number") return null;
|
|
2000
|
+
if (sessions <= 0) return null;
|
|
2001
|
+
return revenue / sessions;
|
|
2002
|
+
}
|
|
2003
|
+
function calcConversionRate(row) {
|
|
2004
|
+
const conversions = row["conversions"] ?? row["keyEvents"];
|
|
2005
|
+
const sessions = row["sessions"];
|
|
2006
|
+
if (typeof conversions !== "number" || typeof sessions !== "number") return null;
|
|
2007
|
+
if (sessions <= 0) return null;
|
|
2008
|
+
return conversions / sessions * 100;
|
|
2009
|
+
}
|
|
2010
|
+
function calcPagesPerSession(row) {
|
|
2011
|
+
const pageViews = row["screenPageViews"];
|
|
2012
|
+
const sessions = row["sessions"];
|
|
2013
|
+
if (typeof pageViews !== "number" || typeof sessions !== "number") return null;
|
|
2014
|
+
if (sessions <= 0) return null;
|
|
2015
|
+
return pageViews / sessions;
|
|
2016
|
+
}
|
|
2017
|
+
function calcAov(row) {
|
|
2018
|
+
const revenue = row["purchaseRevenue"] ?? row["totalRevenue"];
|
|
2019
|
+
const purchases = row["ecommercePurchases"] ?? row["transactions"];
|
|
2020
|
+
if (typeof revenue !== "number" || typeof purchases !== "number") return null;
|
|
2021
|
+
if (purchases <= 0) return null;
|
|
2022
|
+
return revenue / purchases;
|
|
2023
|
+
}
|
|
2024
|
+
function calcRevenuePerUser(row) {
|
|
2025
|
+
const revenue = row["totalRevenue"];
|
|
2026
|
+
const users = row["totalUsers"];
|
|
2027
|
+
if (typeof revenue !== "number" || typeof users !== "number") return null;
|
|
2028
|
+
if (users <= 0) return null;
|
|
2029
|
+
return revenue / users;
|
|
2030
|
+
}
|
|
2031
|
+
function calcAddToCartRate(row) {
|
|
2032
|
+
const addToCarts = row["addToCarts"];
|
|
2033
|
+
const sessions = row["sessions"];
|
|
2034
|
+
if (typeof addToCarts !== "number" || typeof sessions !== "number") return null;
|
|
2035
|
+
if (sessions <= 0) return null;
|
|
2036
|
+
return addToCarts / sessions * 100;
|
|
2037
|
+
}
|
|
2038
|
+
function calcCheckoutRate(row) {
|
|
2039
|
+
const checkouts = row["checkouts"];
|
|
2040
|
+
const sessions = row["sessions"];
|
|
2041
|
+
if (typeof checkouts !== "number" || typeof sessions !== "number") return null;
|
|
2042
|
+
if (sessions <= 0) return null;
|
|
2043
|
+
return checkouts / sessions * 100;
|
|
2044
|
+
}
|
|
2045
|
+
function calcPurchaseRate(row) {
|
|
2046
|
+
const purchases = row["ecommercePurchases"];
|
|
2047
|
+
const sessions = row["sessions"];
|
|
2048
|
+
if (typeof purchases !== "number" || typeof sessions !== "number") return null;
|
|
2049
|
+
if (sessions <= 0) return null;
|
|
2050
|
+
return purchases / sessions * 100;
|
|
2051
|
+
}
|
|
2052
|
+
function calcCartAbandonmentRate(row) {
|
|
2053
|
+
const purchases = row["ecommercePurchases"];
|
|
2054
|
+
const checkouts = row["checkouts"];
|
|
2055
|
+
if (typeof purchases !== "number" || typeof checkouts !== "number") return null;
|
|
2056
|
+
if (checkouts <= 0) return null;
|
|
2057
|
+
return (1 - purchases / checkouts) * 100;
|
|
2058
|
+
}
|
|
2059
|
+
function calculateMetrics(row) {
|
|
2060
|
+
return {
|
|
2061
|
+
revenuePerSession: calcRevenuePerSession(row),
|
|
2062
|
+
conversionRate: calcConversionRate(row),
|
|
2063
|
+
pagesPerSession: calcPagesPerSession(row),
|
|
2064
|
+
aov: calcAov(row),
|
|
2065
|
+
revenuePerUser: calcRevenuePerUser(row),
|
|
2066
|
+
addToCartRate: calcAddToCartRate(row),
|
|
2067
|
+
checkoutRate: calcCheckoutRate(row),
|
|
2068
|
+
purchaseRate: calcPurchaseRate(row),
|
|
2069
|
+
cartAbandonmentRate: calcCartAbandonmentRate(row)
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
function enrichWithCalculatedMetrics(rows, requestedCalculated) {
|
|
2073
|
+
const requested = requestedCalculated ? new Set(requestedCalculated) : null;
|
|
2074
|
+
return rows.map((row) => {
|
|
2075
|
+
const all = calculateMetrics(row);
|
|
2076
|
+
const picked = {};
|
|
2077
|
+
for (const [key, value] of Object.entries(all)) {
|
|
2078
|
+
if (value === null) continue;
|
|
2079
|
+
if (requested && !requested.has(key)) continue;
|
|
2080
|
+
picked[key] = Math.round(value * 100) / 100;
|
|
2081
|
+
}
|
|
2082
|
+
return { ...row, ...picked };
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
function getCalculatedMetricDependencies(metricKeys) {
|
|
2086
|
+
const deps = /* @__PURE__ */ new Set();
|
|
2087
|
+
for (const key of metricKeys) {
|
|
2088
|
+
const def = GA4_CALCULATED_METRIC_DEFINITIONS.find((m) => m.key === key);
|
|
2089
|
+
if (def?.dependencies) {
|
|
2090
|
+
for (const dep of def.dependencies) {
|
|
2091
|
+
deps.add(dep);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
return Array.from(deps);
|
|
2096
|
+
}
|
|
2097
|
+
function isCalculatedMetric(key) {
|
|
2098
|
+
return GA4_CALCULATED_METRIC_DEFINITIONS.some((m) => m.key === key);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// src/platforms/ga4/filter-catalog.ts
|
|
2102
|
+
function buildStringFilter(fieldName, matchType, value, caseSensitive = false) {
|
|
2103
|
+
return {
|
|
2104
|
+
filter: {
|
|
2105
|
+
fieldName,
|
|
2106
|
+
stringFilter: {
|
|
2107
|
+
matchType,
|
|
2108
|
+
value,
|
|
2109
|
+
caseSensitive
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
function buildInListFilter(fieldName, values, caseSensitive = false) {
|
|
2115
|
+
return {
|
|
2116
|
+
filter: {
|
|
2117
|
+
fieldName,
|
|
2118
|
+
inListFilter: {
|
|
2119
|
+
values,
|
|
2120
|
+
caseSensitive
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
function buildNumericFilter(fieldName, operation, value) {
|
|
2126
|
+
return {
|
|
2127
|
+
filter: {
|
|
2128
|
+
fieldName,
|
|
2129
|
+
numericFilter: {
|
|
2130
|
+
operation,
|
|
2131
|
+
value: { doubleValue: value }
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function buildAndFilter(expressions) {
|
|
2137
|
+
if (expressions.length === 1) return expressions[0];
|
|
2138
|
+
return { andGroup: { expressions } };
|
|
2139
|
+
}
|
|
2140
|
+
function buildFilterFromParams(filters) {
|
|
2141
|
+
if (filters.length === 0) return void 0;
|
|
2142
|
+
const expressions = filters.map((f) => {
|
|
2143
|
+
const op = f.operator.toUpperCase();
|
|
2144
|
+
if (op === "IN" && Array.isArray(f.value)) {
|
|
2145
|
+
return buildInListFilter(f.field, f.value.map(String));
|
|
2146
|
+
}
|
|
2147
|
+
if (["GREATER_THAN", "LESS_THAN", "EQUAL", "GREATER_THAN_OR_EQUAL", "LESS_THAN_OR_EQUAL"].includes(op)) {
|
|
2148
|
+
return buildNumericFilter(f.field, op, Number(f.value));
|
|
2149
|
+
}
|
|
2150
|
+
const matchType = op === "CONTAINS" ? "CONTAINS" : op === "BEGINS_WITH" ? "BEGINS_WITH" : op === "ENDS_WITH" ? "ENDS_WITH" : op === "REGEXP" || op === "FULL_REGEXP" ? "FULL_REGEXP" : op === "PARTIAL_REGEXP" ? "PARTIAL_REGEXP" : "EXACT";
|
|
2151
|
+
return buildStringFilter(f.field, matchType, String(f.value));
|
|
2152
|
+
});
|
|
2153
|
+
return buildAndFilter(expressions);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/platforms/ga4/query-planner.ts
|
|
2157
|
+
function planGA4Query(params) {
|
|
2158
|
+
const warnings = [];
|
|
2159
|
+
const errors = [];
|
|
2160
|
+
const calculatedMetrics = [];
|
|
2161
|
+
const apiMetricKeys = [];
|
|
2162
|
+
for (const key of params.metrics) {
|
|
2163
|
+
if (isCalculatedMetric(key)) {
|
|
2164
|
+
calculatedMetrics.push(key);
|
|
2165
|
+
} else {
|
|
2166
|
+
apiMetricKeys.push(key);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
if (calculatedMetrics.length > 0) {
|
|
2170
|
+
const deps = getCalculatedMetricDependencies(calculatedMetrics);
|
|
2171
|
+
for (const dep of deps) {
|
|
2172
|
+
if (!apiMetricKeys.includes(dep)) {
|
|
2173
|
+
apiMetricKeys.push(dep);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
const apiMetrics = [];
|
|
2178
|
+
for (const key of apiMetricKeys) {
|
|
2179
|
+
const def = getMetricByKey(key);
|
|
2180
|
+
if (def) {
|
|
2181
|
+
apiMetrics.push({ name: def.apiField });
|
|
2182
|
+
} else {
|
|
2183
|
+
apiMetrics.push({ name: key });
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
const dimensionKeys = params.dimensions || [];
|
|
2187
|
+
const apiDimensions = [];
|
|
2188
|
+
for (const key of dimensionKeys) {
|
|
2189
|
+
const def = getDimensionByKey(key);
|
|
2190
|
+
if (def) {
|
|
2191
|
+
apiDimensions.push({ name: def.apiField });
|
|
2192
|
+
} else {
|
|
2193
|
+
apiDimensions.push({ name: key });
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
const validation = validateGA4QuerySelection(
|
|
2197
|
+
apiMetricKeys,
|
|
2198
|
+
dimensionKeys
|
|
2199
|
+
);
|
|
2200
|
+
errors.push(...validation.errors);
|
|
2201
|
+
warnings.push(...validation.warnings);
|
|
2202
|
+
let dateRanges;
|
|
2203
|
+
if (params.dateRange) {
|
|
2204
|
+
dateRanges = [{
|
|
2205
|
+
startDate: params.dateRange.startDate,
|
|
2206
|
+
endDate: params.dateRange.endDate
|
|
2207
|
+
}];
|
|
2208
|
+
} else if (params.datePreset) {
|
|
2209
|
+
dateRanges = [resolveDatePreset(params.datePreset)];
|
|
2210
|
+
} else {
|
|
2211
|
+
dateRanges = [{ startDate: "28daysAgo", endDate: "today" }];
|
|
2212
|
+
}
|
|
2213
|
+
const orderBys = [];
|
|
2214
|
+
if (params.orderBy) {
|
|
2215
|
+
const metricDef = getMetricByKey(params.orderBy);
|
|
2216
|
+
const dimDef = getDimensionByKey(params.orderBy);
|
|
2217
|
+
if (metricDef) {
|
|
2218
|
+
orderBys.push({
|
|
2219
|
+
metric: { metricName: metricDef.apiField },
|
|
2220
|
+
desc: params.orderDirection !== "ASC"
|
|
2221
|
+
});
|
|
2222
|
+
} else if (dimDef) {
|
|
2223
|
+
orderBys.push({
|
|
2224
|
+
dimension: { dimensionName: dimDef.apiField },
|
|
2225
|
+
desc: params.orderDirection !== "ASC"
|
|
2226
|
+
});
|
|
2227
|
+
} else {
|
|
2228
|
+
orderBys.push({
|
|
2229
|
+
metric: { metricName: params.orderBy },
|
|
2230
|
+
desc: params.orderDirection !== "ASC"
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
let dimensionFilter;
|
|
2235
|
+
if (params.filters && params.filters.length > 0) {
|
|
2236
|
+
dimensionFilter = buildFilterFromParams(params.filters);
|
|
2237
|
+
}
|
|
2238
|
+
const request = {
|
|
2239
|
+
dateRanges,
|
|
2240
|
+
metrics: apiMetrics,
|
|
2241
|
+
...apiDimensions.length > 0 && { dimensions: apiDimensions },
|
|
2242
|
+
...dimensionFilter && { dimensionFilter },
|
|
2243
|
+
...orderBys.length > 0 && { orderBys },
|
|
2244
|
+
...params.limit && { limit: params.limit },
|
|
2245
|
+
keepEmptyRows: false
|
|
2246
|
+
};
|
|
2247
|
+
return {
|
|
2248
|
+
request,
|
|
2249
|
+
propertyId: params.propertyId,
|
|
2250
|
+
warnings,
|
|
2251
|
+
errors,
|
|
2252
|
+
calculatedMetrics,
|
|
2253
|
+
apiMetrics: apiMetricKeys
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// src/platforms/ga4/surface-tools.ts
|
|
2258
|
+
import { z as z2 } from "zod";
|
|
2259
|
+
|
|
2260
|
+
// src/platforms/ga4/privacy.ts
|
|
2261
|
+
var REDACTED = "[REDACTED]";
|
|
2262
|
+
var PERSONAL_IDENTIFIER_KEYS = /* @__PURE__ */ new Set([
|
|
2263
|
+
"user",
|
|
2264
|
+
"userid",
|
|
2265
|
+
"user_id",
|
|
2266
|
+
"email",
|
|
2267
|
+
"emailaddress",
|
|
2268
|
+
"email_address",
|
|
2269
|
+
"username",
|
|
2270
|
+
"deviceid",
|
|
2271
|
+
"device_id",
|
|
2272
|
+
"mobiledeviceid",
|
|
2273
|
+
"mobile_device_id",
|
|
2274
|
+
"googlesignalspseudonymousid",
|
|
2275
|
+
"google_signals_pseudonymous_id"
|
|
2276
|
+
]);
|
|
2277
|
+
function isRecord(value) {
|
|
2278
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2279
|
+
}
|
|
2280
|
+
function normalizedKey(value) {
|
|
2281
|
+
return value.replace(/[-\s]/g, "").toLowerCase();
|
|
2282
|
+
}
|
|
2283
|
+
function isSensitiveAudienceDimension(name) {
|
|
2284
|
+
const normalized = normalizedKey(name);
|
|
2285
|
+
return PERSONAL_IDENTIFIER_KEYS.has(normalized) || /(?:user|device|pseudonymous|email|username).*id$/.test(normalized) || /^(?:user|device).*identifier$/.test(normalized);
|
|
2286
|
+
}
|
|
2287
|
+
function redactGA4PersonalIdentifiers(value) {
|
|
2288
|
+
if (Array.isArray(value)) return value.map(redactGA4PersonalIdentifiers);
|
|
2289
|
+
if (!isRecord(value)) return value;
|
|
2290
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
|
|
2291
|
+
key,
|
|
2292
|
+
PERSONAL_IDENTIFIER_KEYS.has(normalizedKey(key)) ? REDACTED : redactGA4PersonalIdentifiers(nested)
|
|
2293
|
+
]));
|
|
2294
|
+
}
|
|
2295
|
+
function redactGA4AudienceExportResponse(response) {
|
|
2296
|
+
const redacted = redactGA4PersonalIdentifiers(response);
|
|
2297
|
+
const audienceExport = isRecord(response.audienceExport) ? response.audienceExport : void 0;
|
|
2298
|
+
const dimensions = Array.isArray(audienceExport?.dimensions) ? audienceExport.dimensions : [];
|
|
2299
|
+
const names = dimensions.map(
|
|
2300
|
+
(dimension) => isRecord(dimension) && typeof dimension.dimensionName === "string" ? dimension.dimensionName : ""
|
|
2301
|
+
);
|
|
2302
|
+
const sensitiveIndexes = /* @__PURE__ */ new Set();
|
|
2303
|
+
if (names.length === 0) {
|
|
2304
|
+
sensitiveIndexes.add(-1);
|
|
2305
|
+
} else {
|
|
2306
|
+
names.forEach((name, index) => {
|
|
2307
|
+
if (!name || isSensitiveAudienceDimension(name)) sensitiveIndexes.add(index);
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
if (!Array.isArray(redacted.audienceRows)) return redacted;
|
|
2311
|
+
return {
|
|
2312
|
+
...redacted,
|
|
2313
|
+
audienceRows: redacted.audienceRows.map((row) => {
|
|
2314
|
+
if (!isRecord(row) || !Array.isArray(row.dimensionValues)) return row;
|
|
2315
|
+
return {
|
|
2316
|
+
...row,
|
|
2317
|
+
dimensionValues: row.dimensionValues.map((dimensionValue, index) => {
|
|
2318
|
+
if (!sensitiveIndexes.has(-1) && !sensitiveIndexes.has(index)) return dimensionValue;
|
|
2319
|
+
return isRecord(dimensionValue) ? { ...dimensionValue, value: REDACTED } : REDACTED;
|
|
2320
|
+
})
|
|
2321
|
+
};
|
|
2322
|
+
})
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// src/platforms/ga4/surface-tools.ts
|
|
2327
|
+
var propertyIdSchema = z2.string().min(1).describe("Numeric GA4 property ID or properties/{id}.");
|
|
2328
|
+
var dateRangeSchema = z2.object({
|
|
2329
|
+
startDate: z2.string().min(1).describe("YYYY-MM-DD or a GA4 relative date such as 28daysAgo."),
|
|
2330
|
+
endDate: z2.string().min(1).describe("YYYY-MM-DD or a GA4 relative date such as today.")
|
|
2331
|
+
});
|
|
2332
|
+
var rawFilterSchema = z2.record(z2.unknown()).describe("Native GA4 FilterExpression JSON.");
|
|
2333
|
+
var rawOrderBySchema = z2.record(z2.unknown()).describe("Native GA4 OrderBy JSON.");
|
|
2334
|
+
var aggregationSchema = z2.enum(["TOTAL", "MINIMUM", "MAXIMUM", "COUNT"]);
|
|
2335
|
+
var reportSchema = z2.object({
|
|
2336
|
+
dateRanges: z2.array(dateRangeSchema).min(1).max(4).optional(),
|
|
2337
|
+
dimensions: z2.array(z2.string().min(1)).max(9).optional().default([]),
|
|
2338
|
+
metrics: z2.array(z2.string().min(1)).min(1).max(10),
|
|
2339
|
+
dimensionFilter: rawFilterSchema.optional(),
|
|
2340
|
+
metricFilter: rawFilterSchema.optional(),
|
|
2341
|
+
orderBys: z2.array(rawOrderBySchema).max(10).optional(),
|
|
2342
|
+
limit: z2.number().int().min(1).max(25e4).optional(),
|
|
2343
|
+
offset: z2.number().int().min(0).optional(),
|
|
2344
|
+
keepEmptyRows: z2.boolean().optional(),
|
|
2345
|
+
metricAggregations: z2.array(aggregationSchema).max(4).optional(),
|
|
2346
|
+
currencyCode: z2.string().length(3).optional(),
|
|
2347
|
+
returnPropertyQuota: z2.boolean().optional().default(true),
|
|
2348
|
+
cohortSpec: z2.record(z2.unknown()).optional().describe("Native GA4 CohortSpec JSON. Omit dateRanges for cohort requests."),
|
|
2349
|
+
comparisons: z2.array(z2.record(z2.unknown())).max(4).optional().describe("Native GA4 Comparison objects.")
|
|
2350
|
+
}).refine((report) => Boolean(report.dateRanges?.length || report.cohortSpec), {
|
|
2351
|
+
message: "Provide dateRanges or cohortSpec."
|
|
2352
|
+
});
|
|
2353
|
+
var pivotSchema = z2.object({
|
|
2354
|
+
fieldNames: z2.array(z2.string().min(1)).min(1).max(9),
|
|
2355
|
+
orderBys: z2.array(rawOrderBySchema).max(10).optional(),
|
|
2356
|
+
offset: z2.number().int().min(0).optional(),
|
|
2357
|
+
limit: z2.number().int().min(1).max(25e4),
|
|
2358
|
+
metricAggregations: z2.array(aggregationSchema).max(4).optional()
|
|
2359
|
+
});
|
|
2360
|
+
var pivotReportSchema = z2.object({
|
|
2361
|
+
dateRanges: z2.array(dateRangeSchema).min(1).max(4).optional(),
|
|
2362
|
+
dimensions: z2.array(z2.string().min(1)).min(1).max(9),
|
|
2363
|
+
metrics: z2.array(z2.string().min(1)).min(1).max(10),
|
|
2364
|
+
pivots: z2.array(pivotSchema).min(1).max(9),
|
|
2365
|
+
dimensionFilter: rawFilterSchema.optional(),
|
|
2366
|
+
metricFilter: rawFilterSchema.optional(),
|
|
2367
|
+
currencyCode: z2.string().length(3).optional(),
|
|
2368
|
+
keepEmptyRows: z2.boolean().optional(),
|
|
2369
|
+
returnPropertyQuota: z2.boolean().optional().default(true),
|
|
2370
|
+
cohortSpec: z2.record(z2.unknown()).optional().describe("Native GA4 CohortSpec JSON. Omit dateRanges for cohort requests."),
|
|
2371
|
+
comparisons: z2.array(z2.record(z2.unknown())).max(4).optional().describe("Native GA4 Comparison objects.")
|
|
2372
|
+
}).refine((report) => Boolean(report.dateRanges?.length || report.cohortSpec), {
|
|
2373
|
+
message: "Provide dateRanges or cohortSpec."
|
|
2374
|
+
});
|
|
2375
|
+
function ok(data) {
|
|
2376
|
+
const body = typeof data === "object" && data !== null && !Array.isArray(data) ? {
|
|
2377
|
+
...data,
|
|
2378
|
+
warnings: "warnings" in data ? data.warnings : [],
|
|
2379
|
+
limitations: "limitations" in data ? data.limitations : [],
|
|
2380
|
+
nextActions: "nextActions" in data ? data.nextActions : [],
|
|
2381
|
+
debug: {
|
|
2382
|
+
source: "ga4",
|
|
2383
|
+
apiVersion: "data_api_v1beta/admin_api_v1beta_v1alpha",
|
|
2384
|
+
requestCount: 1,
|
|
2385
|
+
...data.debug
|
|
2386
|
+
}
|
|
2387
|
+
} : data;
|
|
2388
|
+
return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
|
|
2389
|
+
}
|
|
2390
|
+
function reportRequest(input) {
|
|
2391
|
+
return {
|
|
2392
|
+
dateRanges: input.dateRanges,
|
|
2393
|
+
dimensions: input.dimensions.map((name) => ({ name })),
|
|
2394
|
+
metrics: input.metrics.map((name) => ({ name })),
|
|
2395
|
+
dimensionFilter: input.dimensionFilter,
|
|
2396
|
+
metricFilter: input.metricFilter,
|
|
2397
|
+
orderBys: input.orderBys,
|
|
2398
|
+
limit: input.limit,
|
|
2399
|
+
offset: input.offset,
|
|
2400
|
+
keepEmptyRows: input.keepEmptyRows,
|
|
2401
|
+
metricAggregations: input.metricAggregations,
|
|
2402
|
+
currencyCode: input.currencyCode,
|
|
2403
|
+
returnPropertyQuota: input.returnPropertyQuota,
|
|
2404
|
+
cohortSpec: input.cohortSpec,
|
|
2405
|
+
comparisons: input.comparisons
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
function pivotReportRequest(input) {
|
|
2409
|
+
return {
|
|
2410
|
+
dateRanges: input.dateRanges,
|
|
2411
|
+
dimensions: input.dimensions.map((name) => ({ name })),
|
|
2412
|
+
metrics: input.metrics.map((name) => ({ name })),
|
|
2413
|
+
pivots: input.pivots.map((pivot) => ({
|
|
2414
|
+
...pivot,
|
|
2415
|
+
orderBys: pivot.orderBys
|
|
2416
|
+
})),
|
|
2417
|
+
dimensionFilter: input.dimensionFilter,
|
|
2418
|
+
metricFilter: input.metricFilter,
|
|
2419
|
+
currencyCode: input.currencyCode,
|
|
2420
|
+
keepEmptyRows: input.keepEmptyRows,
|
|
2421
|
+
returnPropertyQuota: input.returnPropertyQuota,
|
|
2422
|
+
cohortSpec: input.cohortSpec,
|
|
2423
|
+
comparisons: input.comparisons
|
|
2424
|
+
};
|
|
2425
|
+
}
|
|
2426
|
+
function validatePivotRequest(request) {
|
|
2427
|
+
const errors = [];
|
|
2428
|
+
const declared = new Set(request.dimensions.map(({ name }) => name));
|
|
2429
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2430
|
+
let cellBudget = 1;
|
|
2431
|
+
for (const pivot of request.pivots) {
|
|
2432
|
+
cellBudget *= pivot.limit;
|
|
2433
|
+
for (const field of pivot.fieldNames) {
|
|
2434
|
+
if (!declared.has(field) && field !== "dateRange" && field !== "comparisons") {
|
|
2435
|
+
errors.push(`Pivot field '${field}' is not declared in dimensions.`);
|
|
2436
|
+
}
|
|
2437
|
+
if (seen.has(field)) errors.push(`Pivot field '${field}' appears in more than one pivot.`);
|
|
2438
|
+
seen.add(field);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (cellBudget > 25e4) {
|
|
2442
|
+
errors.push(`The product of pivot limits is ${cellBudget}; GA4 allows at most 250,000.`);
|
|
2443
|
+
}
|
|
2444
|
+
return errors;
|
|
2445
|
+
}
|
|
2446
|
+
function stripProperty(request) {
|
|
2447
|
+
const { property: _property, ...body } = request;
|
|
2448
|
+
return body;
|
|
2449
|
+
}
|
|
2450
|
+
function registerGA4SurfaceTools(server, client) {
|
|
2451
|
+
server.tool(
|
|
2452
|
+
"ga4_run_pivot_report",
|
|
2453
|
+
"Run a read-only GA4 Data API pivot report with native filters, ordering, quota state, multiple date ranges, and up to 250,000 pivot cells. Every pivot requires an explicit limit.",
|
|
2454
|
+
{ propertyId: propertyIdSchema, report: pivotReportSchema },
|
|
2455
|
+
async ({ propertyId, report }) => {
|
|
2456
|
+
try {
|
|
2457
|
+
const request = pivotReportRequest(report);
|
|
2458
|
+
const errors = validatePivotRequest(request);
|
|
2459
|
+
if (errors.length) return ok({ error: "Pivot validation failed", errors });
|
|
2460
|
+
const response = await client.runPivotReport(propertyId, stripProperty(request));
|
|
2461
|
+
return ok({
|
|
2462
|
+
report: response,
|
|
2463
|
+
data: client.flattenResponse(response),
|
|
2464
|
+
rowCount: response.rows?.length ?? 0,
|
|
2465
|
+
propertyQuota: response.propertyQuota
|
|
2466
|
+
});
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
return formatMcpToolError(error);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
);
|
|
2472
|
+
server.tool(
|
|
2473
|
+
"ga4_batch_run_reports",
|
|
2474
|
+
"Run 1-5 independent read-only GA4 Core reports for the same property in one official Data API batch request.",
|
|
2475
|
+
{ propertyId: propertyIdSchema, reports: z2.array(reportSchema).min(1).max(5) },
|
|
2476
|
+
async ({ propertyId, reports }) => {
|
|
2477
|
+
try {
|
|
2478
|
+
const requests = reports.map((report) => stripProperty(reportRequest(report)));
|
|
2479
|
+
const response = await client.batchRunReports(propertyId, requests);
|
|
2480
|
+
const entries = (response.reports ?? []).map((report, index) => ({
|
|
2481
|
+
index,
|
|
2482
|
+
rowCount: report.rowCount ?? report.rows?.length ?? 0,
|
|
2483
|
+
data: client.flattenResponse(report),
|
|
2484
|
+
metadata: report.metadata,
|
|
2485
|
+
totals: report.totals,
|
|
2486
|
+
minimums: report.minimums,
|
|
2487
|
+
maximums: report.maximums,
|
|
2488
|
+
propertyQuota: report.propertyQuota
|
|
2489
|
+
}));
|
|
2490
|
+
return ok({ reports: entries, reportCount: entries.length, kind: response.kind });
|
|
2491
|
+
} catch (error) {
|
|
2492
|
+
return formatMcpToolError(error);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
);
|
|
2496
|
+
server.tool(
|
|
2497
|
+
"ga4_batch_run_pivot_reports",
|
|
2498
|
+
"Run 1-5 independent read-only GA4 pivot reports for the same property in one official Data API batch request.",
|
|
2499
|
+
{ propertyId: propertyIdSchema, reports: z2.array(pivotReportSchema).min(1).max(5) },
|
|
2500
|
+
async ({ propertyId, reports }) => {
|
|
2501
|
+
try {
|
|
2502
|
+
const requests = reports.map(pivotReportRequest);
|
|
2503
|
+
const errors = requests.flatMap((request, index) => validatePivotRequest(request).map((error) => `Report ${index}: ${error}`));
|
|
2504
|
+
if (errors.length) return ok({ error: "Pivot validation failed", errors });
|
|
2505
|
+
const response = await client.batchRunPivotReports(
|
|
2506
|
+
propertyId,
|
|
2507
|
+
requests.map((request) => stripProperty(request))
|
|
2508
|
+
);
|
|
2509
|
+
const entries = (response.pivotReports ?? []).map((report, index) => ({
|
|
2510
|
+
index,
|
|
2511
|
+
rowCount: report.rows?.length ?? 0,
|
|
2512
|
+
data: client.flattenResponse(report),
|
|
2513
|
+
report
|
|
2514
|
+
}));
|
|
2515
|
+
return ok({ reports: entries, reportCount: entries.length, kind: response.kind });
|
|
2516
|
+
} catch (error) {
|
|
2517
|
+
return formatMcpToolError(error);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
);
|
|
2521
|
+
server.tool(
|
|
2522
|
+
"ga4_check_compatibility",
|
|
2523
|
+
"Ask the official GA4 Data API which dimensions and metrics are compatible with a proposed Core report selection.",
|
|
2524
|
+
{
|
|
2525
|
+
propertyId: propertyIdSchema,
|
|
2526
|
+
dimensions: z2.array(z2.string().min(1)).max(9).optional().default([]),
|
|
2527
|
+
metrics: z2.array(z2.string().min(1)).max(10).optional().default([]),
|
|
2528
|
+
dimensionFilter: rawFilterSchema.optional(),
|
|
2529
|
+
metricFilter: rawFilterSchema.optional(),
|
|
2530
|
+
compatibilityFilter: z2.enum(["COMPATIBILITY_UNSPECIFIED", "COMPATIBLE", "INCOMPATIBLE"]).optional()
|
|
2531
|
+
},
|
|
2532
|
+
async ({ propertyId, dimensions, metrics, dimensionFilter, metricFilter, compatibilityFilter }) => {
|
|
2533
|
+
try {
|
|
2534
|
+
const response = await client.checkCompatibility(propertyId, {
|
|
2535
|
+
dimensions: dimensions.map((name) => ({ name })),
|
|
2536
|
+
metrics: metrics.map((name) => ({ name })),
|
|
2537
|
+
...dimensionFilter && { dimensionFilter },
|
|
2538
|
+
...metricFilter && { metricFilter },
|
|
2539
|
+
...compatibilityFilter && { compatibilityFilter }
|
|
2540
|
+
});
|
|
2541
|
+
return ok(response);
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
return formatMcpToolError(error);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
);
|
|
2547
|
+
server.tool(
|
|
2548
|
+
"ga4_get_property_quotas_snapshot",
|
|
2549
|
+
"Read the current GA4 Data API property quota snapshot from the official v1alpha endpoint. This does not run a report, but Google still charges one property-quota token to the category with the most remaining quota.",
|
|
2550
|
+
{ propertyId: propertyIdSchema },
|
|
2551
|
+
async ({ propertyId }) => {
|
|
2552
|
+
try {
|
|
2553
|
+
const snapshot = await client.getPropertyQuotasSnapshot(propertyId);
|
|
2554
|
+
return ok({ propertyId, snapshot });
|
|
2555
|
+
} catch (error) {
|
|
2556
|
+
return formatMcpToolError(error);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
);
|
|
2560
|
+
server.tool(
|
|
2561
|
+
"ga4_list_accounts",
|
|
2562
|
+
"List raw GA4 Analytics Admin accounts accessible to the authenticated user (read-only, auto-paginated).",
|
|
2563
|
+
{ pageSize: z2.number().int().min(1).max(200).optional().default(200) },
|
|
2564
|
+
async ({ pageSize }) => {
|
|
2565
|
+
try {
|
|
2566
|
+
const accounts = await client.listAccounts(pageSize);
|
|
2567
|
+
return ok({ accounts, count: accounts.length });
|
|
2568
|
+
} catch (error) {
|
|
2569
|
+
return formatMcpToolError(error);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
);
|
|
2573
|
+
const adminCollectionNames = Object.keys(GA4_ADMIN_COLLECTIONS);
|
|
2574
|
+
server.tool(
|
|
2575
|
+
"ga4_list_admin_resources",
|
|
2576
|
+
"List an allowlisted GA4 Admin collection in read-only mode: streams, custom definitions, key events, audiences, product links, annotations, channel groups, expanded datasets, subproperty/rollup configuration, and access bindings.",
|
|
2577
|
+
{
|
|
2578
|
+
propertyId: propertyIdSchema,
|
|
2579
|
+
collection: z2.enum(adminCollectionNames),
|
|
2580
|
+
pageSize: z2.number().int().min(1).max(200).optional().default(200),
|
|
2581
|
+
includePersonalIdentifiers: z2.boolean().optional().default(false).describe("Access bindings only. Explicitly include user/email identifiers; false redacts them by default.")
|
|
2582
|
+
},
|
|
2583
|
+
async ({ propertyId, collection, pageSize, includePersonalIdentifiers }) => {
|
|
2584
|
+
try {
|
|
2585
|
+
const resources = await client.listAdminPropertyResources(propertyId, collection, pageSize);
|
|
2586
|
+
const exposesPersonalIdentifiers = collection === "accessBindings";
|
|
2587
|
+
const safeResources = exposesPersonalIdentifiers && !includePersonalIdentifiers ? redactGA4PersonalIdentifiers(resources) : resources;
|
|
2588
|
+
return ok({
|
|
2589
|
+
propertyId,
|
|
2590
|
+
collection,
|
|
2591
|
+
resources: safeResources,
|
|
2592
|
+
count: resources.length,
|
|
2593
|
+
warnings: exposesPersonalIdentifiers && includePersonalIdentifiers ? ["SENSITIVE PERSONAL IDENTIFIERS INCLUDED BY EXPLICIT OPT-IN: access-binding user/email values are present. Restrict storage, sharing, and model output."] : exposesPersonalIdentifiers ? ["Access-binding user/email identifiers were redacted by default. Set includePersonalIdentifiers=true only with explicit authorization."] : []
|
|
2594
|
+
});
|
|
2595
|
+
} catch (error) {
|
|
2596
|
+
return formatMcpToolError(error);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
);
|
|
2600
|
+
const settingNames = [
|
|
2601
|
+
"attributionSettings",
|
|
2602
|
+
"dataRetentionSettings",
|
|
2603
|
+
"googleSignalsSettings",
|
|
2604
|
+
"reportingIdentitySettings",
|
|
2605
|
+
"userProvidedDataSettings"
|
|
2606
|
+
];
|
|
2607
|
+
server.tool(
|
|
2608
|
+
"ga4_get_property_configuration",
|
|
2609
|
+
"Read GA4 property details plus selected singleton Admin settings (attribution, retention, Google Signals, reporting identity, or user-provided-data settings).",
|
|
2610
|
+
{
|
|
2611
|
+
propertyId: propertyIdSchema,
|
|
2612
|
+
settings: z2.array(z2.enum(settingNames)).max(settingNames.length).optional().default([])
|
|
2613
|
+
},
|
|
2614
|
+
async ({ propertyId, settings }) => {
|
|
2615
|
+
try {
|
|
2616
|
+
const property = await client.getProperty(propertyId);
|
|
2617
|
+
const configuration = {};
|
|
2618
|
+
const warnings = [];
|
|
2619
|
+
for (const setting of [...new Set(settings)]) {
|
|
2620
|
+
try {
|
|
2621
|
+
configuration[setting] = await client.getPropertySetting(propertyId, setting);
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
warnings.push(`${setting}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return ok({ property, configuration, warnings, debug: { requestCount: 1 + new Set(settings).size } });
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
return formatMcpToolError(error);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
);
|
|
2632
|
+
server.tool(
|
|
2633
|
+
"ga4_list_audience_exports",
|
|
2634
|
+
"List existing GA4 Audience Export snapshots and Recurring Audience Lists without creating new exports.",
|
|
2635
|
+
{
|
|
2636
|
+
propertyId: propertyIdSchema,
|
|
2637
|
+
includeRecurring: z2.boolean().optional().default(true),
|
|
2638
|
+
pageSize: z2.number().int().min(1).max(200).optional().default(100)
|
|
2639
|
+
},
|
|
2640
|
+
async ({ propertyId, includeRecurring, pageSize }) => {
|
|
2641
|
+
try {
|
|
2642
|
+
const audienceExports = await client.listAudienceExports(propertyId, pageSize);
|
|
2643
|
+
const recurringAudienceLists = includeRecurring ? await client.listRecurringAudienceLists(propertyId, Math.min(pageSize, 100)) : [];
|
|
2644
|
+
return ok({
|
|
2645
|
+
audienceExports,
|
|
2646
|
+
recurringAudienceLists,
|
|
2647
|
+
counts: { audienceExports: audienceExports.length, recurringAudienceLists: recurringAudienceLists.length },
|
|
2648
|
+
debug: { requestCount: includeRecurring ? 2 : 1 }
|
|
2649
|
+
});
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
return formatMcpToolError(error);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
);
|
|
2655
|
+
server.tool(
|
|
2656
|
+
"ga4_query_audience_export",
|
|
2657
|
+
"Query rows from an existing GA4 Audience Export. User/device identifiers are redacted by default; include them only through explicit opt-in.",
|
|
2658
|
+
{
|
|
2659
|
+
audienceExportName: z2.string().regex(/^properties\/[^/]+\/audienceExports\/[^/]+$/),
|
|
2660
|
+
offset: z2.number().int().min(0).optional().default(0),
|
|
2661
|
+
limit: z2.number().int().min(1).max(1e4).optional().default(100),
|
|
2662
|
+
includePersonalIdentifiers: z2.boolean().optional().default(false).describe("Explicitly return user/device identifiers. False redacts identifier columns by default.")
|
|
2663
|
+
},
|
|
2664
|
+
async ({ audienceExportName, offset, limit, includePersonalIdentifiers }) => {
|
|
2665
|
+
try {
|
|
2666
|
+
const response = await client.queryAudienceExport(audienceExportName, {
|
|
2667
|
+
offset: String(offset),
|
|
2668
|
+
limit: String(limit)
|
|
2669
|
+
});
|
|
2670
|
+
return ok({
|
|
2671
|
+
audienceExportName,
|
|
2672
|
+
response: includePersonalIdentifiers ? response : redactGA4AudienceExportResponse(response),
|
|
2673
|
+
warnings: includePersonalIdentifiers ? ["SENSITIVE PERSONAL IDENTIFIERS INCLUDED BY EXPLICIT OPT-IN: Audience Export user/device identifiers are present. Restrict storage, sharing, and model output."] : ["Audience Export user/device identifiers were redacted by default. Set includePersonalIdentifiers=true only with explicit authorization."]
|
|
2674
|
+
});
|
|
2675
|
+
} catch (error) {
|
|
2676
|
+
return formatMcpToolError(error);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
// src/platforms/ga4/tools.ts
|
|
2683
|
+
var propertyIdSchema2 = z3.string().describe("GA4 property ID (numeric, e.g., 123456789)");
|
|
2684
|
+
var datePresetValues = ["today", "yesterday", "last7days", "last28days", "last30days", "last90days", "last12months", "thisMonth", "lastMonth", "thisYear"];
|
|
2685
|
+
var datePresetSchema = z3.enum(datePresetValues);
|
|
2686
|
+
var matchTypeSchema = z3.enum(["EXACT", "BEGINS_WITH", "ENDS_WITH", "CONTAINS", "FULL_REGEXP", "PARTIAL_REGEXP"]);
|
|
2687
|
+
var funnelStepSchema = z3.object({
|
|
2688
|
+
name: z3.string().optional().describe("Optional display name for this funnel step"),
|
|
2689
|
+
eventName: z3.string().optional().describe("GA4 eventName to count for this step"),
|
|
2690
|
+
pagePath: z3.string().optional().describe("GA4 pagePath to count for this step"),
|
|
2691
|
+
matchType: matchTypeSchema.optional().default("EXACT").describe("String match type for pagePath. eventName always uses EXACT.")
|
|
2692
|
+
}).refine((step) => Boolean(step.eventName || step.pagePath), {
|
|
2693
|
+
message: "Each funnel step must include eventName or pagePath"
|
|
2694
|
+
});
|
|
2695
|
+
var advancedFunnelStepSchema = z3.object({
|
|
2696
|
+
name: z3.string().optional().describe("Optional display name for this funnel step"),
|
|
2697
|
+
eventName: z3.string().optional().describe("GA4 event name matched by a FunnelEventFilter"),
|
|
2698
|
+
pagePath: z3.string().optional().describe("Shortcut for fieldName=pagePath string filter"),
|
|
2699
|
+
fieldName: z3.string().optional().describe("Optional GA4 field name for a FunnelFieldFilter, e.g. pagePath, pageTitle, deviceCategory"),
|
|
2700
|
+
fieldValue: z3.string().optional().describe("String value to match for fieldName"),
|
|
2701
|
+
matchType: matchTypeSchema.optional().default("EXACT").describe("String match type for pagePath or fieldName"),
|
|
2702
|
+
isDirectlyFollowedBy: z3.boolean().optional().describe("Require this step to directly follow the previous step"),
|
|
2703
|
+
withinSecondsFromPriorStep: z3.number().int().min(1).max(60 * 60 * 24 * 30).optional().describe("Maximum seconds from the prior step")
|
|
2704
|
+
}).refine((step) => Boolean(step.eventName || step.pagePath || step.fieldName && step.fieldValue), {
|
|
2705
|
+
message: "Each advanced funnel step must include eventName, pagePath, or fieldName plus fieldValue"
|
|
2706
|
+
});
|
|
2707
|
+
var funnelVisualizationTypeSchema = z3.enum(["STANDARD_FUNNEL", "TRENDED_FUNNEL"]);
|
|
2708
|
+
var GA4_RESPONSE_API_VERSION = "data_api_v1beta/admin_api_v1beta_v1alpha";
|
|
2709
|
+
function isRecord2(value) {
|
|
2710
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2711
|
+
}
|
|
2712
|
+
function hasOwnField(value, field) {
|
|
2713
|
+
return Object.prototype.hasOwnProperty.call(value, field);
|
|
2714
|
+
}
|
|
2715
|
+
function getDebugRecord(payload) {
|
|
2716
|
+
return isRecord2(payload["debug"]) ? payload["debug"] : {};
|
|
2717
|
+
}
|
|
2718
|
+
function getRequestCount(payload) {
|
|
2719
|
+
const debug = getDebugRecord(payload);
|
|
2720
|
+
const requestCount = debug["requestCount"];
|
|
2721
|
+
return typeof requestCount === "number" ? requestCount : 1;
|
|
2722
|
+
}
|
|
2723
|
+
function getWarnings(payload) {
|
|
2724
|
+
if (Array.isArray(payload["warnings"])) return payload["warnings"];
|
|
2725
|
+
const debug = getDebugRecord(payload);
|
|
2726
|
+
return Array.isArray(debug["warnings"]) ? debug["warnings"] : [];
|
|
2727
|
+
}
|
|
2728
|
+
function withAgentResponseContract(data) {
|
|
2729
|
+
if (!isRecord2(data)) return data;
|
|
2730
|
+
return {
|
|
2731
|
+
...data,
|
|
2732
|
+
warnings: hasOwnField(data, "warnings") ? data["warnings"] : getWarnings(data),
|
|
2733
|
+
limitations: hasOwnField(data, "limitations") ? data["limitations"] : [],
|
|
2734
|
+
nextActions: hasOwnField(data, "nextActions") ? data["nextActions"] : [],
|
|
2735
|
+
debug: {
|
|
2736
|
+
...getDebugRecord(data),
|
|
2737
|
+
source: "ga4",
|
|
2738
|
+
apiVersion: GA4_RESPONSE_API_VERSION,
|
|
2739
|
+
requestCount: getRequestCount(data)
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
}
|
|
2743
|
+
function ok2(data) {
|
|
2744
|
+
return { content: [{ type: "text", text: JSON.stringify(withAgentResponseContract(data), null, 2) }] };
|
|
2745
|
+
}
|
|
2746
|
+
function stringOrUndefined(value) {
|
|
2747
|
+
return typeof value === "string" ? value : void 0;
|
|
2748
|
+
}
|
|
2749
|
+
function stringArrayOrUndefined(value) {
|
|
2750
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : void 0;
|
|
2751
|
+
}
|
|
2752
|
+
function normalizeMetadataField(value) {
|
|
2753
|
+
if (!isRecord2(value) || typeof value["apiName"] !== "string") {
|
|
2754
|
+
return null;
|
|
2755
|
+
}
|
|
2756
|
+
return {
|
|
2757
|
+
apiName: value["apiName"],
|
|
2758
|
+
uiName: stringOrUndefined(value["uiName"]),
|
|
2759
|
+
description: stringOrUndefined(value["description"]),
|
|
2760
|
+
category: stringOrUndefined(value["category"]),
|
|
2761
|
+
customDefinition: value["customDefinition"] === true,
|
|
2762
|
+
type: stringOrUndefined(value["type"]),
|
|
2763
|
+
deprecatedApiNames: stringArrayOrUndefined(value["deprecatedApiNames"])
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
function extractMetadataFields(metadata, kind) {
|
|
2767
|
+
const raw = Array.isArray(metadata[kind]) ? metadata[kind] : [];
|
|
2768
|
+
return raw.map(normalizeMetadataField).filter((field) => field !== null);
|
|
2769
|
+
}
|
|
2770
|
+
function summarizeMetadata(metadata) {
|
|
2771
|
+
return {
|
|
2772
|
+
dimensions: extractMetadataFields(metadata, "dimensions"),
|
|
2773
|
+
metrics: extractMetadataFields(metadata, "metrics")
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
function inferCustomScope(apiName) {
|
|
2777
|
+
if (apiName.startsWith("customEvent:")) return "event";
|
|
2778
|
+
if (apiName.startsWith("customUser:")) return "user";
|
|
2779
|
+
if (apiName.startsWith("customItem:")) return "item";
|
|
2780
|
+
return "custom";
|
|
2781
|
+
}
|
|
2782
|
+
function serializeMetadataField(field) {
|
|
2783
|
+
return {
|
|
2784
|
+
apiName: field.apiName,
|
|
2785
|
+
uiName: field.uiName,
|
|
2786
|
+
description: field.description,
|
|
2787
|
+
category: field.category,
|
|
2788
|
+
type: field.type,
|
|
2789
|
+
customDefinition: field.customDefinition === true,
|
|
2790
|
+
scope: inferCustomScope(field.apiName),
|
|
2791
|
+
...field.deprecatedApiNames && field.deprecatedApiNames.length > 0 && { deprecatedApiNames: field.deprecatedApiNames }
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
function getCustomDefinitions(summary) {
|
|
2795
|
+
const isCustom = (field) => field.customDefinition === true || field.apiName.startsWith("customEvent:") || field.apiName.startsWith("customUser:") || field.apiName.startsWith("customItem:");
|
|
2796
|
+
return {
|
|
2797
|
+
customDimensions: summary.dimensions.filter(isCustom).map(serializeMetadataField),
|
|
2798
|
+
customMetrics: summary.metrics.filter(isCustom).map(serializeMetadataField)
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
function fieldMatchesCandidate(field, candidate) {
|
|
2802
|
+
return field.apiName === candidate || Boolean(field.deprecatedApiNames?.includes(candidate));
|
|
2803
|
+
}
|
|
2804
|
+
function pickAvailableField(summary, kind, candidates) {
|
|
2805
|
+
if (!summary || summary[kind].length === 0) {
|
|
2806
|
+
return candidates[0];
|
|
2807
|
+
}
|
|
2808
|
+
for (const candidate of candidates) {
|
|
2809
|
+
const match = summary[kind].find((field) => fieldMatchesCandidate(field, candidate));
|
|
2810
|
+
if (match) return match.apiName;
|
|
2811
|
+
}
|
|
2812
|
+
return void 0;
|
|
2813
|
+
}
|
|
2814
|
+
function filterAvailableFields(summary, kind, candidates) {
|
|
2815
|
+
if (!summary || summary[kind].length === 0) {
|
|
2816
|
+
return candidates;
|
|
2817
|
+
}
|
|
2818
|
+
return candidates.flatMap((candidate) => {
|
|
2819
|
+
const match = summary[kind].find((field) => fieldMatchesCandidate(field, candidate));
|
|
2820
|
+
return match ? [match.apiName] : [];
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
function toMetrics(metrics) {
|
|
2824
|
+
return metrics.map((metric) => ({ name: getMetricByKey(metric)?.apiField ?? metric }));
|
|
2825
|
+
}
|
|
2826
|
+
function toDimensions(dimensions) {
|
|
2827
|
+
return dimensions.map((dimension) => ({ name: getDimensionByKey(dimension)?.apiField ?? dimension }));
|
|
2828
|
+
}
|
|
2829
|
+
function resolveInputDateRange(startDate, endDate, datePreset) {
|
|
2830
|
+
if (startDate || endDate) {
|
|
2831
|
+
return {
|
|
2832
|
+
startDate: startDate ?? "28daysAgo",
|
|
2833
|
+
endDate: endDate ?? "today"
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2836
|
+
return resolveDatePreset(datePreset ?? "last28days");
|
|
2837
|
+
}
|
|
2838
|
+
function buildStringFilter2(fieldName, value, matchType = "EXACT") {
|
|
2839
|
+
return {
|
|
2840
|
+
filter: {
|
|
2841
|
+
fieldName,
|
|
2842
|
+
stringFilter: {
|
|
2843
|
+
matchType,
|
|
2844
|
+
value,
|
|
2845
|
+
caseSensitive: false
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
function buildInListFilter2(fieldName, values) {
|
|
2851
|
+
return {
|
|
2852
|
+
filter: {
|
|
2853
|
+
fieldName,
|
|
2854
|
+
inListFilter: {
|
|
2855
|
+
values,
|
|
2856
|
+
caseSensitive: false
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
function buildMetricGreaterThanFilter(fieldName, value) {
|
|
2862
|
+
return {
|
|
2863
|
+
filter: {
|
|
2864
|
+
fieldName,
|
|
2865
|
+
numericFilter: {
|
|
2866
|
+
operation: "GREATER_THAN",
|
|
2867
|
+
value: { doubleValue: value }
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
function combineFilters(expressions) {
|
|
2873
|
+
if (expressions.length === 0) return void 0;
|
|
2874
|
+
if (expressions.length === 1) return expressions[0];
|
|
2875
|
+
return { andGroup: { expressions } };
|
|
2876
|
+
}
|
|
2877
|
+
function numericValue(row, fieldNames) {
|
|
2878
|
+
if (!row) return 0;
|
|
2879
|
+
for (const fieldName of fieldNames) {
|
|
2880
|
+
const value = row[fieldName];
|
|
2881
|
+
if (typeof value === "number") return value;
|
|
2882
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
2883
|
+
return Number(value);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
return 0;
|
|
2887
|
+
}
|
|
2888
|
+
function numericValueOrNull(row, fieldNames) {
|
|
2889
|
+
if (!row) return null;
|
|
2890
|
+
for (const fieldName of fieldNames) {
|
|
2891
|
+
if (!Object.prototype.hasOwnProperty.call(row, fieldName)) continue;
|
|
2892
|
+
const value = row[fieldName];
|
|
2893
|
+
if (typeof value === "number") return value;
|
|
2894
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
2895
|
+
return Number(value);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
return null;
|
|
2899
|
+
}
|
|
2900
|
+
function stringValue(row, fieldName) {
|
|
2901
|
+
const value = row[fieldName];
|
|
2902
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2903
|
+
}
|
|
2904
|
+
function isTruthyGA4Flag(value) {
|
|
2905
|
+
if (typeof value === "boolean") return value;
|
|
2906
|
+
if (typeof value === "number") return value > 0;
|
|
2907
|
+
if (typeof value !== "string") return false;
|
|
2908
|
+
return ["true", "yes", "1", "key event", "conversion"].includes(value.trim().toLowerCase());
|
|
2909
|
+
}
|
|
2910
|
+
function errorMessage(error) {
|
|
2911
|
+
return error instanceof Error ? error.message : String(error);
|
|
2912
|
+
}
|
|
2913
|
+
async function runReportRows(client, propertyId, request) {
|
|
2914
|
+
const response = await client.runReport(propertyId, request);
|
|
2915
|
+
return {
|
|
2916
|
+
response,
|
|
2917
|
+
rows: client.flattenResponse(response)
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
function recordString(value, key) {
|
|
2921
|
+
return typeof value[key] === "string" ? value[key] : void 0;
|
|
2922
|
+
}
|
|
2923
|
+
function recordNumber(value, key) {
|
|
2924
|
+
return typeof value[key] === "number" ? value[key] : void 0;
|
|
2925
|
+
}
|
|
2926
|
+
function countByStringField(records, fieldName) {
|
|
2927
|
+
return records.reduce((acc, record) => {
|
|
2928
|
+
const value = recordString(record, fieldName) ?? "UNKNOWN";
|
|
2929
|
+
acc[value] = (acc[value] ?? 0) + 1;
|
|
2930
|
+
return acc;
|
|
2931
|
+
}, {});
|
|
2932
|
+
}
|
|
2933
|
+
function summarizeAudienceExport(exportRecord) {
|
|
2934
|
+
const dimensions = Array.isArray(exportRecord["dimensions"]) ? exportRecord["dimensions"] : [];
|
|
2935
|
+
return {
|
|
2936
|
+
name: recordString(exportRecord, "name"),
|
|
2937
|
+
audience: recordString(exportRecord, "audience"),
|
|
2938
|
+
audienceDisplayName: recordString(exportRecord, "audienceDisplayName"),
|
|
2939
|
+
state: recordString(exportRecord, "state"),
|
|
2940
|
+
rowCount: recordNumber(exportRecord, "rowCount"),
|
|
2941
|
+
percentageCompleted: recordNumber(exportRecord, "percentageCompleted"),
|
|
2942
|
+
beginCreatingTime: recordString(exportRecord, "beginCreatingTime"),
|
|
2943
|
+
errorMessage: recordString(exportRecord, "errorMessage"),
|
|
2944
|
+
dimensions,
|
|
2945
|
+
dimensionCount: dimensions.length,
|
|
2946
|
+
creationQuotaTokensCharged: recordNumber(exportRecord, "creationQuotaTokensCharged")
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
function summarizeAudience(audience, includeDefinition) {
|
|
2950
|
+
const filterClauses = Array.isArray(audience["filterClauses"]) ? audience["filterClauses"] : [];
|
|
2951
|
+
const eventTrigger = isRecord2(audience["eventTrigger"]) ? audience["eventTrigger"] : void 0;
|
|
2952
|
+
const summary = {
|
|
2953
|
+
name: recordString(audience, "name"),
|
|
2954
|
+
displayName: recordString(audience, "displayName"),
|
|
2955
|
+
description: recordString(audience, "description"),
|
|
2956
|
+
membershipDurationDays: recordNumber(audience, "membershipDurationDays"),
|
|
2957
|
+
adsPersonalizationEnabled: audience["adsPersonalizationEnabled"],
|
|
2958
|
+
filterClauseCount: filterClauses.length,
|
|
2959
|
+
hasEventTrigger: Boolean(eventTrigger),
|
|
2960
|
+
eventTrigger: eventTrigger ? {
|
|
2961
|
+
eventName: recordString(eventTrigger, "eventName"),
|
|
2962
|
+
logCondition: recordString(eventTrigger, "logCondition")
|
|
2963
|
+
} : void 0
|
|
2964
|
+
};
|
|
2965
|
+
return includeDefinition ? { ...summary, definition: audience } : summary;
|
|
2966
|
+
}
|
|
2967
|
+
function summarizeRecurringAudienceList(list) {
|
|
2968
|
+
const dimensions = Array.isArray(list["dimensions"]) ? list["dimensions"] : [];
|
|
2969
|
+
return {
|
|
2970
|
+
name: recordString(list, "name"),
|
|
2971
|
+
audience: recordString(list, "audience"),
|
|
2972
|
+
audienceDisplayName: recordString(list, "audienceDisplayName"),
|
|
2973
|
+
dimensions,
|
|
2974
|
+
dimensionCount: dimensions.length,
|
|
2975
|
+
audienceLists: stringArrayOrUndefined(list["audienceLists"]) ?? [],
|
|
2976
|
+
activeDaysRemaining: recordNumber(list, "activeDaysRemaining"),
|
|
2977
|
+
hasWebhookNotification: isRecord2(list["webhookNotification"])
|
|
2978
|
+
};
|
|
2979
|
+
}
|
|
2980
|
+
function summarizeBigQueryLink(link) {
|
|
2981
|
+
return {
|
|
2982
|
+
name: recordString(link, "name"),
|
|
2983
|
+
project: recordString(link, "project"),
|
|
2984
|
+
datasetLocation: recordString(link, "datasetLocation"),
|
|
2985
|
+
createTime: recordString(link, "createTime"),
|
|
2986
|
+
dailyExportEnabled: link["dailyExportEnabled"] === true,
|
|
2987
|
+
streamingExportEnabled: link["streamingExportEnabled"] === true,
|
|
2988
|
+
freshDailyExportEnabled: link["freshDailyExportEnabled"] === true,
|
|
2989
|
+
includeAdvertisingId: link["includeAdvertisingId"] === true,
|
|
2990
|
+
exportStreams: stringArrayOrUndefined(link["exportStreams"]) ?? [],
|
|
2991
|
+
excludedEvents: stringArrayOrUndefined(link["excludedEvents"]) ?? []
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
function summarizeDataStream(stream) {
|
|
2995
|
+
const webStreamData = isRecord2(stream["webStreamData"]) ? stream["webStreamData"] : void 0;
|
|
2996
|
+
const androidAppStreamData = isRecord2(stream["androidAppStreamData"]) ? stream["androidAppStreamData"] : void 0;
|
|
2997
|
+
const iosAppStreamData = isRecord2(stream["iosAppStreamData"]) ? stream["iosAppStreamData"] : void 0;
|
|
2998
|
+
return {
|
|
2999
|
+
name: recordString(stream, "name"),
|
|
3000
|
+
type: recordString(stream, "type"),
|
|
3001
|
+
displayName: recordString(stream, "displayName"),
|
|
3002
|
+
createTime: recordString(stream, "createTime"),
|
|
3003
|
+
updateTime: recordString(stream, "updateTime"),
|
|
3004
|
+
webStreamData: webStreamData ? {
|
|
3005
|
+
measurementId: recordString(webStreamData, "measurementId"),
|
|
3006
|
+
defaultUri: recordString(webStreamData, "defaultUri")
|
|
3007
|
+
} : void 0,
|
|
3008
|
+
androidAppStreamData: androidAppStreamData ? {
|
|
3009
|
+
packageName: recordString(androidAppStreamData, "packageName"),
|
|
3010
|
+
firebaseAppId: recordString(androidAppStreamData, "firebaseAppId")
|
|
3011
|
+
} : void 0,
|
|
3012
|
+
iosAppStreamData: iosAppStreamData ? {
|
|
3013
|
+
bundleId: recordString(iosAppStreamData, "bundleId"),
|
|
3014
|
+
firebaseAppId: recordString(iosAppStreamData, "firebaseAppId")
|
|
3015
|
+
} : void 0
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
function buildFunnelFieldFilter(fieldName, value, matchType) {
|
|
3019
|
+
return {
|
|
3020
|
+
funnelFieldFilter: {
|
|
3021
|
+
fieldName,
|
|
3022
|
+
stringFilter: {
|
|
3023
|
+
matchType,
|
|
3024
|
+
value,
|
|
3025
|
+
caseSensitive: false
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
function buildAdvancedFunnelFilterExpression(step) {
|
|
3031
|
+
const expressions = [];
|
|
3032
|
+
if (step.eventName) {
|
|
3033
|
+
expressions.push({ funnelEventFilter: { eventName: step.eventName } });
|
|
3034
|
+
}
|
|
3035
|
+
if (step.pagePath) {
|
|
3036
|
+
expressions.push(buildFunnelFieldFilter("pagePath", step.pagePath, step.matchType ?? "EXACT"));
|
|
3037
|
+
}
|
|
3038
|
+
if (step.fieldName && step.fieldValue) {
|
|
3039
|
+
expressions.push(buildFunnelFieldFilter(step.fieldName, step.fieldValue, step.matchType ?? "EXACT"));
|
|
3040
|
+
}
|
|
3041
|
+
return expressions.length === 1 ? expressions[0] : { andGroup: { expressions } };
|
|
3042
|
+
}
|
|
3043
|
+
function buildAdvancedFunnelReportFilter(step) {
|
|
3044
|
+
const expressions = [];
|
|
3045
|
+
if (step.eventName) {
|
|
3046
|
+
expressions.push(buildStringFilter2("eventName", step.eventName, "EXACT"));
|
|
3047
|
+
}
|
|
3048
|
+
if (step.pagePath) {
|
|
3049
|
+
expressions.push(buildStringFilter2("pagePath", step.pagePath, step.matchType ?? "EXACT"));
|
|
3050
|
+
}
|
|
3051
|
+
if (step.fieldName && step.fieldValue) {
|
|
3052
|
+
expressions.push(buildStringFilter2(step.fieldName, step.fieldValue, step.matchType ?? "EXACT"));
|
|
3053
|
+
}
|
|
3054
|
+
return combineFilters(expressions);
|
|
3055
|
+
}
|
|
3056
|
+
function flattenFunnelSubReport(client, subReport) {
|
|
3057
|
+
return subReport ? client.flattenResponse(subReport) : [];
|
|
3058
|
+
}
|
|
3059
|
+
async function runAdvancedFunnelStepCountFallback(client, propertyId, dateRange, steps) {
|
|
3060
|
+
const rows = [];
|
|
3061
|
+
for (let index = 0; index < steps.length; index++) {
|
|
3062
|
+
const step = steps[index];
|
|
3063
|
+
const report = await runReportRows(client, propertyId, {
|
|
3064
|
+
dateRanges: [dateRange],
|
|
3065
|
+
metrics: toMetrics(["activeUsers"]),
|
|
3066
|
+
dimensionFilter: buildAdvancedFunnelReportFilter(step),
|
|
3067
|
+
keepEmptyRows: true,
|
|
3068
|
+
limit: 1
|
|
3069
|
+
});
|
|
3070
|
+
const value = numericValue(report.rows[0], ["activeUsers"]);
|
|
3071
|
+
const previousValue = rows[index - 1]?.value ?? null;
|
|
3072
|
+
const firstValue = rows[0]?.value ?? value;
|
|
3073
|
+
rows.push({
|
|
3074
|
+
index: index + 1,
|
|
3075
|
+
name: step.name ?? step.eventName ?? step.pagePath ?? step.fieldName ?? `Step ${index + 1}`,
|
|
3076
|
+
value,
|
|
3077
|
+
conversionRateFromPrevious: previousValue && previousValue > 0 ? value / previousValue : null,
|
|
3078
|
+
conversionRateFromFirst: firstValue > 0 ? value / firstValue : null
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
return rows;
|
|
3082
|
+
}
|
|
3083
|
+
function registerGA4Tools(server, config) {
|
|
3084
|
+
const client = new GA4Client({ clientId: config.clientId, clientSecret: config.clientSecret, refreshToken: config.refreshToken });
|
|
3085
|
+
server.tool(
|
|
3086
|
+
"ga4_health_check",
|
|
3087
|
+
"Read-only GA4 health check. Verifies configured credentials, accessible properties, and metadata/property access without exposing OAuth tokens.",
|
|
3088
|
+
{ propertyId: propertyIdSchema2.optional().describe("Optional property ID to check metadata and property details for") },
|
|
3089
|
+
async ({ propertyId }) => {
|
|
3090
|
+
const warnings = [];
|
|
3091
|
+
const actions = [];
|
|
3092
|
+
const checks = [];
|
|
3093
|
+
try {
|
|
3094
|
+
checks.push({
|
|
3095
|
+
name: "credentials_configured",
|
|
3096
|
+
status: config.clientId && config.clientSecret && config.refreshToken ? "ok" : "error",
|
|
3097
|
+
detail: "Presence checked only; credential values are never returned."
|
|
3098
|
+
});
|
|
3099
|
+
let properties = [];
|
|
3100
|
+
try {
|
|
3101
|
+
properties = await client.listProperties();
|
|
3102
|
+
checks.push({ name: "list_properties", status: "ok", detail: `${properties.length} properties accessible` });
|
|
3103
|
+
if (properties.length === 0) {
|
|
3104
|
+
warnings.push("No GA4 properties were returned for these credentials.");
|
|
3105
|
+
actions.push("Grant the authenticated Google user Viewer access to at least one GA4 property.");
|
|
3106
|
+
}
|
|
3107
|
+
} catch (error) {
|
|
3108
|
+
checks.push({ name: "list_properties", status: "error", detail: errorMessage(error) });
|
|
3109
|
+
actions.push("Verify GA4_CLIENT_ID, GA4_CLIENT_SECRET, GA4_REFRESH_TOKEN, and Analytics Admin API access.");
|
|
3110
|
+
return ok2({
|
|
3111
|
+
status: "error",
|
|
3112
|
+
checks,
|
|
3113
|
+
warnings,
|
|
3114
|
+
actions,
|
|
3115
|
+
credentials: {
|
|
3116
|
+
clientIdConfigured: Boolean(config.clientId),
|
|
3117
|
+
clientSecretConfigured: Boolean(config.clientSecret),
|
|
3118
|
+
refreshTokenConfigured: Boolean(config.refreshToken)
|
|
3119
|
+
},
|
|
3120
|
+
debug: { requestCount: 1 },
|
|
3121
|
+
tokenExposure: "No access token or refresh token is returned by this tool."
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
const configuredDefaultPropertyId = config.defaultPropertyId;
|
|
3125
|
+
const selectedPropertyId = propertyId ?? configuredDefaultPropertyId ?? properties[0]?.propertyId;
|
|
3126
|
+
if (configuredDefaultPropertyId && !propertyId) {
|
|
3127
|
+
const foundConfiguredDefault = properties.some((property) => property.propertyId === configuredDefaultPropertyId);
|
|
3128
|
+
if (foundConfiguredDefault) {
|
|
3129
|
+
checks.push({ name: "default_property_configured", status: "ok", detail: `Using GA4_PROPERTY_ID=${configuredDefaultPropertyId}` });
|
|
3130
|
+
} else {
|
|
3131
|
+
warnings.push(`GA4_PROPERTY_ID ${configuredDefaultPropertyId} was not present in accountSummaries; direct property/metadata checks will still be attempted.`);
|
|
3132
|
+
checks.push({ name: "default_property_configured", status: "warning", detail: `GA4_PROPERTY_ID=${configuredDefaultPropertyId} not found in account summaries` });
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
if (propertyId && properties.length > 0 && !properties.some((property) => property.propertyId === propertyId)) {
|
|
3136
|
+
warnings.push(`Property ${propertyId} was not present in accountSummaries; direct property/metadata checks will still be attempted.`);
|
|
3137
|
+
}
|
|
3138
|
+
let propertyDetail = null;
|
|
3139
|
+
let metadataSummary = null;
|
|
3140
|
+
if (selectedPropertyId) {
|
|
3141
|
+
try {
|
|
3142
|
+
propertyDetail = await client.getProperty(selectedPropertyId);
|
|
3143
|
+
checks.push({ name: "get_property", status: "ok", detail: `Property ${selectedPropertyId} is readable` });
|
|
3144
|
+
} catch (error) {
|
|
3145
|
+
checks.push({ name: "get_property", status: "warning", detail: errorMessage(error) });
|
|
3146
|
+
warnings.push(`Could not read Admin API property details for ${selectedPropertyId}.`);
|
|
3147
|
+
actions.push("Confirm the authenticated user can access the GA4 property in Google Analytics Admin.");
|
|
3148
|
+
}
|
|
3149
|
+
try {
|
|
3150
|
+
metadataSummary = summarizeMetadata(await client.getMetadata(selectedPropertyId));
|
|
3151
|
+
checks.push({
|
|
3152
|
+
name: "get_metadata",
|
|
3153
|
+
status: "ok",
|
|
3154
|
+
detail: `${metadataSummary.dimensions.length} dimensions, ${metadataSummary.metrics.length} metrics`
|
|
3155
|
+
});
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
checks.push({ name: "get_metadata", status: "warning", detail: errorMessage(error) });
|
|
3158
|
+
warnings.push(`Could not read Data API metadata for ${selectedPropertyId}.`);
|
|
3159
|
+
actions.push("Enable the Google Analytics Data API and verify property-level Viewer access.");
|
|
3160
|
+
}
|
|
3161
|
+
} else {
|
|
3162
|
+
checks.push({ name: "get_property", status: "warning", detail: "Skipped because no property was available." });
|
|
3163
|
+
checks.push({ name: "get_metadata", status: "warning", detail: "Skipped because no property was available." });
|
|
3164
|
+
}
|
|
3165
|
+
const customDefinitions = metadataSummary ? getCustomDefinitions(metadataSummary) : null;
|
|
3166
|
+
const status = checks.some((check) => check.status === "error") ? "error" : warnings.length > 0 ? "warning" : "ok";
|
|
3167
|
+
return ok2({
|
|
3168
|
+
status,
|
|
3169
|
+
checks,
|
|
3170
|
+
warnings,
|
|
3171
|
+
actions,
|
|
3172
|
+
credentials: {
|
|
3173
|
+
clientIdConfigured: Boolean(config.clientId),
|
|
3174
|
+
clientSecretConfigured: Boolean(config.clientSecret),
|
|
3175
|
+
refreshTokenConfigured: Boolean(config.refreshToken),
|
|
3176
|
+
defaultPropertyIdConfigured: Boolean(config.defaultPropertyId)
|
|
3177
|
+
},
|
|
3178
|
+
properties: {
|
|
3179
|
+
count: properties.length,
|
|
3180
|
+
sample: properties.slice(0, 10).map((property) => ({
|
|
3181
|
+
propertyId: property.propertyId,
|
|
3182
|
+
displayName: property.displayName,
|
|
3183
|
+
timeZone: property.timeZone,
|
|
3184
|
+
currencyCode: property.currencyCode,
|
|
3185
|
+
propertyType: property.propertyType,
|
|
3186
|
+
parent: property.parent
|
|
3187
|
+
}))
|
|
3188
|
+
},
|
|
3189
|
+
selectedPropertyId,
|
|
3190
|
+
defaultPropertyId: configuredDefaultPropertyId ?? null,
|
|
3191
|
+
property: propertyDetail,
|
|
3192
|
+
metadata: metadataSummary ? {
|
|
3193
|
+
dimensions: metadataSummary.dimensions.length,
|
|
3194
|
+
metrics: metadataSummary.metrics.length,
|
|
3195
|
+
customDimensions: customDefinitions?.customDimensions.length ?? 0,
|
|
3196
|
+
customMetrics: customDefinitions?.customMetrics.length ?? 0
|
|
3197
|
+
} : null,
|
|
3198
|
+
debug: { requestCount: 1 + (propertyDetail ? 1 : 0) + (metadataSummary ? 1 : 0) },
|
|
3199
|
+
tokenExposure: "No access token or refresh token is returned by this tool."
|
|
3200
|
+
});
|
|
3201
|
+
} catch (e) {
|
|
3202
|
+
return formatMcpToolError(e);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
);
|
|
3206
|
+
server.tool(
|
|
3207
|
+
"ga4_list_properties",
|
|
3208
|
+
"List all GA4 properties accessible with the current credentials. Returns property ID, display name, timezone, and currency.",
|
|
3209
|
+
{},
|
|
3210
|
+
async () => {
|
|
3211
|
+
try {
|
|
3212
|
+
const properties = await client.listProperties();
|
|
3213
|
+
return ok2({ properties, count: properties.length });
|
|
3214
|
+
} catch (e) {
|
|
3215
|
+
return formatMcpToolError(e);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
);
|
|
3219
|
+
server.tool(
|
|
3220
|
+
"ga4_run_report",
|
|
3221
|
+
`Run a GA4 analytics report with intelligent query planning. Supports 52 metrics, 63 dimensions, auto-pagination.
|
|
3222
|
+
Use ga4://metrics and ga4://dimensions resources for available fields. Max 9 dimensions + 10 metrics per request.`,
|
|
3223
|
+
{
|
|
3224
|
+
propertyId: propertyIdSchema2,
|
|
3225
|
+
metrics: z3.array(z3.string()).min(1).describe("Metric keys (e.g., sessions, activeUsers, totalRevenue)"),
|
|
3226
|
+
dimensions: z3.array(z3.string()).optional().describe("Dimension keys (e.g., date, sessionSource, country)"),
|
|
3227
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3228
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3229
|
+
datePreset: z3.enum(["today", "yesterday", "last7days", "last28days", "last30days", "last90days", "last12months", "thisMonth", "lastMonth", "thisYear"]).optional(),
|
|
3230
|
+
orderBy: z3.string().optional().describe("Metric or dimension key to sort by"),
|
|
3231
|
+
orderDirection: z3.enum(["ASC", "DESC"]).optional().default("DESC"),
|
|
3232
|
+
limit: z3.number().int().min(1).max(25e4).optional().default(1e3),
|
|
3233
|
+
offset: z3.number().int().min(0).optional().describe("Optional 0-based row offset for explicit pagination."),
|
|
3234
|
+
autoPaginate: z3.boolean().optional().default(false).describe("When true, omit the limit so the GA4 client paginates through all rows using offset.")
|
|
3235
|
+
},
|
|
3236
|
+
async ({ propertyId, metrics, dimensions, startDate, endDate, datePreset, orderBy, orderDirection, limit, offset, autoPaginate }) => {
|
|
3237
|
+
try {
|
|
3238
|
+
const startTime = Date.now();
|
|
3239
|
+
const plan = planGA4Query({
|
|
3240
|
+
propertyId,
|
|
3241
|
+
metrics,
|
|
3242
|
+
dimensions,
|
|
3243
|
+
dateRange: startDate && endDate ? { startDate, endDate } : void 0,
|
|
3244
|
+
datePreset,
|
|
3245
|
+
orderBy,
|
|
3246
|
+
orderDirection,
|
|
3247
|
+
limit: autoPaginate ? void 0 : limit
|
|
3248
|
+
});
|
|
3249
|
+
if (typeof offset === "number") plan.request.offset = offset;
|
|
3250
|
+
if (plan.errors.length > 0) {
|
|
3251
|
+
return ok2({ error: "Query validation failed", errors: plan.errors, warnings: plan.warnings });
|
|
3252
|
+
}
|
|
3253
|
+
const response = await client.runReport(propertyId, plan.request);
|
|
3254
|
+
let data = client.flattenResponse(response);
|
|
3255
|
+
if (plan.calculatedMetrics.length > 0) {
|
|
3256
|
+
data = enrichWithCalculatedMetrics(data, plan.calculatedMetrics);
|
|
3257
|
+
}
|
|
3258
|
+
return ok2({
|
|
3259
|
+
data,
|
|
3260
|
+
rowCount: data.length,
|
|
3261
|
+
totalRowCount: response.rowCount,
|
|
3262
|
+
debug: {
|
|
3263
|
+
requestCount: 1,
|
|
3264
|
+
executionTimeMs: Date.now() - startTime,
|
|
3265
|
+
warnings: plan.warnings,
|
|
3266
|
+
calculatedMetrics: plan.calculatedMetrics,
|
|
3267
|
+
apiMetrics: plan.apiMetrics,
|
|
3268
|
+
pagination: {
|
|
3269
|
+
mode: autoPaginate ? "auto_offset" : "single_page",
|
|
3270
|
+
autoPaginate,
|
|
3271
|
+
offset: offset ?? 0,
|
|
3272
|
+
limit: autoPaginate ? null : limit,
|
|
3273
|
+
totalRowCount: response.rowCount
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
});
|
|
3277
|
+
} catch (e) {
|
|
3278
|
+
return formatMcpToolError(e);
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
);
|
|
3282
|
+
server.tool(
|
|
3283
|
+
"ga4_run_realtime_report",
|
|
3284
|
+
"Run a read-only GA4 Data API realtime report. Realtime supports only a limited subset of dimensions and metrics.",
|
|
3285
|
+
{
|
|
3286
|
+
propertyId: propertyIdSchema2,
|
|
3287
|
+
metrics: z3.array(z3.string()).min(1).max(10).optional().default(["activeUsers"]).describe("Realtime metric API names, e.g. activeUsers, eventCount"),
|
|
3288
|
+
dimensions: z3.array(z3.string()).max(9).optional().describe("Realtime dimension API names, e.g. eventName, city, deviceCategory"),
|
|
3289
|
+
dimensionFilter: z3.record(z3.unknown()).optional().describe("Native realtime-compatible GA4 FilterExpression JSON."),
|
|
3290
|
+
metricFilter: z3.record(z3.unknown()).optional().describe("Native realtime-compatible GA4 FilterExpression JSON."),
|
|
3291
|
+
minuteRanges: z3.array(z3.object({
|
|
3292
|
+
name: z3.string().optional(),
|
|
3293
|
+
startMinutesAgo: z3.number().int().min(0).max(59).optional(),
|
|
3294
|
+
endMinutesAgo: z3.number().int().min(0).max(59).optional()
|
|
3295
|
+
})).max(2).optional().describe("Optional named realtime minute ranges; zero is the current minute."),
|
|
3296
|
+
orderBys: z3.array(z3.record(z3.unknown())).max(10).optional().describe("Native GA4 OrderBy JSON."),
|
|
3297
|
+
limit: z3.number().int().min(1).max(1e4).optional().default(100),
|
|
3298
|
+
keepEmptyRows: z3.boolean().optional().default(false)
|
|
3299
|
+
},
|
|
3300
|
+
async ({ propertyId, metrics, dimensions, dimensionFilter, metricFilter, minuteRanges, orderBys, limit, keepEmptyRows }) => {
|
|
3301
|
+
try {
|
|
3302
|
+
const response = await client.runRealtimeReport(propertyId, {
|
|
3303
|
+
metrics: toMetrics(metrics),
|
|
3304
|
+
...dimensions && dimensions.length > 0 && { dimensions: toDimensions(dimensions) },
|
|
3305
|
+
...dimensionFilter && { dimensionFilter },
|
|
3306
|
+
...metricFilter && { metricFilter },
|
|
3307
|
+
...minuteRanges && { minuteRanges },
|
|
3308
|
+
...orderBys && { orderBys },
|
|
3309
|
+
limit,
|
|
3310
|
+
keepEmptyRows,
|
|
3311
|
+
returnPropertyQuota: true
|
|
3312
|
+
});
|
|
3313
|
+
const data = client.flattenResponse(response);
|
|
3314
|
+
return ok2({
|
|
3315
|
+
data,
|
|
3316
|
+
rowCount: data.length,
|
|
3317
|
+
totalRowCount: response.rowCount,
|
|
3318
|
+
warnings: [
|
|
3319
|
+
"GA4 realtime reports support a limited subset of dimensions and metrics. If a field fails, check ga4_get_metadata or retry with core realtime fields such as activeUsers, eventCount, eventName, city, and deviceCategory."
|
|
3320
|
+
],
|
|
3321
|
+
propertyQuota: response.propertyQuota
|
|
3322
|
+
});
|
|
3323
|
+
} catch (e) {
|
|
3324
|
+
return formatMcpToolError(e);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
);
|
|
3328
|
+
server.tool(
|
|
3329
|
+
"ga4_get_metadata",
|
|
3330
|
+
"Get the complete list of dimensions and metrics available for a specific GA4 property. Useful for discovering custom dimensions/metrics.",
|
|
3331
|
+
{ propertyId: propertyIdSchema2 },
|
|
3332
|
+
async ({ propertyId }) => {
|
|
3333
|
+
try {
|
|
3334
|
+
const metadata = await client.getMetadata(propertyId);
|
|
3335
|
+
return ok2(metadata);
|
|
3336
|
+
} catch (e) {
|
|
3337
|
+
return formatMcpToolError(e);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
);
|
|
3341
|
+
server.tool(
|
|
3342
|
+
"ga4_get_custom_definitions",
|
|
3343
|
+
"List custom dimensions and custom metrics exposed by GA4 Data API metadata. Read-only fallback for custom definition inventory.",
|
|
3344
|
+
{ propertyId: propertyIdSchema2 },
|
|
3345
|
+
async ({ propertyId }) => {
|
|
3346
|
+
try {
|
|
3347
|
+
const metadata = await client.getMetadata(propertyId);
|
|
3348
|
+
const summary = summarizeMetadata(metadata);
|
|
3349
|
+
const customDefinitions = getCustomDefinitions(summary);
|
|
3350
|
+
return ok2({
|
|
3351
|
+
propertyId,
|
|
3352
|
+
source: "data_api_metadata",
|
|
3353
|
+
customDimensions: customDefinitions.customDimensions,
|
|
3354
|
+
customMetrics: customDefinitions.customMetrics,
|
|
3355
|
+
counts: {
|
|
3356
|
+
customDimensions: customDefinitions.customDimensions.length,
|
|
3357
|
+
customMetrics: customDefinitions.customMetrics.length,
|
|
3358
|
+
metadataDimensions: summary.dimensions.length,
|
|
3359
|
+
metadataMetrics: summary.metrics.length
|
|
3360
|
+
},
|
|
3361
|
+
limitations: [
|
|
3362
|
+
"This server does not call Admin API mutate endpoints.",
|
|
3363
|
+
"Data API metadata exposes reportable custom definitions but may omit Admin-only details such as archived state or exact creation metadata."
|
|
3364
|
+
]
|
|
3365
|
+
});
|
|
3366
|
+
} catch (e) {
|
|
3367
|
+
return formatMcpToolError(e);
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
);
|
|
3371
|
+
server.tool(
|
|
3372
|
+
"ga4_get_key_events",
|
|
3373
|
+
"Inventory GA4 key events/conversions using metadata-aware Data API reports. Returns best-effort event names and counts.",
|
|
3374
|
+
{
|
|
3375
|
+
propertyId: propertyIdSchema2,
|
|
3376
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3377
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3378
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
3379
|
+
limit: z3.number().int().min(1).max(1e3).optional().default(100)
|
|
3380
|
+
},
|
|
3381
|
+
async ({ propertyId, startDate, endDate, datePreset, limit }) => {
|
|
3382
|
+
try {
|
|
3383
|
+
const warnings = [];
|
|
3384
|
+
const attempts = [];
|
|
3385
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
3386
|
+
let metadataSummary;
|
|
3387
|
+
try {
|
|
3388
|
+
metadataSummary = summarizeMetadata(await client.getMetadata(propertyId));
|
|
3389
|
+
} catch (error) {
|
|
3390
|
+
warnings.push(`Metadata lookup failed; continuing with standard key event fields. ${errorMessage(error)}`);
|
|
3391
|
+
}
|
|
3392
|
+
const eventNameField = pickAvailableField(metadataSummary, "dimensions", ["eventName"]) ?? "eventName";
|
|
3393
|
+
const flagField = pickAvailableField(metadataSummary, "dimensions", ["isKeyEvent", "isConversionEvent"]);
|
|
3394
|
+
const metricField = pickAvailableField(metadataSummary, "metrics", ["keyEvents", "conversions"]);
|
|
3395
|
+
let rows = [];
|
|
3396
|
+
let source = "none";
|
|
3397
|
+
if (flagField && metricField) {
|
|
3398
|
+
attempts.push(`eventName + ${flagField} + ${metricField}`);
|
|
3399
|
+
try {
|
|
3400
|
+
const report = await runReportRows(client, propertyId, {
|
|
3401
|
+
dateRanges: [dateRange],
|
|
3402
|
+
dimensions: toDimensions([eventNameField, flagField]),
|
|
3403
|
+
metrics: toMetrics([metricField]),
|
|
3404
|
+
orderBys: [{ metric: { metricName: metricField }, desc: true }],
|
|
3405
|
+
limit
|
|
3406
|
+
});
|
|
3407
|
+
rows = report.rows.filter((row) => isTruthyGA4Flag(row[flagField]) || numericValue(row, [metricField]) > 0);
|
|
3408
|
+
source = `report:${eventNameField}+${flagField}+${metricField}`;
|
|
3409
|
+
} catch (error) {
|
|
3410
|
+
warnings.push(`Could not report with key event flag (${flagField}). ${errorMessage(error)}`);
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
if (rows.length === 0 && metricField) {
|
|
3414
|
+
attempts.push(`eventName + ${metricField} > 0`);
|
|
3415
|
+
try {
|
|
3416
|
+
const report = await runReportRows(client, propertyId, {
|
|
3417
|
+
dateRanges: [dateRange],
|
|
3418
|
+
dimensions: toDimensions([eventNameField]),
|
|
3419
|
+
metrics: toMetrics([metricField]),
|
|
3420
|
+
metricFilter: buildMetricGreaterThanFilter(metricField, 0),
|
|
3421
|
+
orderBys: [{ metric: { metricName: metricField }, desc: true }],
|
|
3422
|
+
limit
|
|
3423
|
+
});
|
|
3424
|
+
rows = report.rows.filter((row) => numericValue(row, [metricField]) > 0);
|
|
3425
|
+
source = `report:${eventNameField}+${metricField}`;
|
|
3426
|
+
} catch (error) {
|
|
3427
|
+
warnings.push(`Could not report key event metric (${metricField}). ${errorMessage(error)}`);
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
if (!metricField) {
|
|
3431
|
+
warnings.push("No keyEvents or conversions metric was found in metadata. Key event inventory cannot be determined from Data API reports.");
|
|
3432
|
+
}
|
|
3433
|
+
const keyEvents = rows.map((row) => {
|
|
3434
|
+
const count = metricField ? numericValue(row, [metricField]) : 0;
|
|
3435
|
+
return {
|
|
3436
|
+
eventName: stringValue(row, eventNameField),
|
|
3437
|
+
...flagField && { flagField, flagValue: row[flagField], isKeyEvent: isTruthyGA4Flag(row[flagField]) },
|
|
3438
|
+
...metricField && { metric: metricField, value: count }
|
|
3439
|
+
};
|
|
3440
|
+
}).filter((event) => event.eventName);
|
|
3441
|
+
return ok2({
|
|
3442
|
+
propertyId,
|
|
3443
|
+
dateRange,
|
|
3444
|
+
source,
|
|
3445
|
+
attempts,
|
|
3446
|
+
keyEvents,
|
|
3447
|
+
count: keyEvents.length,
|
|
3448
|
+
warnings,
|
|
3449
|
+
limitations: [
|
|
3450
|
+
"This is a read-only Data API inventory. Admin-only key event configuration details may require Admin API support.",
|
|
3451
|
+
"If the property uses the newer key events naming, keyEvents is preferred; conversions is used as a fallback."
|
|
3452
|
+
]
|
|
3453
|
+
});
|
|
3454
|
+
} catch (e) {
|
|
3455
|
+
return formatMcpToolError(e);
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
);
|
|
3459
|
+
server.tool(
|
|
3460
|
+
"ga4_get_ecommerce_diagnostics",
|
|
3461
|
+
"Read-only ecommerce coverage diagnostics for core ecommerce events, revenue metrics, and item-level reporting over a date range.",
|
|
3462
|
+
{
|
|
3463
|
+
propertyId: propertyIdSchema2,
|
|
3464
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3465
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3466
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
3467
|
+
limit: z3.number().int().min(1).max(1e3).optional().default(100)
|
|
3468
|
+
},
|
|
3469
|
+
async ({ propertyId, startDate, endDate, datePreset, limit }) => {
|
|
3470
|
+
try {
|
|
3471
|
+
const warnings = [];
|
|
3472
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
3473
|
+
const coreEvents = ["view_item", "add_to_cart", "begin_checkout", "purchase"];
|
|
3474
|
+
const diagnosticEvents = [...coreEvents, "view_item_list", "select_item", "add_shipping_info", "add_payment_info"];
|
|
3475
|
+
let metadataSummary;
|
|
3476
|
+
try {
|
|
3477
|
+
metadataSummary = summarizeMetadata(await client.getMetadata(propertyId));
|
|
3478
|
+
} catch (error) {
|
|
3479
|
+
warnings.push(`Metadata lookup failed; continuing with standard ecommerce fields. ${errorMessage(error)}`);
|
|
3480
|
+
}
|
|
3481
|
+
const eventNameField = pickAvailableField(metadataSummary, "dimensions", ["eventName"]) ?? "eventName";
|
|
3482
|
+
const eventCountMetric = pickAvailableField(metadataSummary, "metrics", ["eventCount"]) ?? "eventCount";
|
|
3483
|
+
let eventRows = [];
|
|
3484
|
+
try {
|
|
3485
|
+
const report = await runReportRows(client, propertyId, {
|
|
3486
|
+
dateRanges: [dateRange],
|
|
3487
|
+
dimensions: toDimensions([eventNameField]),
|
|
3488
|
+
metrics: toMetrics([eventCountMetric]),
|
|
3489
|
+
dimensionFilter: buildInListFilter2(eventNameField, diagnosticEvents),
|
|
3490
|
+
orderBys: [{ metric: { metricName: eventCountMetric }, desc: true }],
|
|
3491
|
+
limit
|
|
3492
|
+
});
|
|
3493
|
+
eventRows = report.rows;
|
|
3494
|
+
} catch (error) {
|
|
3495
|
+
warnings.push(`Could not read ecommerce event coverage. ${errorMessage(error)}`);
|
|
3496
|
+
}
|
|
3497
|
+
const totalMetrics = filterAvailableFields(metadataSummary, "metrics", [
|
|
3498
|
+
"totalRevenue",
|
|
3499
|
+
"purchaseRevenue",
|
|
3500
|
+
"itemRevenue",
|
|
3501
|
+
"transactions",
|
|
3502
|
+
"ecommercePurchases",
|
|
3503
|
+
"addToCarts",
|
|
3504
|
+
"checkouts",
|
|
3505
|
+
"itemsPurchased",
|
|
3506
|
+
"itemsViewed",
|
|
3507
|
+
"itemsAddedToCart",
|
|
3508
|
+
"itemsCheckedOut"
|
|
3509
|
+
]);
|
|
3510
|
+
let totalsRow;
|
|
3511
|
+
if (totalMetrics.length > 0) {
|
|
3512
|
+
totalsRow = {};
|
|
3513
|
+
for (const metric of totalMetrics) {
|
|
3514
|
+
try {
|
|
3515
|
+
const report = await runReportRows(client, propertyId, {
|
|
3516
|
+
dateRanges: [dateRange],
|
|
3517
|
+
metrics: toMetrics([metric]),
|
|
3518
|
+
limit: 1
|
|
3519
|
+
});
|
|
3520
|
+
totalsRow = { ...totalsRow, ...report.rows[0] ?? {} };
|
|
3521
|
+
} catch (error) {
|
|
3522
|
+
warnings.push(`Could not read ecommerce aggregate metric ${metric}. ${errorMessage(error)}`);
|
|
3523
|
+
}
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
const itemDimensions = filterAvailableFields(metadataSummary, "dimensions", ["itemId", "itemName"]);
|
|
3527
|
+
const itemMetrics = filterAvailableFields(metadataSummary, "metrics", [
|
|
3528
|
+
"itemsViewed",
|
|
3529
|
+
"itemsAddedToCart",
|
|
3530
|
+
"itemsCheckedOut",
|
|
3531
|
+
"itemsPurchased",
|
|
3532
|
+
"itemRevenue"
|
|
3533
|
+
]);
|
|
3534
|
+
let itemRows = [];
|
|
3535
|
+
if (itemDimensions.length > 0 && itemMetrics.length > 0) {
|
|
3536
|
+
try {
|
|
3537
|
+
const report = await runReportRows(client, propertyId, {
|
|
3538
|
+
dateRanges: [dateRange],
|
|
3539
|
+
dimensions: toDimensions(itemDimensions.slice(0, 2)),
|
|
3540
|
+
metrics: toMetrics(itemMetrics),
|
|
3541
|
+
orderBys: [{ metric: { metricName: itemMetrics[0] }, desc: true }],
|
|
3542
|
+
limit
|
|
3543
|
+
});
|
|
3544
|
+
itemRows = report.rows;
|
|
3545
|
+
} catch (error) {
|
|
3546
|
+
warnings.push(`Could not read item-level ecommerce report. ${errorMessage(error)}`);
|
|
3547
|
+
}
|
|
3548
|
+
} else {
|
|
3549
|
+
warnings.push("Item-level dimensions or metrics were not available in metadata.");
|
|
3550
|
+
}
|
|
3551
|
+
const eventCoverage = diagnosticEvents.map((eventName) => {
|
|
3552
|
+
const row = eventRows.find((candidate) => stringValue(candidate, eventNameField) === eventName);
|
|
3553
|
+
const eventCount = numericValue(row, [eventCountMetric]);
|
|
3554
|
+
return {
|
|
3555
|
+
eventName,
|
|
3556
|
+
present: eventCount > 0,
|
|
3557
|
+
eventCount
|
|
3558
|
+
};
|
|
3559
|
+
});
|
|
3560
|
+
const missingCoreEvents = eventCoverage.filter((event) => coreEvents.includes(event.eventName) && !event.present).map((event) => event.eventName);
|
|
3561
|
+
const totalRevenue = numericValueOrNull(totalsRow, ["totalRevenue"]);
|
|
3562
|
+
const purchaseRevenue = numericValueOrNull(totalsRow, ["purchaseRevenue"]);
|
|
3563
|
+
const purchases = numericValueOrNull(totalsRow, ["ecommercePurchases", "transactions"]);
|
|
3564
|
+
const purchaseEventCount = eventCoverage.find((event) => event.eventName === "purchase")?.eventCount ?? 0;
|
|
3565
|
+
if (missingCoreEvents.length > 0) {
|
|
3566
|
+
warnings.push(`Missing core ecommerce event coverage: ${missingCoreEvents.join(", ")}.`);
|
|
3567
|
+
}
|
|
3568
|
+
if (purchaseEventCount > 0 && purchases === null) {
|
|
3569
|
+
warnings.push("Purchase events are present but aggregate purchase metrics were unavailable; summary.purchases is null rather than inferred as 0.");
|
|
3570
|
+
}
|
|
3571
|
+
if (purchases !== null && purchases > 0 && totalRevenue === 0 && purchaseRevenue === 0) {
|
|
3572
|
+
warnings.push("Purchase events are present but revenue metrics are zero; verify value/currency ecommerce parameters.");
|
|
3573
|
+
}
|
|
3574
|
+
if (itemRows.length === 0) {
|
|
3575
|
+
warnings.push("No item-level rows returned; verify items array parameters such as item_id and item_name.");
|
|
3576
|
+
}
|
|
3577
|
+
return ok2({
|
|
3578
|
+
propertyId,
|
|
3579
|
+
dateRange,
|
|
3580
|
+
summary: {
|
|
3581
|
+
coreEventsPresent: coreEvents.filter((eventName) => eventCoverage.some((event) => event.eventName === eventName && event.present)),
|
|
3582
|
+
missingCoreEvents,
|
|
3583
|
+
totalRevenue,
|
|
3584
|
+
purchaseRevenue,
|
|
3585
|
+
purchases,
|
|
3586
|
+
itemRows: itemRows.length
|
|
3587
|
+
},
|
|
3588
|
+
eventCoverage,
|
|
3589
|
+
aggregateMetrics: totalsRow ?? {},
|
|
3590
|
+
itemBreakdown: itemRows,
|
|
3591
|
+
warnings,
|
|
3592
|
+
limitations: [
|
|
3593
|
+
"Diagnostics are based on reportable Data API fields and do not inspect raw event payloads.",
|
|
3594
|
+
"Use BigQuery export or DebugView to validate unregistered ecommerce parameters at event ingestion time."
|
|
3595
|
+
]
|
|
3596
|
+
});
|
|
3597
|
+
} catch (e) {
|
|
3598
|
+
return formatMcpToolError(e);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
);
|
|
3602
|
+
server.tool(
|
|
3603
|
+
"ga4_get_event_parameters",
|
|
3604
|
+
"Best-effort event parameter inventory from GA4 metadata plus event reports. Documents Data API limitations for raw parameters.",
|
|
3605
|
+
{
|
|
3606
|
+
propertyId: propertyIdSchema2,
|
|
3607
|
+
eventNames: z3.array(z3.string()).optional().describe("Optional event names to sample, e.g. purchase, add_to_cart"),
|
|
3608
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3609
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3610
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
3611
|
+
limit: z3.number().int().min(1).max(1e3).optional().default(50)
|
|
3612
|
+
},
|
|
3613
|
+
async ({ propertyId, eventNames, startDate, endDate, datePreset, limit }) => {
|
|
3614
|
+
try {
|
|
3615
|
+
const warnings = [];
|
|
3616
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
3617
|
+
const metadata = await client.getMetadata(propertyId);
|
|
3618
|
+
const summary = summarizeMetadata(metadata);
|
|
3619
|
+
const customDefinitions = getCustomDefinitions(summary);
|
|
3620
|
+
const eventNameField = pickAvailableField(summary, "dimensions", ["eventName"]) ?? "eventName";
|
|
3621
|
+
const eventCountMetric = pickAvailableField(summary, "metrics", ["eventCount"]) ?? "eventCount";
|
|
3622
|
+
let eventRows = [];
|
|
3623
|
+
try {
|
|
3624
|
+
const report = await runReportRows(client, propertyId, {
|
|
3625
|
+
dateRanges: [dateRange],
|
|
3626
|
+
dimensions: toDimensions([eventNameField]),
|
|
3627
|
+
metrics: toMetrics([eventCountMetric]),
|
|
3628
|
+
...eventNames && eventNames.length > 0 && { dimensionFilter: buildInListFilter2(eventNameField, eventNames) },
|
|
3629
|
+
orderBys: [{ metric: { metricName: eventCountMetric }, desc: true }],
|
|
3630
|
+
limit
|
|
3631
|
+
});
|
|
3632
|
+
eventRows = report.rows;
|
|
3633
|
+
} catch (error) {
|
|
3634
|
+
warnings.push(`Could not sample events for parameter context. ${errorMessage(error)}`);
|
|
3635
|
+
}
|
|
3636
|
+
const registeredEventParameters = [
|
|
3637
|
+
...customDefinitions.customDimensions,
|
|
3638
|
+
...customDefinitions.customMetrics
|
|
3639
|
+
].filter((definition) => definition.scope === "event").map((definition) => ({
|
|
3640
|
+
...definition,
|
|
3641
|
+
parameterName: definition.apiName.includes(":") ? definition.apiName.split(":").slice(1).join(":") : definition.apiName
|
|
3642
|
+
}));
|
|
3643
|
+
if (registeredEventParameters.length === 0) {
|
|
3644
|
+
warnings.push("No registered event-scoped custom dimensions or metrics were exposed by metadata.");
|
|
3645
|
+
}
|
|
3646
|
+
return ok2({
|
|
3647
|
+
propertyId,
|
|
3648
|
+
dateRange,
|
|
3649
|
+
registeredEventParameters,
|
|
3650
|
+
eventSamples: eventRows,
|
|
3651
|
+
warnings,
|
|
3652
|
+
limitations: [
|
|
3653
|
+
"GA4 Data API metadata only exposes parameters registered as reportable custom dimensions or metrics.",
|
|
3654
|
+
"Unregistered raw event parameters and parameter values are not enumerable through this read-only Data API path.",
|
|
3655
|
+
"For exhaustive raw parameter discovery, use GA4 BigQuery export or instrumentation logs."
|
|
3656
|
+
]
|
|
3657
|
+
});
|
|
3658
|
+
} catch (e) {
|
|
3659
|
+
return formatMcpToolError(e);
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
);
|
|
3663
|
+
server.tool(
|
|
3664
|
+
"ga4_run_funnel_recipe",
|
|
3665
|
+
"Run a read-only configurable funnel recipe using one GA4 report per eventName/pagePath step and return simple step counts.",
|
|
3666
|
+
{
|
|
3667
|
+
propertyId: propertyIdSchema2,
|
|
3668
|
+
steps: z3.array(funnelStepSchema).min(2).max(10).describe("Ordered funnel steps. Each step needs eventName or pagePath."),
|
|
3669
|
+
metric: z3.enum(["activeUsers", "totalUsers", "sessions", "eventCount", "screenPageViews"]).optional().default("activeUsers"),
|
|
3670
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3671
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3672
|
+
datePreset: datePresetSchema.optional().default("last28days")
|
|
3673
|
+
},
|
|
3674
|
+
async ({ propertyId, steps, metric, startDate, endDate, datePreset }) => {
|
|
3675
|
+
try {
|
|
3676
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
3677
|
+
const metricField = getMetricByKey(metric)?.apiField ?? metric;
|
|
3678
|
+
const results = [];
|
|
3679
|
+
for (let index = 0; index < steps.length; index++) {
|
|
3680
|
+
const step = steps[index];
|
|
3681
|
+
const filters = [];
|
|
3682
|
+
if (step.eventName) {
|
|
3683
|
+
filters.push(buildStringFilter2("eventName", step.eventName, "EXACT"));
|
|
3684
|
+
}
|
|
3685
|
+
if (step.pagePath) {
|
|
3686
|
+
filters.push(buildStringFilter2("pagePath", step.pagePath, step.matchType ?? "EXACT"));
|
|
3687
|
+
}
|
|
3688
|
+
const report = await runReportRows(client, propertyId, {
|
|
3689
|
+
dateRanges: [dateRange],
|
|
3690
|
+
metrics: toMetrics([metricField]),
|
|
3691
|
+
dimensionFilter: combineFilters(filters),
|
|
3692
|
+
keepEmptyRows: true,
|
|
3693
|
+
limit: 1
|
|
3694
|
+
});
|
|
3695
|
+
const value = numericValue(report.rows[0], [metricField]);
|
|
3696
|
+
const previousValue = results[index - 1]?.value ?? null;
|
|
3697
|
+
const firstValue = results[0]?.value ?? value;
|
|
3698
|
+
results.push({
|
|
3699
|
+
index: index + 1,
|
|
3700
|
+
name: step.name ?? step.eventName ?? step.pagePath ?? `Step ${index + 1}`,
|
|
3701
|
+
filter: {
|
|
3702
|
+
...step.eventName && { eventName: step.eventName },
|
|
3703
|
+
...step.pagePath && { pagePath: step.pagePath, matchType: step.matchType ?? "EXACT" }
|
|
3704
|
+
},
|
|
3705
|
+
value,
|
|
3706
|
+
conversionRateFromPrevious: previousValue && previousValue > 0 ? value / previousValue : null,
|
|
3707
|
+
conversionRateFromFirst: firstValue > 0 ? value / firstValue : null,
|
|
3708
|
+
dropoffFromPrevious: previousValue === null ? null : Math.max(previousValue - value, 0)
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
return ok2({
|
|
3712
|
+
propertyId,
|
|
3713
|
+
dateRange,
|
|
3714
|
+
metric: metricField,
|
|
3715
|
+
steps: results,
|
|
3716
|
+
recipe: {
|
|
3717
|
+
type: "simple_step_counts",
|
|
3718
|
+
readOnly: true,
|
|
3719
|
+
reportsRun: results.length
|
|
3720
|
+
},
|
|
3721
|
+
limitations: [
|
|
3722
|
+
"This is a simple read-only funnel recipe, not GA4 Explore funnel semantics.",
|
|
3723
|
+
"Each step is counted independently with one runReport call; counts are not user-sequenced or deduplicated across steps."
|
|
3724
|
+
]
|
|
3725
|
+
});
|
|
3726
|
+
} catch (e) {
|
|
3727
|
+
return formatMcpToolError(e);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
);
|
|
3731
|
+
server.tool(
|
|
3732
|
+
"ga4_get_audience_export_diagnostics",
|
|
3733
|
+
"Read-only Audience Export diagnostics. Lists existing audience exports, state counts, and optionally samples rows from an existing export.",
|
|
3734
|
+
{
|
|
3735
|
+
propertyId: propertyIdSchema2,
|
|
3736
|
+
audienceExportName: z3.string().optional().describe("Optional resource name, e.g. properties/123456789/audienceExports/abc123"),
|
|
3737
|
+
pageSize: z3.number().int().min(1).max(200).optional().default(100),
|
|
3738
|
+
includeRowSample: z3.boolean().optional().default(false).describe("If true, query a small row sample from audienceExportName. Can include user-level export dimensions."),
|
|
3739
|
+
sampleLimit: z3.number().int().min(1).max(10).optional().default(5),
|
|
3740
|
+
includePersonalIdentifiers: z3.boolean().optional().default(false).describe("Explicitly include user/device identifiers in the optional sample. False redacts them by default.")
|
|
3741
|
+
},
|
|
3742
|
+
async ({ propertyId, audienceExportName, pageSize, includeRowSample, sampleLimit, includePersonalIdentifiers }) => {
|
|
3743
|
+
try {
|
|
3744
|
+
const warnings = [];
|
|
3745
|
+
const exports = await client.listAudienceExports(propertyId, pageSize);
|
|
3746
|
+
const summarizedExports = exports.map(summarizeAudienceExport);
|
|
3747
|
+
const failedExports = summarizedExports.filter((exportRecord) => exportRecord.state === "FAILED");
|
|
3748
|
+
let selectedExport = null;
|
|
3749
|
+
let rowSample = null;
|
|
3750
|
+
if (audienceExportName) {
|
|
3751
|
+
try {
|
|
3752
|
+
selectedExport = summarizeAudienceExport(await client.getAudienceExport(audienceExportName));
|
|
3753
|
+
} catch (error) {
|
|
3754
|
+
warnings.push(`Could not get audience export ${audienceExportName}. ${errorMessage(error)}`);
|
|
3755
|
+
}
|
|
3756
|
+
if (includeRowSample) {
|
|
3757
|
+
try {
|
|
3758
|
+
const response = await client.queryAudienceExport(audienceExportName, { limit: String(sampleLimit), offset: "0" });
|
|
3759
|
+
rowSample = includePersonalIdentifiers ? response : redactGA4AudienceExportResponse(response);
|
|
3760
|
+
warnings.push(includePersonalIdentifiers ? "SENSITIVE PERSONAL IDENTIFIERS INCLUDED BY EXPLICIT OPT-IN: Audience Export sample user/device identifiers are present. Restrict storage, sharing, and model output." : "Audience Export sample user/device identifiers were redacted by default. Set includePersonalIdentifiers=true only with explicit authorization.");
|
|
3761
|
+
} catch (error) {
|
|
3762
|
+
warnings.push(`Could not query row sample for ${audienceExportName}. ${errorMessage(error)}`);
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
} else if (includeRowSample) {
|
|
3766
|
+
warnings.push("includeRowSample was ignored because audienceExportName was not provided.");
|
|
3767
|
+
}
|
|
3768
|
+
if (exports.length === 0) {
|
|
3769
|
+
warnings.push("No existing Audience Exports were found. This server intentionally does not create new exports.");
|
|
3770
|
+
}
|
|
3771
|
+
if (failedExports.length > 0) {
|
|
3772
|
+
warnings.push(`${failedExports.length} Audience Export(s) are FAILED. Inspect errorMessage for root cause.`);
|
|
3773
|
+
}
|
|
3774
|
+
return ok2({
|
|
3775
|
+
propertyId,
|
|
3776
|
+
source: "data_api_v1beta_audienceExports_list",
|
|
3777
|
+
count: exports.length,
|
|
3778
|
+
stateCounts: countByStringField(exports, "state"),
|
|
3779
|
+
audienceExports: summarizedExports,
|
|
3780
|
+
selectedExport,
|
|
3781
|
+
rowSample,
|
|
3782
|
+
warnings,
|
|
3783
|
+
limitations: [
|
|
3784
|
+
"This tool only lists/gets/queries existing Audience Exports; it never calls audienceExports.create.",
|
|
3785
|
+
"Row sampling is disabled by default, and identifier values are redacted unless includePersonalIdentifiers=true is explicitly supplied."
|
|
3786
|
+
]
|
|
3787
|
+
});
|
|
3788
|
+
} catch (e) {
|
|
3789
|
+
return formatMcpToolError(e);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
);
|
|
3793
|
+
server.tool(
|
|
3794
|
+
"ga4_get_audience_diagnostics",
|
|
3795
|
+
"Read-only audience diagnostics from Admin API audiences, Data API recurring audience lists, and observed audienceName report fallback.",
|
|
3796
|
+
{
|
|
3797
|
+
propertyId: propertyIdSchema2,
|
|
3798
|
+
recurringAudienceListName: z3.string().optional().describe("Optional resource name, e.g. properties/123456789/recurringAudienceLists/abc123"),
|
|
3799
|
+
includeDefinitions: z3.boolean().optional().default(false).describe("Include full Admin API audience definitions when available"),
|
|
3800
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
3801
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
3802
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
3803
|
+
limit: z3.number().int().min(1).max(1e3).optional().default(100)
|
|
3804
|
+
},
|
|
3805
|
+
async ({ propertyId, recurringAudienceListName, includeDefinitions, startDate, endDate, datePreset, limit }) => {
|
|
3806
|
+
try {
|
|
3807
|
+
const warnings = [];
|
|
3808
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
3809
|
+
let audiences = [];
|
|
3810
|
+
let recurringAudienceLists = [];
|
|
3811
|
+
let selectedRecurringAudienceList = null;
|
|
3812
|
+
let observedAudienceRows = [];
|
|
3813
|
+
try {
|
|
3814
|
+
audiences = await client.listAudiences(propertyId);
|
|
3815
|
+
} catch (error) {
|
|
3816
|
+
warnings.push(`Admin API audiences.list was unavailable. ${errorMessage(error)}`);
|
|
3817
|
+
}
|
|
3818
|
+
try {
|
|
3819
|
+
recurringAudienceLists = await client.listRecurringAudienceLists(propertyId);
|
|
3820
|
+
} catch (error) {
|
|
3821
|
+
warnings.push(`Data API recurringAudienceLists.list was unavailable. ${errorMessage(error)}`);
|
|
3822
|
+
}
|
|
3823
|
+
if (recurringAudienceListName) {
|
|
3824
|
+
try {
|
|
3825
|
+
selectedRecurringAudienceList = summarizeRecurringAudienceList(await client.getRecurringAudienceList(recurringAudienceListName));
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
warnings.push(`Could not get recurring audience list ${recurringAudienceListName}. ${errorMessage(error)}`);
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
try {
|
|
3831
|
+
const summary = summarizeMetadata(await client.getMetadata(propertyId));
|
|
3832
|
+
const audienceNameField = pickAvailableField(summary, "dimensions", ["audienceName"]);
|
|
3833
|
+
const activeUsersMetric = pickAvailableField(summary, "metrics", ["activeUsers"]) ?? "activeUsers";
|
|
3834
|
+
if (audienceNameField) {
|
|
3835
|
+
const report = await runReportRows(client, propertyId, {
|
|
3836
|
+
dateRanges: [dateRange],
|
|
3837
|
+
dimensions: toDimensions([audienceNameField]),
|
|
3838
|
+
metrics: toMetrics([activeUsersMetric]),
|
|
3839
|
+
orderBys: [{ metric: { metricName: activeUsersMetric }, desc: true }],
|
|
3840
|
+
limit
|
|
3841
|
+
});
|
|
3842
|
+
observedAudienceRows = report.rows;
|
|
3843
|
+
} else {
|
|
3844
|
+
warnings.push("audienceName was not available in Data API metadata, so observed audience membership fallback was skipped.");
|
|
3845
|
+
}
|
|
3846
|
+
} catch (error) {
|
|
3847
|
+
warnings.push(`Observed audienceName report fallback failed. ${errorMessage(error)}`);
|
|
3848
|
+
}
|
|
3849
|
+
return ok2({
|
|
3850
|
+
propertyId,
|
|
3851
|
+
dateRange,
|
|
3852
|
+
adminAudiences: audiences.map((audience) => summarizeAudience(audience, includeDefinitions)),
|
|
3853
|
+
recurringAudienceLists: recurringAudienceLists.map(summarizeRecurringAudienceList),
|
|
3854
|
+
selectedRecurringAudienceList,
|
|
3855
|
+
observedAudienceRows,
|
|
3856
|
+
counts: {
|
|
3857
|
+
adminAudiences: audiences.length,
|
|
3858
|
+
recurringAudienceLists: recurringAudienceLists.length,
|
|
3859
|
+
observedAudienceRows: observedAudienceRows.length
|
|
3860
|
+
},
|
|
3861
|
+
warnings,
|
|
3862
|
+
limitations: [
|
|
3863
|
+
"Admin API audiences.list is alpha and may omit pre-2020 or default audience filter definitions.",
|
|
3864
|
+
"Recurring Audience Lists are listed read-only; this server never calls recurringAudienceLists.create.",
|
|
3865
|
+
"The observed audienceName report is a traffic fallback, not an Admin configuration inventory."
|
|
3866
|
+
]
|
|
3867
|
+
});
|
|
3868
|
+
} catch (e) {
|
|
3869
|
+
return formatMcpToolError(e);
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
);
|
|
3873
|
+
server.tool(
|
|
3874
|
+
"ga4_get_bigquery_export_diagnostics",
|
|
3875
|
+
"Detect GA4 BigQuery export links and diagnose export modes, stream coverage, excluded events, and dataset location.",
|
|
3876
|
+
{
|
|
3877
|
+
propertyId: propertyIdSchema2,
|
|
3878
|
+
includeDataStreams: z3.boolean().optional().default(true).describe("Include data stream summaries to compare against BigQuery exportStreams")
|
|
3879
|
+
},
|
|
3880
|
+
async ({ propertyId, includeDataStreams }) => {
|
|
3881
|
+
try {
|
|
3882
|
+
const warnings = [];
|
|
3883
|
+
let links = [];
|
|
3884
|
+
let streams = [];
|
|
3885
|
+
try {
|
|
3886
|
+
links = await client.listBigQueryLinks(propertyId);
|
|
3887
|
+
} catch (error) {
|
|
3888
|
+
warnings.push(`Admin API bigQueryLinks.list was unavailable. ${errorMessage(error)}`);
|
|
3889
|
+
}
|
|
3890
|
+
if (includeDataStreams) {
|
|
3891
|
+
try {
|
|
3892
|
+
streams = await client.listDataStreams(propertyId);
|
|
3893
|
+
} catch (error) {
|
|
3894
|
+
warnings.push(`Admin API dataStreams.list was unavailable. ${errorMessage(error)}`);
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
const bigQueryLinks = links.map(summarizeBigQueryLink);
|
|
3898
|
+
const exportedStreamNames = new Set(bigQueryLinks.flatMap((link) => link.exportStreams));
|
|
3899
|
+
const dataStreams = streams.map(summarizeDataStream);
|
|
3900
|
+
const streamsNotExplicitlyExported = exportedStreamNames.size > 0 ? dataStreams.filter((stream) => stream.name && !exportedStreamNames.has(stream.name)) : [];
|
|
3901
|
+
if (bigQueryLinks.length === 0) {
|
|
3902
|
+
warnings.push("No BigQuery links were detected for this property.");
|
|
3903
|
+
}
|
|
3904
|
+
if (bigQueryLinks.some((link) => !link.dailyExportEnabled && !link.streamingExportEnabled && !link.freshDailyExportEnabled)) {
|
|
3905
|
+
warnings.push("At least one BigQuery link has no daily, streaming, or fresh daily export mode enabled.");
|
|
3906
|
+
}
|
|
3907
|
+
if (streamsNotExplicitlyExported.length > 0) {
|
|
3908
|
+
warnings.push(`${streamsNotExplicitlyExported.length} stream(s) were not present in explicit BigQuery exportStreams.`);
|
|
3909
|
+
}
|
|
3910
|
+
return ok2({
|
|
3911
|
+
propertyId,
|
|
3912
|
+
detected: bigQueryLinks.length > 0,
|
|
3913
|
+
bigQueryLinks,
|
|
3914
|
+
dataStreams,
|
|
3915
|
+
summary: {
|
|
3916
|
+
linkCount: bigQueryLinks.length,
|
|
3917
|
+
dailyExportLinks: bigQueryLinks.filter((link) => link.dailyExportEnabled).length,
|
|
3918
|
+
streamingExportLinks: bigQueryLinks.filter((link) => link.streamingExportEnabled).length,
|
|
3919
|
+
freshDailyExportLinks: bigQueryLinks.filter((link) => link.freshDailyExportEnabled).length,
|
|
3920
|
+
datasetLocations: Array.from(new Set(bigQueryLinks.map((link) => link.datasetLocation).filter(Boolean))),
|
|
3921
|
+
excludedEventCount: bigQueryLinks.reduce((total, link) => total + link.excludedEvents.length, 0),
|
|
3922
|
+
explicitExportStreamCount: exportedStreamNames.size,
|
|
3923
|
+
streamsNotExplicitlyExported: streamsNotExplicitlyExported.length
|
|
3924
|
+
},
|
|
3925
|
+
warnings,
|
|
3926
|
+
limitations: [
|
|
3927
|
+
"This detects GA4 Admin API BigQueryLink configuration only; it does not query BigQuery datasets or table freshness.",
|
|
3928
|
+
"When exportStreams is empty, Google may apply property-level defaults; stream coverage should be confirmed in GA4 Admin UI or BigQuery."
|
|
3929
|
+
]
|
|
3930
|
+
});
|
|
3931
|
+
} catch (e) {
|
|
3932
|
+
return formatMcpToolError(e);
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
);
|
|
3936
|
+
server.tool(
|
|
3937
|
+
"ga4_get_server_side_tagging_diagnostics",
|
|
3938
|
+
"Best-effort read-only server-side tagging diagnostics from data streams, Measurement Protocol secrets, event rules, and stream settings.",
|
|
3939
|
+
{
|
|
3940
|
+
propertyId: propertyIdSchema2,
|
|
3941
|
+
includeSettings: z3.boolean().optional().default(true).describe("Fetch enhanced measurement and data redaction settings for web streams"),
|
|
3942
|
+
includeRules: z3.boolean().optional().default(true).describe("Fetch event create/edit rules for web streams")
|
|
3943
|
+
},
|
|
3944
|
+
async ({ propertyId, includeSettings, includeRules }) => {
|
|
3945
|
+
try {
|
|
3946
|
+
const warnings = [];
|
|
3947
|
+
const streams = await client.listDataStreams(propertyId);
|
|
3948
|
+
const streamDiagnostics = [];
|
|
3949
|
+
for (const stream of streams) {
|
|
3950
|
+
const streamName = recordString(stream, "name");
|
|
3951
|
+
const streamSummary = summarizeDataStream(stream);
|
|
3952
|
+
const streamWarnings = [];
|
|
3953
|
+
let measurementProtocolSecrets = [];
|
|
3954
|
+
let enhancedMeasurementSettings = null;
|
|
3955
|
+
let dataRedactionSettings = null;
|
|
3956
|
+
let eventCreateRules = [];
|
|
3957
|
+
let eventEditRules = [];
|
|
3958
|
+
if (streamName) {
|
|
3959
|
+
try {
|
|
3960
|
+
measurementProtocolSecrets = await client.listMeasurementProtocolSecrets(streamName);
|
|
3961
|
+
} catch (error) {
|
|
3962
|
+
streamWarnings.push(`measurementProtocolSecrets.list unavailable. ${errorMessage(error)}`);
|
|
3963
|
+
}
|
|
3964
|
+
if (includeSettings && streamSummary.type === "WEB_DATA_STREAM") {
|
|
3965
|
+
try {
|
|
3966
|
+
enhancedMeasurementSettings = await client.getEnhancedMeasurementSettings(streamName);
|
|
3967
|
+
} catch (error) {
|
|
3968
|
+
streamWarnings.push(`enhancedMeasurementSettings unavailable. ${errorMessage(error)}`);
|
|
3969
|
+
}
|
|
3970
|
+
try {
|
|
3971
|
+
dataRedactionSettings = await client.getDataRedactionSettings(streamName);
|
|
3972
|
+
} catch (error) {
|
|
3973
|
+
streamWarnings.push(`dataRedactionSettings unavailable. ${errorMessage(error)}`);
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
if (includeRules && streamSummary.type === "WEB_DATA_STREAM") {
|
|
3977
|
+
try {
|
|
3978
|
+
eventCreateRules = await client.listEventCreateRules(streamName);
|
|
3979
|
+
} catch (error) {
|
|
3980
|
+
streamWarnings.push(`eventCreateRules.list unavailable. ${errorMessage(error)}`);
|
|
3981
|
+
}
|
|
3982
|
+
try {
|
|
3983
|
+
eventEditRules = await client.listEventEditRules(streamName);
|
|
3984
|
+
} catch (error) {
|
|
3985
|
+
streamWarnings.push(`eventEditRules.list unavailable. ${errorMessage(error)}`);
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
streamDiagnostics.push({
|
|
3990
|
+
...streamSummary,
|
|
3991
|
+
measurementProtocolSecrets,
|
|
3992
|
+
measurementProtocolSecretCount: measurementProtocolSecrets.length,
|
|
3993
|
+
enhancedMeasurementSettings,
|
|
3994
|
+
dataRedactionSettings,
|
|
3995
|
+
eventCreateRuleCount: eventCreateRules.length,
|
|
3996
|
+
eventEditRuleCount: eventEditRules.length,
|
|
3997
|
+
eventCreateRules,
|
|
3998
|
+
eventEditRules,
|
|
3999
|
+
warnings: streamWarnings
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
const measurementProtocolSecretCount = streamDiagnostics.reduce((total, stream) => {
|
|
4003
|
+
return total + (typeof stream.measurementProtocolSecretCount === "number" ? stream.measurementProtocolSecretCount : 0);
|
|
4004
|
+
}, 0);
|
|
4005
|
+
const eventRuleCount = streamDiagnostics.reduce((total, stream) => {
|
|
4006
|
+
const createRules = typeof stream.eventCreateRuleCount === "number" ? stream.eventCreateRuleCount : 0;
|
|
4007
|
+
const editRules = typeof stream.eventEditRuleCount === "number" ? stream.eventEditRuleCount : 0;
|
|
4008
|
+
return total + createRules + editRules;
|
|
4009
|
+
}, 0);
|
|
4010
|
+
if (measurementProtocolSecretCount === 0) {
|
|
4011
|
+
warnings.push("No Measurement Protocol secrets were detected. This does not prove server-side tagging is absent.");
|
|
4012
|
+
}
|
|
4013
|
+
warnings.push("GA4 Admin API does not expose GTM server container routing or tagging server URLs; server-side tagging is inferred, not confirmed.");
|
|
4014
|
+
return ok2({
|
|
4015
|
+
propertyId,
|
|
4016
|
+
streamDiagnostics,
|
|
4017
|
+
summary: {
|
|
4018
|
+
streamCount: streams.length,
|
|
4019
|
+
webStreamCount: streamDiagnostics.filter((stream) => stream.type === "WEB_DATA_STREAM").length,
|
|
4020
|
+
measurementProtocolSecretCount,
|
|
4021
|
+
eventRuleCount,
|
|
4022
|
+
possibleServerSideOrServerToServerSignals: measurementProtocolSecretCount > 0 || eventRuleCount > 0
|
|
4023
|
+
},
|
|
4024
|
+
warnings,
|
|
4025
|
+
safety: {
|
|
4026
|
+
measurementProtocolSecretValues: "Redacted before returning tool output."
|
|
4027
|
+
},
|
|
4028
|
+
limitations: [
|
|
4029
|
+
"Measurement Protocol secrets can support server-to-server collection, but are not proof of GTM server-side tagging.",
|
|
4030
|
+
"Direct GTM server container configuration is outside the GA4 Admin/Data APIs used by this read-only server."
|
|
4031
|
+
]
|
|
4032
|
+
});
|
|
4033
|
+
} catch (e) {
|
|
4034
|
+
return formatMcpToolError(e);
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
);
|
|
4038
|
+
server.tool(
|
|
4039
|
+
"ga4_run_advanced_funnel_report",
|
|
4040
|
+
"Run the GA4 Data API v1alpha runFunnelReport endpoint with optional breakdown/next-action, falling back to read-only step counts if unavailable.",
|
|
4041
|
+
{
|
|
4042
|
+
propertyId: propertyIdSchema2,
|
|
4043
|
+
steps: z3.array(advancedFunnelStepSchema).min(2).max(10).describe("Ordered funnel steps using eventName, pagePath, or fieldName+fieldValue"),
|
|
4044
|
+
isOpenFunnel: z3.boolean().optional().default(false),
|
|
4045
|
+
visualizationType: funnelVisualizationTypeSchema.optional().default("STANDARD_FUNNEL"),
|
|
4046
|
+
breakdownDimension: z3.string().optional().describe("Optional breakdown dimension, e.g. deviceCategory or sessionDefaultChannelGroup"),
|
|
4047
|
+
breakdownLimit: z3.number().int().min(1).max(50).optional().default(10),
|
|
4048
|
+
nextActionDimension: z3.string().optional().describe("Optional next action dimension, commonly eventName"),
|
|
4049
|
+
nextActionLimit: z3.number().int().min(1).max(50).optional().default(10),
|
|
4050
|
+
startDate: z3.string().optional().describe("Start date YYYY-MM-DD or relative (7daysAgo, yesterday)"),
|
|
4051
|
+
endDate: z3.string().optional().describe("End date YYYY-MM-DD or relative (today, yesterday)"),
|
|
4052
|
+
datePreset: datePresetSchema.optional().default("last28days"),
|
|
4053
|
+
limit: z3.number().int().min(1).max(25e4).optional().default(1e4),
|
|
4054
|
+
fallbackToStepCounts: z3.boolean().optional().default(true)
|
|
4055
|
+
},
|
|
4056
|
+
async ({
|
|
4057
|
+
propertyId,
|
|
4058
|
+
steps,
|
|
4059
|
+
isOpenFunnel,
|
|
4060
|
+
visualizationType,
|
|
4061
|
+
breakdownDimension,
|
|
4062
|
+
breakdownLimit,
|
|
4063
|
+
nextActionDimension,
|
|
4064
|
+
nextActionLimit,
|
|
4065
|
+
startDate,
|
|
4066
|
+
endDate,
|
|
4067
|
+
datePreset,
|
|
4068
|
+
limit,
|
|
4069
|
+
fallbackToStepCounts
|
|
4070
|
+
}) => {
|
|
4071
|
+
try {
|
|
4072
|
+
const dateRange = resolveInputDateRange(startDate, endDate, datePreset);
|
|
4073
|
+
const warnings = [];
|
|
4074
|
+
const request = {
|
|
4075
|
+
dateRanges: [dateRange],
|
|
4076
|
+
funnel: {
|
|
4077
|
+
isOpenFunnel,
|
|
4078
|
+
steps: steps.map((step, index) => ({
|
|
4079
|
+
name: step.name ?? step.eventName ?? step.pagePath ?? step.fieldName ?? `Step ${index + 1}`,
|
|
4080
|
+
...step.isDirectlyFollowedBy !== void 0 && { isDirectlyFollowedBy: step.isDirectlyFollowedBy },
|
|
4081
|
+
...step.withinSecondsFromPriorStep !== void 0 && index > 0 && { withinDurationFromPriorStep: `${step.withinSecondsFromPriorStep}s` },
|
|
4082
|
+
filterExpression: buildAdvancedFunnelFilterExpression(step)
|
|
4083
|
+
}))
|
|
4084
|
+
},
|
|
4085
|
+
funnelVisualizationType: visualizationType,
|
|
4086
|
+
limit: String(limit),
|
|
4087
|
+
returnPropertyQuota: true,
|
|
4088
|
+
...breakdownDimension && {
|
|
4089
|
+
funnelBreakdown: {
|
|
4090
|
+
breakdownDimension: { name: getDimensionByKey(breakdownDimension)?.apiField ?? breakdownDimension },
|
|
4091
|
+
limit: String(breakdownLimit)
|
|
4092
|
+
}
|
|
4093
|
+
},
|
|
4094
|
+
...nextActionDimension && {
|
|
4095
|
+
funnelNextAction: {
|
|
4096
|
+
nextActionDimension: { name: getDimensionByKey(nextActionDimension)?.apiField ?? nextActionDimension },
|
|
4097
|
+
limit: String(nextActionLimit)
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
};
|
|
4101
|
+
try {
|
|
4102
|
+
const response = await client.runFunnelReport(propertyId, request);
|
|
4103
|
+
return ok2({
|
|
4104
|
+
propertyId,
|
|
4105
|
+
dateRange,
|
|
4106
|
+
source: "data_api_v1alpha_runFunnelReport",
|
|
4107
|
+
funnelTable: flattenFunnelSubReport(client, response.funnelTable),
|
|
4108
|
+
funnelVisualization: flattenFunnelSubReport(client, response.funnelVisualization),
|
|
4109
|
+
rawResponse: response,
|
|
4110
|
+
request,
|
|
4111
|
+
warnings,
|
|
4112
|
+
limitations: [
|
|
4113
|
+
"runFunnelReport is a Data API v1alpha read-only report endpoint and may have alpha limitations.",
|
|
4114
|
+
"Returned rows are report aggregates; this tool does not mutate GA4 Explore reports or property configuration."
|
|
4115
|
+
]
|
|
4116
|
+
});
|
|
4117
|
+
} catch (error) {
|
|
4118
|
+
if (!fallbackToStepCounts) {
|
|
4119
|
+
throw error;
|
|
4120
|
+
}
|
|
4121
|
+
warnings.push(`runFunnelReport failed; used independent step-count fallback. ${errorMessage(error)}`);
|
|
4122
|
+
const fallbackSteps = await runAdvancedFunnelStepCountFallback(client, propertyId, dateRange, steps);
|
|
4123
|
+
return ok2({
|
|
4124
|
+
propertyId,
|
|
4125
|
+
dateRange,
|
|
4126
|
+
source: "data_api_runReport_step_count_fallback",
|
|
4127
|
+
steps: fallbackSteps,
|
|
4128
|
+
request,
|
|
4129
|
+
warnings,
|
|
4130
|
+
limitations: [
|
|
4131
|
+
"Fallback counts each step independently with activeUsers and does not reproduce GA4 Explore/runFunnelReport sequence semantics.",
|
|
4132
|
+
"Disable fallbackToStepCounts to surface runFunnelReport errors directly."
|
|
4133
|
+
]
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
} catch (e) {
|
|
4137
|
+
return formatMcpToolError(e);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
);
|
|
4141
|
+
server.tool(
|
|
4142
|
+
"ga4_get_channel_groups",
|
|
4143
|
+
"List custom channel groups defined for a GA4 property.",
|
|
4144
|
+
{ propertyId: propertyIdSchema2 },
|
|
4145
|
+
async ({ propertyId }) => {
|
|
4146
|
+
try {
|
|
4147
|
+
const groups = await client.listChannelGroups(propertyId);
|
|
4148
|
+
return ok2({ channelGroups: groups });
|
|
4149
|
+
} catch (e) {
|
|
4150
|
+
return formatMcpToolError(e);
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
);
|
|
4154
|
+
server.tool(
|
|
4155
|
+
"ga4_validate_query",
|
|
4156
|
+
"Validate a metric/dimension combination BEFORE executing. Checks max limits (9 dims, 10 metrics), ecommerce rules, and dimension compatibility.",
|
|
4157
|
+
{
|
|
4158
|
+
metrics: z3.array(z3.string()).min(1).describe("Metric keys to validate"),
|
|
4159
|
+
dimensions: z3.array(z3.string()).optional().describe("Dimension keys to validate")
|
|
4160
|
+
},
|
|
4161
|
+
async ({ metrics, dimensions }) => {
|
|
4162
|
+
try {
|
|
4163
|
+
const result = validateGA4QuerySelection(metrics, dimensions ?? []);
|
|
4164
|
+
return ok2({
|
|
4165
|
+
...result,
|
|
4166
|
+
debug: {
|
|
4167
|
+
requestCount: 0
|
|
4168
|
+
}
|
|
4169
|
+
});
|
|
4170
|
+
} catch (e) {
|
|
4171
|
+
return formatMcpToolError(e);
|
|
4172
|
+
}
|
|
4173
|
+
}
|
|
4174
|
+
);
|
|
4175
|
+
registerGA4SurfaceTools(server, client);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
// src/platforms/ga4/resources.ts
|
|
4179
|
+
function registerGA4Resources(server) {
|
|
4180
|
+
server.resource("ga4-metrics", "ga4://metrics", async () => ({
|
|
4181
|
+
contents: [{
|
|
4182
|
+
uri: "ga4://metrics",
|
|
4183
|
+
mimeType: "application/json",
|
|
4184
|
+
text: JSON.stringify(GA4_METRIC_CATALOG.map((m) => ({
|
|
4185
|
+
key: m.key,
|
|
4186
|
+
name: m.name,
|
|
4187
|
+
description: m.description,
|
|
4188
|
+
category: m.category,
|
|
4189
|
+
type: m.type,
|
|
4190
|
+
format: m.format,
|
|
4191
|
+
apiField: m.apiField
|
|
4192
|
+
})), null, 2)
|
|
4193
|
+
}]
|
|
4194
|
+
}));
|
|
4195
|
+
server.resource("ga4-dimensions", "ga4://dimensions", async () => ({
|
|
4196
|
+
contents: [{
|
|
4197
|
+
uri: "ga4://dimensions",
|
|
4198
|
+
mimeType: "application/json",
|
|
4199
|
+
text: JSON.stringify(GA4_DIMENSION_CATALOG.map((d) => ({
|
|
4200
|
+
key: d.key,
|
|
4201
|
+
name: d.name,
|
|
4202
|
+
description: d.description,
|
|
4203
|
+
category: d.category,
|
|
4204
|
+
apiField: d.apiField
|
|
4205
|
+
})), null, 2)
|
|
4206
|
+
}]
|
|
4207
|
+
}));
|
|
4208
|
+
server.resource("ga4-compatibility", "ga4://compatibility", async () => ({
|
|
4209
|
+
contents: [{
|
|
4210
|
+
uri: "ga4://compatibility",
|
|
4211
|
+
mimeType: "application/json",
|
|
4212
|
+
text: JSON.stringify({
|
|
4213
|
+
description: "GA4 constraints: max 9 dimensions + 10 metrics per request. Ecommerce dimensions require ecommerce metrics.",
|
|
4214
|
+
maxDimensions: 9,
|
|
4215
|
+
maxMetrics: 10,
|
|
4216
|
+
maxRowsPerCoreRequest: 25e4,
|
|
4217
|
+
maxReportsPerBatch: 5,
|
|
4218
|
+
maxPivotCells: 25e4,
|
|
4219
|
+
dateFormats: ["YYYY-MM-DD", "7daysAgo", "yesterday", "today"]
|
|
4220
|
+
}, null, 2)
|
|
4221
|
+
}]
|
|
4222
|
+
}));
|
|
4223
|
+
server.resource("ga4-manifest", "ga4://manifest", async () => ({
|
|
4224
|
+
contents: [{
|
|
4225
|
+
uri: "ga4://manifest",
|
|
4226
|
+
mimeType: "application/json",
|
|
4227
|
+
text: JSON.stringify({
|
|
4228
|
+
name: "GA4 MCP",
|
|
4229
|
+
platform: "google_analytics_4",
|
|
4230
|
+
readOnly: true,
|
|
4231
|
+
authentication: {
|
|
4232
|
+
type: "google_oauth_refresh_token",
|
|
4233
|
+
tokensExposedByTools: false
|
|
4234
|
+
},
|
|
4235
|
+
tools: [
|
|
4236
|
+
{ name: "ga4_health_check", purpose: "Verify credentials, list properties, and test property/metadata access" },
|
|
4237
|
+
{ name: "ga4_list_properties", purpose: "List accessible GA4 properties" },
|
|
4238
|
+
{ name: "ga4_run_report", purpose: "Run standard GA4 Data API reports" },
|
|
4239
|
+
{ name: "ga4_run_pivot_report", purpose: "Run native GA4 Data API pivot reports" },
|
|
4240
|
+
{ name: "ga4_batch_run_reports", purpose: "Run up to five Core reports in one official batch request" },
|
|
4241
|
+
{ name: "ga4_batch_run_pivot_reports", purpose: "Run up to five pivot reports in one official batch request" },
|
|
4242
|
+
{ name: "ga4_check_compatibility", purpose: "Check official Core dimension/metric compatibility" },
|
|
4243
|
+
{ name: "ga4_get_property_quotas_snapshot", purpose: "Read the current Data API property quota snapshot" },
|
|
4244
|
+
{ name: "ga4_run_realtime_report", purpose: "Run GA4 Data API realtime reports with limited realtime-compatible fields" },
|
|
4245
|
+
{ name: "ga4_get_metadata", purpose: "Return property metadata dimensions and metrics" },
|
|
4246
|
+
{ name: "ga4_get_custom_definitions", purpose: "Inventory reportable custom dimensions and metrics from metadata" },
|
|
4247
|
+
{ name: "ga4_get_key_events", purpose: "Best-effort key event/conversion inventory from reports" },
|
|
4248
|
+
{ name: "ga4_get_ecommerce_diagnostics", purpose: "Diagnose ecommerce event, revenue, and item coverage" },
|
|
4249
|
+
{ name: "ga4_get_event_parameters", purpose: "Approximate event parameter inventory from registered custom definitions" },
|
|
4250
|
+
{ name: "ga4_run_funnel_recipe", purpose: "Run simple read-only event/pagePath funnel step counts" },
|
|
4251
|
+
{ name: "ga4_get_audience_export_diagnostics", purpose: "List existing Audience Exports and inspect state/readiness without creating exports" },
|
|
4252
|
+
{ name: "ga4_get_audience_diagnostics", purpose: "List Admin audiences, recurring audience lists, and observed audienceName report fallback" },
|
|
4253
|
+
{ name: "ga4_get_bigquery_export_diagnostics", purpose: "Detect BigQuery export links, export modes, stream coverage, and excluded events" },
|
|
4254
|
+
{ name: "ga4_get_server_side_tagging_diagnostics", purpose: "Infer server-side/server-to-server tagging signals from data streams and Measurement Protocol secrets" },
|
|
4255
|
+
{ name: "ga4_run_advanced_funnel_report", purpose: "Run Data API v1alpha funnel reports with breakdown/next-action and read-only fallback" },
|
|
4256
|
+
{ name: "ga4_get_channel_groups", purpose: "List custom channel groups" },
|
|
4257
|
+
{ name: "ga4_list_accounts", purpose: "List raw accessible Analytics Admin accounts" },
|
|
4258
|
+
{ name: "ga4_list_admin_resources", purpose: "List allowlisted property Admin collections" },
|
|
4259
|
+
{ name: "ga4_get_property_configuration", purpose: "Read property details and singleton Admin settings" },
|
|
4260
|
+
{ name: "ga4_list_audience_exports", purpose: "List existing audience exports and recurring audience lists" },
|
|
4261
|
+
{ name: "ga4_query_audience_export", purpose: "Query rows from an existing audience export" },
|
|
4262
|
+
{ name: "ga4_validate_query", purpose: "Validate report metric/dimension combinations" }
|
|
4263
|
+
],
|
|
4264
|
+
resources: ["ga4://metrics", "ga4://dimensions", "ga4://compatibility", "ga4://manifest", "ga4://recipes", "ga4://p2-diagnostics"],
|
|
4265
|
+
safety: [
|
|
4266
|
+
"No write or mutate endpoints are registered.",
|
|
4267
|
+
"OAuth access and refresh tokens are never returned in tool responses.",
|
|
4268
|
+
"Measurement Protocol secret values are redacted before tool output.",
|
|
4269
|
+
"Audience Export and access-binding personal identifiers are redacted unless explicitly opted in.",
|
|
4270
|
+
"Diagnostics are best-effort read-only views over Data API/Admin read endpoints."
|
|
4271
|
+
]
|
|
4272
|
+
}, null, 2)
|
|
4273
|
+
}]
|
|
4274
|
+
}));
|
|
4275
|
+
server.resource("ga4-recipes", "ga4://recipes", async () => ({
|
|
4276
|
+
contents: [{
|
|
4277
|
+
uri: "ga4://recipes",
|
|
4278
|
+
mimeType: "application/json",
|
|
4279
|
+
text: JSON.stringify({
|
|
4280
|
+
recipes: [
|
|
4281
|
+
{
|
|
4282
|
+
name: "Account readiness check",
|
|
4283
|
+
tool: "ga4_health_check",
|
|
4284
|
+
input: { propertyId: "123456789" },
|
|
4285
|
+
useWhen: "Before querying a new credential set or property."
|
|
4286
|
+
},
|
|
4287
|
+
{
|
|
4288
|
+
name: "Realtime traffic snapshot",
|
|
4289
|
+
tool: "ga4_run_realtime_report",
|
|
4290
|
+
input: { propertyId: "123456789", metrics: ["activeUsers"], dimensions: ["eventName"], limit: 25 },
|
|
4291
|
+
useWhen: "To inspect live activity with realtime-compatible fields."
|
|
4292
|
+
},
|
|
4293
|
+
{
|
|
4294
|
+
name: "Key event inventory",
|
|
4295
|
+
tool: "ga4_get_key_events",
|
|
4296
|
+
input: { propertyId: "123456789", datePreset: "last28days", limit: 100 },
|
|
4297
|
+
useWhen: "To identify key events/conversions currently receiving traffic."
|
|
4298
|
+
},
|
|
4299
|
+
{
|
|
4300
|
+
name: "Ecommerce implementation diagnostics",
|
|
4301
|
+
tool: "ga4_get_ecommerce_diagnostics",
|
|
4302
|
+
input: { propertyId: "123456789", datePreset: "last28days", limit: 100 },
|
|
4303
|
+
useWhen: "To check view_item, add_to_cart, begin_checkout, purchase, revenue, and item coverage."
|
|
4304
|
+
},
|
|
4305
|
+
{
|
|
4306
|
+
name: "Registered event parameters",
|
|
4307
|
+
tool: "ga4_get_event_parameters",
|
|
4308
|
+
input: { propertyId: "123456789", eventNames: ["purchase", "add_to_cart"], datePreset: "last28days" },
|
|
4309
|
+
useWhen: "To discover reportable custom event parameters and sample event volume."
|
|
4310
|
+
},
|
|
4311
|
+
{
|
|
4312
|
+
name: "Simple ecommerce funnel",
|
|
4313
|
+
tool: "ga4_run_funnel_recipe",
|
|
4314
|
+
input: {
|
|
4315
|
+
propertyId: "123456789",
|
|
4316
|
+
metric: "activeUsers",
|
|
4317
|
+
datePreset: "last28days",
|
|
4318
|
+
steps: [
|
|
4319
|
+
{ name: "Product views", eventName: "view_item" },
|
|
4320
|
+
{ name: "Cart adds", eventName: "add_to_cart" },
|
|
4321
|
+
{ name: "Checkout starts", eventName: "begin_checkout" },
|
|
4322
|
+
{ name: "Purchases", eventName: "purchase" }
|
|
4323
|
+
]
|
|
4324
|
+
},
|
|
4325
|
+
useWhen: "To get quick step counts without GA4 Explore funnel semantics."
|
|
4326
|
+
},
|
|
4327
|
+
{
|
|
4328
|
+
name: "Advanced funnel with device breakdown",
|
|
4329
|
+
tool: "ga4_run_advanced_funnel_report",
|
|
4330
|
+
input: {
|
|
4331
|
+
propertyId: "123456789",
|
|
4332
|
+
datePreset: "last28days",
|
|
4333
|
+
breakdownDimension: "deviceCategory",
|
|
4334
|
+
steps: [
|
|
4335
|
+
{ name: "Landing", pagePath: "/", matchType: "EXACT" },
|
|
4336
|
+
{ name: "Signup start", eventName: "sign_up_start" },
|
|
4337
|
+
{ name: "Signup complete", eventName: "sign_up" }
|
|
4338
|
+
]
|
|
4339
|
+
},
|
|
4340
|
+
useWhen: "To use GA4 Data API funnel semantics with optional fallback to independent step counts."
|
|
4341
|
+
},
|
|
4342
|
+
{
|
|
4343
|
+
name: "Pivoted country/device performance",
|
|
4344
|
+
tool: "ga4_run_pivot_report",
|
|
4345
|
+
input: {
|
|
4346
|
+
propertyId: "123456789",
|
|
4347
|
+
report: {
|
|
4348
|
+
dateRanges: [{ startDate: "28daysAgo", endDate: "today" }],
|
|
4349
|
+
dimensions: ["country", "deviceCategory"],
|
|
4350
|
+
metrics: ["sessions", "totalRevenue"],
|
|
4351
|
+
pivots: [{ fieldNames: ["country"], limit: 20 }, { fieldNames: ["deviceCategory"], limit: 3 }]
|
|
4352
|
+
}
|
|
4353
|
+
},
|
|
4354
|
+
useWhen: "To return a native multidimensional pivot and property quota state."
|
|
4355
|
+
},
|
|
4356
|
+
{
|
|
4357
|
+
name: "Audience readiness",
|
|
4358
|
+
tool: "ga4_get_audience_diagnostics",
|
|
4359
|
+
input: { propertyId: "123456789", datePreset: "last28days", limit: 100 },
|
|
4360
|
+
useWhen: "To inspect Admin audiences, recurring audience lists, and observed audienceName traffic."
|
|
4361
|
+
},
|
|
4362
|
+
{
|
|
4363
|
+
name: "Audience export readiness",
|
|
4364
|
+
tool: "ga4_get_audience_export_diagnostics",
|
|
4365
|
+
input: { propertyId: "123456789", pageSize: 100 },
|
|
4366
|
+
useWhen: "To find existing Audience Exports and their ACTIVE/FAILED/CREATING states without creating new exports."
|
|
4367
|
+
},
|
|
4368
|
+
{
|
|
4369
|
+
name: "BigQuery export detection",
|
|
4370
|
+
tool: "ga4_get_bigquery_export_diagnostics",
|
|
4371
|
+
input: { propertyId: "123456789", includeDataStreams: true },
|
|
4372
|
+
useWhen: "To confirm whether GA4 BigQuery export is configured and which export modes are enabled."
|
|
4373
|
+
},
|
|
4374
|
+
{
|
|
4375
|
+
name: "Server-side tagging inference",
|
|
4376
|
+
tool: "ga4_get_server_side_tagging_diagnostics",
|
|
4377
|
+
input: { propertyId: "123456789", includeSettings: true, includeRules: true },
|
|
4378
|
+
useWhen: "To infer server-side/server-to-server collection signals while keeping Measurement Protocol secret values redacted."
|
|
4379
|
+
}
|
|
4380
|
+
],
|
|
4381
|
+
notes: [
|
|
4382
|
+
"Use ga4://metrics and ga4://dimensions for standard report fields.",
|
|
4383
|
+
"Use ga4_get_metadata or ga4_get_custom_definitions for property-specific custom fields.",
|
|
4384
|
+
"Funnel recipes are independent step counts, not sequence-aware user path analysis.",
|
|
4385
|
+
"Advanced funnel reports use the GA4 Data API v1alpha runFunnelReport endpoint and can fall back to independent step counts.",
|
|
4386
|
+
"Audience Export and Recurring Audience List diagnostics are read-only and never create new lists.",
|
|
4387
|
+
"ga4_list_admin_resources is an allowlisted GET-only explorer over Admin API v1beta/v1alpha collections.",
|
|
4388
|
+
"Batch Data API methods accept at most five reports for one property, matching the official API contract.",
|
|
4389
|
+
"Core report and pivot-cell limits are 250,000; every pivot must include a limit."
|
|
4390
|
+
]
|
|
4391
|
+
}, null, 2)
|
|
4392
|
+
}]
|
|
4393
|
+
}));
|
|
4394
|
+
server.resource("ga4-p2-diagnostics", "ga4://p2-diagnostics", async () => ({
|
|
4395
|
+
contents: [{
|
|
4396
|
+
uri: "ga4://p2-diagnostics",
|
|
4397
|
+
mimeType: "application/json",
|
|
4398
|
+
text: JSON.stringify({
|
|
4399
|
+
readOnlyP2Tools: [
|
|
4400
|
+
{
|
|
4401
|
+
name: "ga4_get_audience_export_diagnostics",
|
|
4402
|
+
endpoints: [
|
|
4403
|
+
"GET analyticsdata.googleapis.com/v1beta/properties/{property}/audienceExports",
|
|
4404
|
+
"GET analyticsdata.googleapis.com/v1beta/properties/{property}/audienceExports/{audienceExport}",
|
|
4405
|
+
"POST analyticsdata.googleapis.com/v1beta/properties/{property}/audienceExports/{audienceExport}:query (optional read-only sample)"
|
|
4406
|
+
],
|
|
4407
|
+
noCreateEndpointCalled: true
|
|
4408
|
+
},
|
|
4409
|
+
{
|
|
4410
|
+
name: "ga4_get_audience_diagnostics",
|
|
4411
|
+
endpoints: [
|
|
4412
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/audiences",
|
|
4413
|
+
"GET analyticsdata.googleapis.com/v1alpha/properties/{property}/recurringAudienceLists",
|
|
4414
|
+
"POST analyticsdata.googleapis.com/v1beta/properties/{property}:runReport for audienceName fallback"
|
|
4415
|
+
],
|
|
4416
|
+
noCreateEndpointCalled: true
|
|
4417
|
+
},
|
|
4418
|
+
{
|
|
4419
|
+
name: "ga4_get_bigquery_export_diagnostics",
|
|
4420
|
+
endpoints: [
|
|
4421
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/bigQueryLinks",
|
|
4422
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams"
|
|
4423
|
+
]
|
|
4424
|
+
},
|
|
4425
|
+
{
|
|
4426
|
+
name: "ga4_get_server_side_tagging_diagnostics",
|
|
4427
|
+
endpoints: [
|
|
4428
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams",
|
|
4429
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams/{stream}/measurementProtocolSecrets",
|
|
4430
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams/{stream}/enhancedMeasurementSettings",
|
|
4431
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams/{stream}/dataRedactionSettings",
|
|
4432
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams/{stream}/eventCreateRules",
|
|
4433
|
+
"GET analyticsadmin.googleapis.com/v1alpha/properties/{property}/dataStreams/{stream}/eventEditRules"
|
|
4434
|
+
],
|
|
4435
|
+
secretHandling: "measurementProtocolSecrets.secretValue is redacted"
|
|
4436
|
+
},
|
|
4437
|
+
{
|
|
4438
|
+
name: "ga4_run_advanced_funnel_report",
|
|
4439
|
+
endpoints: [
|
|
4440
|
+
"POST analyticsdata.googleapis.com/v1alpha/properties/{property}:runFunnelReport",
|
|
4441
|
+
"POST analyticsdata.googleapis.com/v1beta/properties/{property}:runReport fallback when enabled"
|
|
4442
|
+
]
|
|
4443
|
+
}
|
|
4444
|
+
],
|
|
4445
|
+
agentNotes: [
|
|
4446
|
+
"Prefer diagnostics tools before asking to create GA4 assets; this server has no mutate tools.",
|
|
4447
|
+
"Treat alpha Admin/Data API fields as best-effort and inspect warnings in every response.",
|
|
4448
|
+
"Use includeRowSample on Audience Exports only when user-level export rows are explicitly needed."
|
|
4449
|
+
]
|
|
4450
|
+
}, null, 2)
|
|
4451
|
+
}]
|
|
4452
|
+
}));
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
// src/platforms/ga4/index.ts
|
|
4456
|
+
function registerGA4(server, config) {
|
|
4457
|
+
registerGA4Tools(server, config);
|
|
4458
|
+
registerGA4Resources(server);
|
|
4459
|
+
logger.info("ga4", "Registered 27 read tools and 6 resources");
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
// src/server.ts
|
|
4463
|
+
var PACKAGE_VERSION = "1.0.0";
|
|
4464
|
+
function createServer(config) {
|
|
4465
|
+
const server = new McpServer(
|
|
4466
|
+
{ name: "google-analytics-mcp", version: PACKAGE_VERSION },
|
|
4467
|
+
{ capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true } } }
|
|
4468
|
+
);
|
|
4469
|
+
registerGA4(server, config);
|
|
4470
|
+
logger.system(`google-analytics-mcp v${PACKAGE_VERSION} ready, read-only`);
|
|
4471
|
+
return server;
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
// src/cli.ts
|
|
4475
|
+
async function main() {
|
|
4476
|
+
try {
|
|
4477
|
+
const config = loadConfig();
|
|
4478
|
+
logger.setLevel(config.logLevel);
|
|
4479
|
+
const server = createServer(config);
|
|
4480
|
+
await server.connect(new StdioServerTransport());
|
|
4481
|
+
logger.system("Stdio transport connected");
|
|
4482
|
+
} catch (error) {
|
|
4483
|
+
logger.error("cli", "Fatal error", error instanceof Error ? error.message : error);
|
|
4484
|
+
process.exit(1);
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
main();
|
|
4488
|
+
//# sourceMappingURL=cli.js.map
|