@rawdash/connector-google-analytics 0.1.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/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @rawdash/connector-google-analytics
2
+
3
+ Rawdash connector for Google Analytics 4 — syncs traffic, source/medium attribution, top pages, events, conversions, and geo data via the [GA4 Data API](https://developers.google.com/analytics/devguides/reporting/data/v1).
4
+
5
+ ## Auth setup
6
+
7
+ The connector supports two authentication methods. **Service account JSON** is recommended for server-side use.
8
+
9
+ ### Option A — Service account (recommended)
10
+
11
+ 1. Open [Google Cloud Console](https://console.cloud.google.com) and select (or create) your project.
12
+ 2. Navigate to **IAM & Admin → Service Accounts** and click **Create Service Account**.
13
+ 3. Give it a name (e.g. `rawdash-ga4-reader`) and click **Create and continue**.
14
+ 4. Skip the optional role assignment on this screen — click **Done**.
15
+ 5. In Google Analytics, go to **Admin → Account Access Management** and add the service account email with the **Viewer** role.
16
+ 6. Back in Cloud Console, open the service account → **Keys** → **Add key → Create new key → JSON**.
17
+ 7. Download the `.json` file and store its contents as the secret `GA_SERVICE_ACCOUNT_JSON`.
18
+
19
+ ### Option B — OAuth refresh token
20
+
21
+ 1. Create an OAuth 2.0 client ID in [Google Cloud Console](https://console.cloud.google.com) → **APIs & Services → Credentials → Create Credentials → OAuth client ID** (type: Web application).
22
+ 2. Run the OAuth consent flow with scope `https://www.googleapis.com/auth/analytics.readonly`.
23
+ 3. Exchange the authorization code for a refresh token using the token endpoint.
24
+ 4. Store the refresh token as `GA_REFRESH_TOKEN`, the client ID as `GA_CLIENT_ID`, and the client secret as `GA_CLIENT_SECRET`.
25
+
26
+ ## Configuration
27
+
28
+ ```ts
29
+ import { GA4Connector } from '@rawdash/connector-google-analytics';
30
+ import { secret } from '@rawdash/core';
31
+
32
+ // Service account auth
33
+ const ga4 = new GA4Connector(
34
+ { propertyId: '123456789' },
35
+ { serviceAccountJson: secret('GA_SERVICE_ACCOUNT_JSON') },
36
+ );
37
+
38
+ // OAuth auth
39
+ const ga4 = new GA4Connector(
40
+ { propertyId: '123456789' },
41
+ {
42
+ refreshToken: secret('GA_REFRESH_TOKEN'),
43
+ clientId: process.env['GA_CLIENT_ID']!,
44
+ clientSecret: secret('GA_CLIENT_SECRET'),
45
+ },
46
+ );
47
+ ```
48
+
49
+ Or using `GA4Connector.create` (validates via `configFields` Zod schema):
50
+
51
+ ```ts
52
+ const { connector: ga4 } = GA4Connector.create({
53
+ propertyId: '123456789',
54
+ serviceAccountJson: { $secret: 'GA_SERVICE_ACCOUNT_JSON' },
55
+ // lookbackDays: 90, // optional, default 90 for full sync
56
+ });
57
+ ```
58
+
59
+ Then wire it into `defineConfig`:
60
+
61
+ ```ts
62
+ import { defineConfig, defineDashboard, defineMetric } from '@rawdash/core';
63
+
64
+ export default defineConfig({
65
+ connectors: [{ connector: ga4 }],
66
+ dashboards: {
67
+ marketing: defineDashboard({
68
+ widgets: {
69
+ sessions_today: {
70
+ kind: 'stat',
71
+ title: 'Sessions today',
72
+ metric: defineMetric({
73
+ connector: ga4,
74
+ shape: 'metric',
75
+ name: 'ga4_traffic_by_day',
76
+ field: 'sessions',
77
+ fn: 'sum',
78
+ window: '1d',
79
+ }),
80
+ },
81
+ sessions_over_time: {
82
+ kind: 'timeseries',
83
+ title: 'Sessions over time',
84
+ window: '30d',
85
+ metric: defineMetric({
86
+ connector: ga4,
87
+ shape: 'metric',
88
+ name: 'ga4_traffic_by_day',
89
+ field: 'sessions',
90
+ fn: 'sum',
91
+ window: '30d',
92
+ groupBy: { field: 'ts', granularity: 'day' },
93
+ }),
94
+ },
95
+ traffic_by_source: {
96
+ kind: 'distribution',
97
+ title: 'Traffic by source',
98
+ metric: defineMetric({
99
+ connector: ga4,
100
+ shape: 'metric',
101
+ name: 'ga4_traffic_by_source',
102
+ field: 'sessions',
103
+ fn: 'sum',
104
+ window: '30d',
105
+ }),
106
+ },
107
+ },
108
+ }),
109
+ },
110
+ });
111
+ ```
112
+
113
+ ## Data model
114
+
115
+ All resources are stored as **metric samples** (`shape: 'metric'`). The `ts` field is the date in Unix milliseconds. All GA4 dimensions and metrics are available as attributes on each sample.
116
+
117
+ | Metric name | Dimensions | Metrics (attributes) |
118
+ | ----------------------- | ---------------------------------- | --------------------------------------------------------------- |
119
+ | `ga4_traffic_by_day` | date | sessions, totalUsers, newUsers, screenPageViews, engagementRate |
120
+ | `ga4_traffic_by_source` | date, sessionSource, sessionMedium | sessions, conversions |
121
+ | `ga4_top_pages` | date, pagePath | screenPageViews, averageSessionDuration |
122
+ | `ga4_events` | date, eventName | eventCount, totalUsers |
123
+ | `ga4_conversions` | date, eventName | conversions, totalRevenue |
124
+ | `ga4_geo` | date, country | sessions, totalUsers |
125
+
126
+ The `value` field of each metric sample contains the first metric in the table above (e.g. `sessions` for `ga4_traffic_by_day`). All other metrics are accessible via attribute names.
127
+
128
+ ## Sync behaviour
129
+
130
+ - **Backfill** (`mode: 'full'`): fetches a rolling window (default 90 days, configurable via `lookbackDays`) for all six resources.
131
+ - **Incremental** (`mode: 'latest'`): fetches the trailing 30 days to catch late-arriving attribution data (GA4 can attribute conversions up to 3 days after the session).
132
+ - Both modes **clear existing metric data** for each resource before re-inserting, preventing duplicate rows from accumulating across sync runs.
133
+ - **Pagination**: uses the GA4 Data API `offset`/`limit` model with 10 000 rows per page. Interrupted syncs return a cursor and resume from the same phase and offset.
134
+ - **Rate limits**: the GA4 Data API quota is 200 000 tokens/day per property (default). 429 responses are handled automatically by the built-in HTTP client with exponential back-off.
135
+
136
+ ## Registering in the MCP server
137
+
138
+ ```ts
139
+ import {
140
+ GA4Connector,
141
+ configFields,
142
+ } from '@rawdash/connector-google-analytics';
143
+
144
+ createMcpServer({
145
+ // ...
146
+ connectorFactories: [
147
+ {
148
+ id: 'google-analytics',
149
+ configFields,
150
+ create: GA4Connector.create,
151
+ },
152
+ ],
153
+ });
154
+ ```
@@ -0,0 +1,87 @@
1
+ import { BaseConnector, SyncOptions, StorageHandle, SyncResult } from '@rawdash/core';
2
+ import { z } from 'zod';
3
+
4
+ declare const configFields: z.ZodObject<{
5
+ propertyId: z.ZodString;
6
+ serviceAccountJson: z.ZodOptional<z.ZodObject<{
7
+ $secret: z.ZodString;
8
+ }, z.core.$strip>>;
9
+ refreshToken: z.ZodOptional<z.ZodObject<{
10
+ $secret: z.ZodString;
11
+ }, z.core.$strip>>;
12
+ clientId: z.ZodOptional<z.ZodString>;
13
+ clientSecret: z.ZodOptional<z.ZodObject<{
14
+ $secret: z.ZodString;
15
+ }, z.core.$strip>>;
16
+ lookbackDays: z.ZodOptional<z.ZodNumber>;
17
+ }, z.core.$strip>;
18
+ interface GA4Settings {
19
+ propertyId: string;
20
+ lookbackDays?: number;
21
+ }
22
+ declare const ga4Credentials: {
23
+ serviceAccountJson: {
24
+ description: string;
25
+ auth: "optional";
26
+ };
27
+ refreshToken: {
28
+ description: string;
29
+ auth: "optional";
30
+ };
31
+ clientId: {
32
+ description: string;
33
+ auth: "optional";
34
+ };
35
+ clientSecret: {
36
+ description: string;
37
+ auth: "optional";
38
+ };
39
+ };
40
+ type GA4Credentials = typeof ga4Credentials;
41
+ interface GA4DimensionValue {
42
+ value: string;
43
+ }
44
+ interface GA4MetricValue {
45
+ value: string;
46
+ }
47
+ interface GA4ReportRow {
48
+ dimensionValues: GA4DimensionValue[];
49
+ metricValues: GA4MetricValue[];
50
+ }
51
+ declare function rowToMetricSample(row: GA4ReportRow, dimensionHeaders: string[], metricHeaders: string[], metricName: string): {
52
+ name: string;
53
+ ts: number;
54
+ value: number;
55
+ attributes: Record<string, string | number>;
56
+ };
57
+ declare class GA4Connector extends BaseConnector<GA4Settings, GA4Credentials> {
58
+ static readonly id = "google-analytics";
59
+ static create(input: unknown): {
60
+ connector: GA4Connector;
61
+ };
62
+ readonly id = "google-analytics";
63
+ readonly credentials: {
64
+ serviceAccountJson: {
65
+ description: string;
66
+ auth: "optional";
67
+ };
68
+ refreshToken: {
69
+ description: string;
70
+ auth: "optional";
71
+ };
72
+ clientId: {
73
+ description: string;
74
+ auth: "optional";
75
+ };
76
+ clientSecret: {
77
+ description: string;
78
+ auth: "optional";
79
+ };
80
+ };
81
+ private cachedToken;
82
+ private getAccessToken;
83
+ private runReport;
84
+ sync(options: SyncOptions, storage: StorageHandle, signal?: AbortSignal): Promise<SyncResult>;
85
+ }
86
+
87
+ export { GA4Connector, type GA4Settings, configFields, rowToMetricSample };
package/dist/index.js ADDED
@@ -0,0 +1,665 @@
1
+ // ../../connector-shared/dist/index.js
2
+ var HttpClientError = class extends Error {
3
+ response;
4
+ constructor(message, response) {
5
+ super(message);
6
+ this.name = new.target.name;
7
+ this.response = response;
8
+ }
9
+ };
10
+ var TransientError = class extends HttpClientError {
11
+ kind = "transient";
12
+ };
13
+ var RateLimitError = class extends HttpClientError {
14
+ kind = "rate_limit";
15
+ retryAfter;
16
+ constructor(message, response, retryAfter) {
17
+ super(message, response);
18
+ this.retryAfter = retryAfter;
19
+ }
20
+ };
21
+ var AuthError = class extends HttpClientError {
22
+ kind = "auth";
23
+ };
24
+ var UpstreamBugError = class extends HttpClientError {
25
+ kind = "upstream_bug";
26
+ };
27
+ var ClientBugError = class extends HttpClientError {
28
+ kind = "client_bug";
29
+ };
30
+ function classifyStatus(status) {
31
+ if (status === 429) {
32
+ return "rate_limit";
33
+ }
34
+ if (status === 401 || status === 403) {
35
+ return "auth";
36
+ }
37
+ if (status === 408) {
38
+ return "transient";
39
+ }
40
+ if (status >= 500) {
41
+ return "upstream_bug";
42
+ }
43
+ if (status >= 400) {
44
+ return "client_bug";
45
+ }
46
+ return "client_bug";
47
+ }
48
+ function errorForStatus(message, response, retryAfter) {
49
+ const kind = classifyStatus(response.status);
50
+ switch (kind) {
51
+ case "rate_limit":
52
+ return new RateLimitError(message, response, retryAfter);
53
+ case "auth":
54
+ return new AuthError(message, response);
55
+ case "transient":
56
+ return new TransientError(message, response);
57
+ case "upstream_bug":
58
+ return new UpstreamBugError(message, response);
59
+ case "client_bug":
60
+ return new ClientBugError(message, response);
61
+ }
62
+ }
63
+ var defaultRetryOn = (status, err) => {
64
+ if (err instanceof RateLimitError) {
65
+ return true;
66
+ }
67
+ if (err instanceof TransientError) {
68
+ return true;
69
+ }
70
+ if (status === null) {
71
+ return err instanceof Error && !(err instanceof HttpClientError);
72
+ }
73
+ if (status === 408 || status === 429) {
74
+ return true;
75
+ }
76
+ if (status >= 500) {
77
+ return true;
78
+ }
79
+ return false;
80
+ };
81
+ function parseRetryAfter(headerValue, now = /* @__PURE__ */ new Date()) {
82
+ if (!headerValue) {
83
+ return void 0;
84
+ }
85
+ const trimmed = headerValue.trim();
86
+ if (/^\d+$/.test(trimmed)) {
87
+ return new Date(now.getTime() + Number(trimmed) * 1e3);
88
+ }
89
+ const parsed = Date.parse(trimmed);
90
+ if (Number.isNaN(parsed)) {
91
+ return void 0;
92
+ }
93
+ return new Date(parsed);
94
+ }
95
+ function sleep(ms, signal) {
96
+ if (signal?.aborted) {
97
+ return Promise.reject(signal.reason ?? new Error("Aborted"));
98
+ }
99
+ return new Promise((resolve, reject) => {
100
+ const onAbort = () => {
101
+ clearTimeout(timer);
102
+ reject(signal.reason ?? new Error("Aborted"));
103
+ };
104
+ const timer = setTimeout(() => {
105
+ signal?.removeEventListener("abort", onAbort);
106
+ resolve();
107
+ }, ms);
108
+ signal?.addEventListener("abort", onAbort, { once: true });
109
+ });
110
+ }
111
+ var HTTP_CLIENT_VERSION = "0.0.0";
112
+ var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
113
+ var DEFAULT_TIMEOUT_MS = 1e4;
114
+ var DEFAULT_MAX_ATTEMPTS = 3;
115
+ var DEFAULT_INITIAL_DELAY_MS = 1e3;
116
+ var DEFAULT_MAX_DELAY_MS = 6e4;
117
+ function mergeHeaders(defaults, overrides) {
118
+ const merged = {};
119
+ for (const [k, v] of Object.entries(defaults)) {
120
+ merged[k.toLowerCase()] = v;
121
+ }
122
+ if (overrides) {
123
+ for (const [k, v] of Object.entries(overrides)) {
124
+ merged[k.toLowerCase()] = v;
125
+ }
126
+ }
127
+ return merged;
128
+ }
129
+ function linkTimeoutSignal(parent, timeoutMs) {
130
+ const controller = new AbortController();
131
+ const onParentAbort = () => {
132
+ controller.abort(parent?.reason);
133
+ };
134
+ if (parent) {
135
+ if (parent.aborted) {
136
+ controller.abort(parent.reason);
137
+ } else {
138
+ parent.addEventListener("abort", onParentAbort, { once: true });
139
+ }
140
+ }
141
+ const timer = setTimeout(() => {
142
+ controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));
143
+ }, timeoutMs);
144
+ return {
145
+ signal: controller.signal,
146
+ cancel: () => {
147
+ clearTimeout(timer);
148
+ if (parent) {
149
+ parent.removeEventListener("abort", onParentAbort);
150
+ }
151
+ }
152
+ };
153
+ }
154
+ async function readBody(res, parseJson) {
155
+ if (res.status === 204 || res.status === 205) {
156
+ return null;
157
+ }
158
+ const contentType = res.headers.get("content-type") ?? "";
159
+ if (parseJson && contentType.includes("application/json")) {
160
+ const text = await res.text();
161
+ if (text.length === 0) {
162
+ return null;
163
+ }
164
+ return JSON.parse(text);
165
+ }
166
+ return res.text();
167
+ }
168
+ async function request(req, options = {}) {
169
+ const fetchImpl = options.fetch ?? globalThis.fetch;
170
+ const retry = req.retry ?? {};
171
+ const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
172
+ const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
173
+ const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
174
+ const retryOn = retry.retryOn ?? defaultRetryOn;
175
+ const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
176
+ const parseJson = req.parseJson ?? true;
177
+ const headers = mergeHeaders(
178
+ {
179
+ "User-Agent": DEFAULT_USER_AGENT,
180
+ Accept: "application/json"
181
+ },
182
+ req.headers
183
+ );
184
+ let lastErr;
185
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
186
+ req.signal?.throwIfAborted();
187
+ const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);
188
+ let res;
189
+ try {
190
+ res = await fetchImpl(req.url, {
191
+ method: req.method ?? "GET",
192
+ headers,
193
+ body: req.body,
194
+ signal
195
+ });
196
+ } catch (err2) {
197
+ cancel();
198
+ if (req.signal?.aborted) {
199
+ throw req.signal.reason ?? err2;
200
+ }
201
+ const error = err2 instanceof Error ? err2 : new Error(String(err2));
202
+ lastErr = error;
203
+ if (attempt < maxAttempts - 1 && retryOn(null, error)) {
204
+ const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);
205
+ await sleep(delay, req.signal);
206
+ continue;
207
+ }
208
+ throw new TransientError(error.message);
209
+ }
210
+ cancel();
211
+ const body = await readBody(res, parseJson);
212
+ const httpResponse = {
213
+ status: res.status,
214
+ headers: res.headers,
215
+ body
216
+ };
217
+ if (req.rateLimit) {
218
+ const state = req.rateLimit.parse(res.headers);
219
+ if (state) {
220
+ httpResponse.rateLimitState = state;
221
+ }
222
+ }
223
+ if (res.ok) {
224
+ return httpResponse;
225
+ }
226
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
227
+ const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? "GET"} ${req.url}`;
228
+ const err = errorForStatus(message, httpResponse, retryAfter);
229
+ if (attempt < maxAttempts - 1 && retryOn(res.status, err) && !(err instanceof AuthError) && !(err instanceof ClientBugError)) {
230
+ lastErr = err;
231
+ let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);
232
+ if (err instanceof RateLimitError && retryAfter) {
233
+ const wait = retryAfter.getTime() - Date.now();
234
+ if (wait > 0) {
235
+ delay = Math.min(wait, maxDelayMs);
236
+ }
237
+ }
238
+ await sleep(delay, req.signal);
239
+ continue;
240
+ }
241
+ throw err;
242
+ }
243
+ throw lastErr ?? new UpstreamBugError("Exhausted retry attempts");
244
+ }
245
+ function computeDelay(attempt, initialDelayMs, maxDelayMs) {
246
+ const base = initialDelayMs * 2 ** attempt;
247
+ const jitter = base * 0.25 * Math.random();
248
+ return Math.min(base + jitter, maxDelayMs);
249
+ }
250
+
251
+ // src/google-analytics.ts
252
+ import {
253
+ BaseConnector,
254
+ defineConfigFields,
255
+ paginateChunked
256
+ } from "@rawdash/core";
257
+ import { z } from "zod";
258
+ var configFields = defineConfigFields(
259
+ z.object({
260
+ propertyId: z.string().regex(/^\d+$/, "GA4 Property ID must be digits only").meta({
261
+ label: "GA4 Property ID",
262
+ description: "Numeric ID of your GA4 property (e.g. 123456789). Find it in Google Analytics \u2192 Admin \u2192 Property settings.",
263
+ placeholder: "123456789"
264
+ }),
265
+ serviceAccountJson: z.object({ $secret: z.string() }).optional().meta({
266
+ label: "Service Account JSON (recommended)",
267
+ description: "Contents of the JSON key file for a Google service account with the Analytics Viewer role. Create one at Google Cloud \u2192 IAM & Admin \u2192 Service Accounts.",
268
+ secret: true
269
+ }),
270
+ refreshToken: z.object({ $secret: z.string() }).optional().meta({
271
+ label: "OAuth Refresh Token",
272
+ description: "Google OAuth 2.0 refresh token with analytics.readonly scope. Required if not using serviceAccountJson.",
273
+ secret: true
274
+ }),
275
+ clientId: z.string().optional().meta({
276
+ label: "OAuth Client ID",
277
+ description: "OAuth 2.0 client ID from Google Cloud Console. Required when using refreshToken auth.",
278
+ placeholder: "\u2026apps.googleusercontent.com"
279
+ }),
280
+ clientSecret: z.object({ $secret: z.string() }).optional().meta({
281
+ label: "OAuth Client Secret",
282
+ description: "OAuth 2.0 client secret from Google Cloud Console. Required when using refreshToken auth.",
283
+ secret: true
284
+ }),
285
+ lookbackDays: z.number().int().positive().optional().meta({
286
+ label: "Lookback days (full sync)",
287
+ description: "How many calendar days to fetch on a full sync. Defaults to 90.",
288
+ placeholder: "90"
289
+ })
290
+ }).refine(
291
+ (val) => val.serviceAccountJson !== void 0 || val.refreshToken !== void 0 && val.clientId !== void 0 && val.clientSecret !== void 0,
292
+ {
293
+ message: "Provide either serviceAccountJson or the full OAuth tuple (refreshToken + clientId + clientSecret)"
294
+ }
295
+ )
296
+ );
297
+ var ga4Credentials = {
298
+ serviceAccountJson: {
299
+ description: "Google service account JSON key (base64 or raw JSON)",
300
+ auth: "optional"
301
+ },
302
+ refreshToken: {
303
+ description: "Google OAuth 2.0 refresh token",
304
+ auth: "optional"
305
+ },
306
+ clientId: {
307
+ description: "Google OAuth 2.0 client ID",
308
+ auth: "optional"
309
+ },
310
+ clientSecret: {
311
+ description: "Google OAuth 2.0 client secret",
312
+ auth: "optional"
313
+ }
314
+ };
315
+ var PHASE_ORDER = [
316
+ "traffic_by_day",
317
+ "traffic_by_source",
318
+ "top_pages",
319
+ "events",
320
+ "conversions",
321
+ "geo"
322
+ ];
323
+ function isGA4Page(value) {
324
+ if (typeof value !== "object" || value === null) {
325
+ return false;
326
+ }
327
+ const v = value;
328
+ return typeof v.offset === "number" && typeof v.startDate === "string" && typeof v.endDate === "string";
329
+ }
330
+ function isGA4SyncCursor(value) {
331
+ if (typeof value !== "object" || value === null) {
332
+ return false;
333
+ }
334
+ const v = value;
335
+ if (typeof v.phase !== "string") {
336
+ return false;
337
+ }
338
+ if (!PHASE_ORDER.includes(v.phase)) {
339
+ return false;
340
+ }
341
+ if (v.page !== null && !isGA4Page(v.page)) {
342
+ return false;
343
+ }
344
+ return true;
345
+ }
346
+ var PHASE_CONFIGS = {
347
+ traffic_by_day: {
348
+ dimensions: ["date"],
349
+ metrics: [
350
+ "sessions",
351
+ "totalUsers",
352
+ "newUsers",
353
+ "screenPageViews",
354
+ "engagementRate"
355
+ ],
356
+ metricName: "ga4_traffic_by_day"
357
+ },
358
+ traffic_by_source: {
359
+ dimensions: ["date", "sessionSource", "sessionMedium"],
360
+ metrics: ["sessions", "conversions"],
361
+ metricName: "ga4_traffic_by_source"
362
+ },
363
+ top_pages: {
364
+ dimensions: ["date", "pagePath"],
365
+ metrics: ["screenPageViews", "averageSessionDuration"],
366
+ metricName: "ga4_top_pages"
367
+ },
368
+ events: {
369
+ dimensions: ["date", "eventName"],
370
+ metrics: ["eventCount", "totalUsers"],
371
+ metricName: "ga4_events"
372
+ },
373
+ conversions: {
374
+ dimensions: ["date", "eventName"],
375
+ metrics: ["conversions", "totalRevenue"],
376
+ metricName: "ga4_conversions"
377
+ },
378
+ geo: {
379
+ dimensions: ["date", "country"],
380
+ metrics: ["sessions", "totalUsers"],
381
+ metricName: "ga4_geo"
382
+ }
383
+ };
384
+ var ROWS_PER_PAGE = 1e4;
385
+ function base64urlFromBytes(bytes) {
386
+ let binary = "";
387
+ for (let i = 0; i < bytes.length; i++) {
388
+ binary += String.fromCharCode(bytes[i]);
389
+ }
390
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
391
+ }
392
+ function base64urlFromString(str) {
393
+ return base64urlFromBytes(new TextEncoder().encode(str));
394
+ }
395
+ async function signRS256JWT(payload, privateKeyPem) {
396
+ const header = { alg: "RS256", typ: "JWT" };
397
+ const headerB64 = base64urlFromString(JSON.stringify(header));
398
+ const payloadB64 = base64urlFromString(JSON.stringify(payload));
399
+ const signingInput = `${headerB64}.${payloadB64}`;
400
+ const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
401
+ const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
402
+ const key = await globalThis.crypto.subtle.importKey(
403
+ "pkcs8",
404
+ der,
405
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
406
+ false,
407
+ ["sign"]
408
+ );
409
+ const signature = await globalThis.crypto.subtle.sign(
410
+ "RSASSA-PKCS1-v1_5",
411
+ key,
412
+ new TextEncoder().encode(signingInput)
413
+ );
414
+ return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
415
+ }
416
+ function parseServiceAccountJson(value) {
417
+ const trimmed = value.trim();
418
+ if (trimmed.startsWith("{")) {
419
+ return JSON.parse(trimmed);
420
+ }
421
+ const binary = atob(trimmed);
422
+ const bytes = new Uint8Array(binary.length);
423
+ for (let i = 0; i < binary.length; i++) {
424
+ bytes[i] = binary.charCodeAt(i);
425
+ }
426
+ const decoded = new TextDecoder().decode(bytes);
427
+ return JSON.parse(decoded);
428
+ }
429
+ async function fetchServiceAccountToken(serviceAccountJson, signal) {
430
+ const sa = parseServiceAccountJson(serviceAccountJson);
431
+ const now = Math.floor(Date.now() / 1e3);
432
+ const jwt = await signRS256JWT(
433
+ {
434
+ iss: sa.client_email,
435
+ scope: "https://www.googleapis.com/auth/analytics.readonly",
436
+ aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
437
+ exp: now + 3600,
438
+ iat: now
439
+ },
440
+ sa.private_key
441
+ );
442
+ const body = new URLSearchParams({
443
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
444
+ assertion: jwt
445
+ }).toString();
446
+ const res = await request({
447
+ url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
448
+ method: "POST",
449
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
450
+ body,
451
+ signal
452
+ });
453
+ const expiresIn = res.body.expires_in ?? 3600;
454
+ return {
455
+ token: res.body.access_token,
456
+ expiresAt: Date.now() + (expiresIn - 60) * 1e3
457
+ };
458
+ }
459
+ async function fetchRefreshToken(refreshToken, clientId, clientSecret, signal) {
460
+ const body = new URLSearchParams({
461
+ grant_type: "refresh_token",
462
+ refresh_token: refreshToken,
463
+ client_id: clientId,
464
+ client_secret: clientSecret
465
+ }).toString();
466
+ const res = await request({
467
+ url: "https://oauth2.googleapis.com/token",
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
470
+ body,
471
+ signal
472
+ });
473
+ const expiresIn = res.body.expires_in ?? 3600;
474
+ return {
475
+ token: res.body.access_token,
476
+ expiresAt: Date.now() + (expiresIn - 60) * 1e3
477
+ };
478
+ }
479
+ function toGA4Date(date) {
480
+ const y = date.getUTCFullYear();
481
+ const m = String(date.getUTCMonth() + 1).padStart(2, "0");
482
+ const d = String(date.getUTCDate()).padStart(2, "0");
483
+ return `${y}-${m}-${d}`;
484
+ }
485
+ function ga4DateToMs(ga4Date) {
486
+ const y = ga4Date.slice(0, 4);
487
+ const m = ga4Date.slice(4, 6);
488
+ const d = ga4Date.slice(6, 8);
489
+ return Date.UTC(Number(y), Number(m) - 1, Number(d));
490
+ }
491
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
492
+ var INCREMENTAL_LOOKBACK_DAYS = 30;
493
+ function getDateRange(options, lookbackDays) {
494
+ const now = Date.now();
495
+ const endDate = toGA4Date(new Date(now));
496
+ const days = options.mode === "latest" && options.since ? INCREMENTAL_LOOKBACK_DAYS : lookbackDays;
497
+ const startMs = now - (days - 1) * MS_PER_DAY;
498
+ return { startDate: toGA4Date(new Date(startMs)), endDate };
499
+ }
500
+ function rowToMetricSample(row, dimensionHeaders, metricHeaders, metricName) {
501
+ const dims = {};
502
+ for (let i = 0; i < dimensionHeaders.length; i++) {
503
+ dims[dimensionHeaders[i]] = row.dimensionValues[i]?.value ?? "";
504
+ }
505
+ const mets = {};
506
+ for (let i = 0; i < metricHeaders.length; i++) {
507
+ mets[metricHeaders[i]] = parseFloat(row.metricValues[i]?.value ?? "0") || 0;
508
+ }
509
+ const dateStr = dims["date"] ?? "19700101";
510
+ const ts = ga4DateToMs(dateStr);
511
+ const primaryValue = mets[metricHeaders[0]] ?? 0;
512
+ return {
513
+ name: metricName,
514
+ ts,
515
+ value: primaryValue,
516
+ attributes: { ...dims, ...mets }
517
+ };
518
+ }
519
+ var GA4Connector = class _GA4Connector extends BaseConnector {
520
+ static id = "google-analytics";
521
+ static create(input) {
522
+ const parsed = configFields.parse(input);
523
+ return {
524
+ connector: new _GA4Connector(
525
+ {
526
+ propertyId: parsed.propertyId,
527
+ lookbackDays: parsed.lookbackDays
528
+ },
529
+ {
530
+ serviceAccountJson: parsed.serviceAccountJson,
531
+ refreshToken: parsed.refreshToken,
532
+ clientId: parsed.clientId,
533
+ clientSecret: parsed.clientSecret
534
+ }
535
+ )
536
+ };
537
+ }
538
+ id = "google-analytics";
539
+ credentials = ga4Credentials;
540
+ cachedToken = null;
541
+ async getAccessToken(signal) {
542
+ if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {
543
+ return this.cachedToken.token;
544
+ }
545
+ const { serviceAccountJson, refreshToken, clientId, clientSecret } = this.creds;
546
+ if (serviceAccountJson) {
547
+ this.cachedToken = await fetchServiceAccountToken(
548
+ serviceAccountJson,
549
+ signal
550
+ );
551
+ return this.cachedToken.token;
552
+ }
553
+ if (refreshToken && clientId && clientSecret) {
554
+ this.cachedToken = await fetchRefreshToken(
555
+ refreshToken,
556
+ clientId,
557
+ clientSecret,
558
+ signal
559
+ );
560
+ return this.cachedToken.token;
561
+ }
562
+ throw new Error(
563
+ "GA4 connector: provide either serviceAccountJson or (refreshToken + clientId + clientSecret)"
564
+ );
565
+ }
566
+ async runReport(accessToken, phase, dateRange, offset, signal) {
567
+ const { dimensions, metrics } = PHASE_CONFIGS[phase];
568
+ const url = `https://analyticsdata.googleapis.com/v1beta/properties/${this.settings.propertyId}:runReport`;
569
+ const body = {
570
+ dimensions: dimensions.map((name) => ({ name })),
571
+ metrics: metrics.map((name) => ({ name })),
572
+ dateRanges: [
573
+ { startDate: dateRange.startDate, endDate: dateRange.endDate }
574
+ ],
575
+ limit: ROWS_PER_PAGE,
576
+ offset
577
+ };
578
+ const req = {
579
+ url,
580
+ method: "POST",
581
+ headers: {
582
+ Authorization: `Bearer ${accessToken}`,
583
+ "Content-Type": "application/json",
584
+ "User-Agent": "rawdash/connector-google-analytics (+https://rawdash.dev)"
585
+ },
586
+ body: JSON.stringify(body),
587
+ signal
588
+ };
589
+ const res = await request(req);
590
+ return res.body;
591
+ }
592
+ async sync(options, storage, signal) {
593
+ const lookbackDays = this.settings.lookbackDays ?? 90;
594
+ const cursor = isGA4SyncCursor(options.cursor) ? options.cursor : void 0;
595
+ const resumeDateRange = cursor?.page && isGA4Page(cursor.page) ? { startDate: cursor.page.startDate, endDate: cursor.page.endDate } : null;
596
+ const dateRange = resumeDateRange ?? getDateRange(options, lookbackDays);
597
+ let accessToken = null;
598
+ const getToken = async (sig) => {
599
+ if (!accessToken) {
600
+ accessToken = await this.getAccessToken(sig);
601
+ }
602
+ return accessToken;
603
+ };
604
+ const clearedPhases = /* @__PURE__ */ new Set();
605
+ return paginateChunked({
606
+ phases: PHASE_ORDER,
607
+ cursor,
608
+ signal,
609
+ fetchPage: async (phase, page, sig) => {
610
+ const token = await getToken(sig);
611
+ const offset = page?.offset ?? 0;
612
+ let response;
613
+ try {
614
+ response = await this.runReport(token, phase, dateRange, offset, sig);
615
+ } catch (err) {
616
+ console.warn(
617
+ `[ga4] runReport failed, refreshing token and retrying once`,
618
+ err
619
+ );
620
+ accessToken = null;
621
+ const freshToken = await getToken(sig);
622
+ response = await this.runReport(
623
+ freshToken,
624
+ phase,
625
+ dateRange,
626
+ offset,
627
+ sig
628
+ );
629
+ }
630
+ const rows = response.rows ?? [];
631
+ const totalRows = response.rowCount ?? 0;
632
+ const nextOffset = offset + rows.length;
633
+ const next = rows.length > 0 && nextOffset < totalRows ? {
634
+ offset: nextOffset,
635
+ startDate: dateRange.startDate,
636
+ endDate: dateRange.endDate
637
+ } : null;
638
+ return { items: rows, next };
639
+ },
640
+ writeBatch: async (phase, items, page) => {
641
+ const cfg = PHASE_CONFIGS[phase];
642
+ if (page === null && !clearedPhases.has(phase)) {
643
+ clearedPhases.add(phase);
644
+ await storage.metrics([], { names: [cfg.metricName] });
645
+ }
646
+ const rows = items;
647
+ for (const row of rows) {
648
+ const sample = rowToMetricSample(
649
+ row,
650
+ cfg.dimensions,
651
+ cfg.metrics,
652
+ cfg.metricName
653
+ );
654
+ await storage.metric(sample);
655
+ }
656
+ }
657
+ });
658
+ }
659
+ };
660
+ export {
661
+ GA4Connector,
662
+ configFields,
663
+ rowToMetricSample
664
+ };
665
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../connector-shared/src/errors.ts","../../../connector-shared/src/retry.ts","../../../connector-shared/src/version.ts","../../../connector-shared/src/request.ts","../../../connector-shared/src/rate-limit.ts","../../../connector-shared/src/pagination.ts","../src/google-analytics.ts"],"sourcesContent":["import type { HttpResponse } from './types';\n\nexport type HttpErrorKind =\n | 'transient'\n | 'rate_limit'\n | 'auth'\n | 'upstream_bug'\n | 'client_bug';\n\nexport abstract class HttpClientError extends Error {\n abstract readonly kind: HttpErrorKind;\n readonly response?: HttpResponse;\n\n constructor(message: string, response?: HttpResponse) {\n super(message);\n this.name = new.target.name;\n this.response = response;\n }\n}\n\nexport class TransientError extends HttpClientError {\n readonly kind = 'transient' as const;\n}\n\nexport class RateLimitError extends HttpClientError {\n readonly kind = 'rate_limit' as const;\n readonly retryAfter?: Date;\n\n constructor(message: string, response?: HttpResponse, retryAfter?: Date) {\n super(message, response);\n this.retryAfter = retryAfter;\n }\n}\n\nexport class AuthError extends HttpClientError {\n readonly kind = 'auth' as const;\n}\n\nexport class UpstreamBugError extends HttpClientError {\n readonly kind = 'upstream_bug' as const;\n}\n\nexport class ClientBugError extends HttpClientError {\n readonly kind = 'client_bug' as const;\n}\n\nexport function classifyStatus(status: number): HttpErrorKind {\n if (status === 429) {\n return 'rate_limit';\n }\n if (status === 401 || status === 403) {\n return 'auth';\n }\n if (status === 408) {\n return 'transient';\n }\n if (status >= 500) {\n return 'upstream_bug';\n }\n if (status >= 400) {\n return 'client_bug';\n }\n return 'client_bug';\n}\n\nexport function errorForStatus(\n message: string,\n response: HttpResponse,\n retryAfter?: Date,\n): HttpClientError {\n const kind = classifyStatus(response.status);\n switch (kind) {\n case 'rate_limit':\n return new RateLimitError(message, response, retryAfter);\n case 'auth':\n return new AuthError(message, response);\n case 'transient':\n return new TransientError(message, response);\n case 'upstream_bug':\n return new UpstreamBugError(message, response);\n case 'client_bug':\n return new ClientBugError(message, response);\n }\n}\n","import { HttpClientError, RateLimitError, TransientError } from './errors';\n\nexport interface RetryPolicy {\n maxAttempts?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n retryOn?: (status: number | null, err?: Error) => boolean;\n}\n\nexport const defaultRetryOn = (status: number | null, err?: Error): boolean => {\n if (err instanceof RateLimitError) {\n return true;\n }\n if (err instanceof TransientError) {\n return true;\n }\n if (status === null) {\n return err instanceof Error && !(err instanceof HttpClientError);\n }\n if (status === 408 || status === 429) {\n return true;\n }\n if (status >= 500) {\n return true;\n }\n return false;\n};\n\nexport function backoffDelayMs(\n attempt: number,\n policy: Required<Pick<RetryPolicy, 'initialDelayMs' | 'maxDelayMs'>>,\n): number {\n const base = policy.initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, policy.maxDelayMs);\n}\n\nexport function parseRetryAfter(\n headerValue: string | null,\n now: Date = new Date(),\n): Date | undefined {\n if (!headerValue) {\n return undefined;\n }\n const trimmed = headerValue.trim();\n if (/^\\d+$/.test(trimmed)) {\n return new Date(now.getTime() + Number(trimmed) * 1000);\n }\n const parsed = Date.parse(trimmed);\n if (Number.isNaN(parsed)) {\n return undefined;\n }\n return new Date(parsed);\n}\n\nexport function sleep(ms: number, signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) {\n return Promise.reject(signal.reason ?? new Error('Aborted'));\n }\n return new Promise<void>((resolve, reject) => {\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal!.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n","export const HTTP_CLIENT_VERSION = '0.0.0';\n\nexport const DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;\n","import {\n AuthError,\n ClientBugError,\n HttpClientError,\n RateLimitError,\n TransientError,\n UpstreamBugError,\n errorForStatus,\n} from './errors';\nimport { defaultRetryOn, parseRetryAfter, sleep } from './retry';\nimport type { FetchLike, HttpRequest, HttpResponse } from './types';\nimport { DEFAULT_USER_AGENT } from './version';\n\nconst DEFAULT_TIMEOUT_MS = 10_000;\nconst DEFAULT_MAX_ATTEMPTS = 3;\nconst DEFAULT_INITIAL_DELAY_MS = 1000;\nconst DEFAULT_MAX_DELAY_MS = 60_000;\n\nexport interface RequestOptions {\n fetch?: FetchLike;\n}\n\nfunction mergeHeaders(\n defaults: Record<string, string>,\n overrides: Record<string, string> | undefined,\n): Record<string, string> {\n const merged: Record<string, string> = {};\n for (const [k, v] of Object.entries(defaults)) {\n merged[k.toLowerCase()] = v;\n }\n if (overrides) {\n for (const [k, v] of Object.entries(overrides)) {\n merged[k.toLowerCase()] = v;\n }\n }\n return merged;\n}\n\nfunction linkTimeoutSignal(\n parent: AbortSignal | undefined,\n timeoutMs: number,\n): { signal: AbortSignal; cancel: () => void } {\n const controller = new AbortController();\n const onParentAbort = () => {\n controller.abort(parent?.reason);\n };\n if (parent) {\n if (parent.aborted) {\n controller.abort(parent.reason);\n } else {\n parent.addEventListener('abort', onParentAbort, { once: true });\n }\n }\n const timer = setTimeout(() => {\n controller.abort(new Error(`Request timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n return {\n signal: controller.signal,\n cancel: () => {\n clearTimeout(timer);\n if (parent) {\n parent.removeEventListener('abort', onParentAbort);\n }\n },\n };\n}\n\nasync function readBody(res: Response, parseJson: boolean): Promise<unknown> {\n if (res.status === 204 || res.status === 205) {\n return null;\n }\n const contentType = res.headers.get('content-type') ?? '';\n if (parseJson && contentType.includes('application/json')) {\n const text = await res.text();\n if (text.length === 0) {\n return null;\n }\n return JSON.parse(text);\n }\n return res.text();\n}\n\nexport async function request<T = unknown>(\n req: HttpRequest,\n options: RequestOptions = {},\n): Promise<HttpResponse<T>> {\n const fetchImpl: FetchLike = options.fetch ?? (globalThis.fetch as FetchLike);\n const retry = req.retry ?? {};\n const maxAttempts = retry.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;\n const initialDelayMs = retry.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;\n const maxDelayMs = retry.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n const parseJson = req.parseJson ?? true;\n\n const headers = mergeHeaders(\n {\n 'User-Agent': DEFAULT_USER_AGENT,\n Accept: 'application/json',\n },\n req.headers,\n );\n\n let lastErr: Error | undefined;\n\n for (let attempt = 0; attempt < maxAttempts; attempt++) {\n req.signal?.throwIfAborted();\n\n const { signal, cancel } = linkTimeoutSignal(req.signal, timeoutMs);\n let res: Response;\n try {\n res = await fetchImpl(req.url, {\n method: req.method ?? 'GET',\n headers,\n body: req.body as RequestInit['body'],\n signal,\n });\n } catch (err) {\n cancel();\n if (req.signal?.aborted) {\n throw req.signal.reason ?? err;\n }\n const error = err instanceof Error ? err : new Error(String(err));\n lastErr = error;\n if (attempt < maxAttempts - 1 && retryOn(null, error)) {\n const delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n await sleep(delay, req.signal);\n continue;\n }\n throw new TransientError(error.message);\n }\n cancel();\n\n const body = await readBody(res, parseJson);\n const httpResponse: HttpResponse<T> = {\n status: res.status,\n headers: res.headers,\n body: body as T,\n };\n if (req.rateLimit) {\n const state = req.rateLimit.parse(res.headers);\n if (state) {\n httpResponse.rateLimitState = state;\n }\n }\n\n if (res.ok) {\n return httpResponse;\n }\n\n const retryAfter = parseRetryAfter(res.headers.get('retry-after'));\n const message = `HTTP ${res.status} ${res.statusText} for ${req.method ?? 'GET'} ${req.url}`;\n const err = errorForStatus(message, httpResponse, retryAfter);\n\n if (\n attempt < maxAttempts - 1 &&\n retryOn(res.status, err) &&\n !(err instanceof AuthError) &&\n !(err instanceof ClientBugError)\n ) {\n lastErr = err;\n let delay = computeDelay(attempt, initialDelayMs, maxDelayMs);\n if (err instanceof RateLimitError && retryAfter) {\n const wait = retryAfter.getTime() - Date.now();\n if (wait > 0) {\n delay = Math.min(wait, maxDelayMs);\n }\n }\n await sleep(delay, req.signal);\n continue;\n }\n\n throw err;\n }\n\n throw lastErr ?? new UpstreamBugError('Exhausted retry attempts');\n}\n\nfunction computeDelay(\n attempt: number,\n initialDelayMs: number,\n maxDelayMs: number,\n): number {\n const base = initialDelayMs * 2 ** attempt;\n const jitter = base * 0.25 * Math.random();\n return Math.min(base + jitter, maxDelayMs);\n}\n\nexport { HttpClientError };\n","export interface RateLimitState {\n remaining: number;\n resetAt: Date;\n}\n\nexport interface RateLimitPolicy {\n parse(headers: Headers): RateLimitState | null;\n}\n\nexport const githubRateLimit: RateLimitPolicy = {\n parse(h) {\n const remainingRaw = h.get('x-ratelimit-remaining');\n const resetRaw = h.get('x-ratelimit-reset');\n if (remainingRaw === null || resetRaw === null) {\n return null;\n }\n const remaining = Number(remainingRaw);\n const reset = Number(resetRaw);\n if (!Number.isFinite(remaining) || !Number.isFinite(reset) || reset < 0) {\n return null;\n }\n return { remaining, resetAt: new Date(reset * 1000) };\n },\n};\n\nexport const sentryRateLimit: RateLimitPolicy = {\n parse(h) {\n const concurrent = h.get('x-sentry-rate-limit-remaining');\n const reset = h.get('x-sentry-rate-limit-reset');\n if (concurrent === null || reset === null) {\n return null;\n }\n const remaining = Number(concurrent);\n const resetSec = Number(reset);\n if (\n !Number.isFinite(remaining) ||\n !Number.isFinite(resetSec) ||\n resetSec < 0\n ) {\n return null;\n }\n return { remaining, resetAt: new Date(resetSec * 1000) };\n },\n};\n\nexport const linearRateLimit: RateLimitPolicy = {\n parse(h) {\n const remainingRaw = h.get('x-ratelimit-requests-remaining');\n const resetRaw = h.get('x-ratelimit-requests-reset');\n if (remainingRaw === null) {\n return null;\n }\n const remaining = Number(remainingRaw);\n if (!Number.isFinite(remaining)) {\n return null;\n }\n let resetAt: Date;\n if (resetRaw !== null) {\n const reset = Number(resetRaw);\n if (!Number.isFinite(reset) || reset < 0) {\n return null;\n }\n resetAt = new Date(reset);\n } else {\n resetAt = new Date(Date.now() + 60_000);\n }\n return { remaining, resetAt };\n },\n};\n","import { request } from './request';\nimport type { HttpRequest } from './types';\n\nexport function parseLinkHeader(header: string | null): Record<string, string> {\n if (!header) {\n return {};\n }\n const result: Record<string, string> = {};\n for (const part of header.split(',')) {\n const match = part.match(/<([^>]+)>\\s*;\\s*rel=\"([^\"]+)\"/);\n if (match) {\n result[match[2]!] = match[1]!;\n }\n }\n return result;\n}\n\nexport async function* paginateLink<T>(\n initial: HttpRequest,\n parse: (body: unknown) => T[],\n): AsyncIterable<T> {\n let next: string | null = initial.url;\n while (next) {\n const res: Awaited<ReturnType<typeof request>> = await request({\n ...initial,\n url: next,\n });\n for (const item of parse(res.body)) {\n yield item;\n }\n const links = parseLinkHeader(res.headers.get('link'));\n next = links['next'] ?? null;\n }\n}\n\nexport async function* paginateCursor<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; nextCursor: string | null },\n buildNext: (req: HttpRequest, cursor: string) => HttpRequest,\n): AsyncIterable<T> {\n let req: HttpRequest = initial;\n while (true) {\n const res = await request(req);\n const { items, nextCursor } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!nextCursor) {\n return;\n }\n req = buildNext(req, nextCursor);\n }\n}\n\nexport async function* paginatePage<T>(\n initial: HttpRequest,\n parse: (body: unknown) => { items: T[]; hasMore: boolean },\n buildPage: (req: HttpRequest, page: number) => HttpRequest,\n): AsyncIterable<T> {\n let page = 1;\n while (true) {\n const req = page === 1 ? initial : buildPage(initial, page);\n const res = await request(req);\n const { items, hasMore } = parse(res.body);\n for (const item of items) {\n yield item;\n }\n if (!hasMore || items.length === 0) {\n return;\n }\n page++;\n }\n}\n","import { type HttpRequest, request } from '@rawdash/connector-shared';\nimport {\n BaseConnector,\n type ChunkedSyncCursor,\n type CredentialsSchema,\n type StorageHandle,\n type SyncOptions,\n type SyncResult,\n defineConfigFields,\n paginateChunked,\n} from '@rawdash/core';\nimport { z } from 'zod';\n\nexport const configFields = defineConfigFields(\n z\n .object({\n propertyId: z\n .string()\n .regex(/^\\d+$/, 'GA4 Property ID must be digits only')\n .meta({\n label: 'GA4 Property ID',\n description:\n 'Numeric ID of your GA4 property (e.g. 123456789). Find it in Google Analytics → Admin → Property settings.',\n placeholder: '123456789',\n }),\n serviceAccountJson: z.object({ $secret: z.string() }).optional().meta({\n label: 'Service Account JSON (recommended)',\n description:\n 'Contents of the JSON key file for a Google service account with the Analytics Viewer role. Create one at Google Cloud → IAM & Admin → Service Accounts.',\n secret: true,\n }),\n refreshToken: z.object({ $secret: z.string() }).optional().meta({\n label: 'OAuth Refresh Token',\n description:\n 'Google OAuth 2.0 refresh token with analytics.readonly scope. Required if not using serviceAccountJson.',\n secret: true,\n }),\n clientId: z.string().optional().meta({\n label: 'OAuth Client ID',\n description:\n 'OAuth 2.0 client ID from Google Cloud Console. Required when using refreshToken auth.',\n placeholder: '…apps.googleusercontent.com',\n }),\n clientSecret: z.object({ $secret: z.string() }).optional().meta({\n label: 'OAuth Client Secret',\n description:\n 'OAuth 2.0 client secret from Google Cloud Console. Required when using refreshToken auth.',\n secret: true,\n }),\n lookbackDays: z.number().int().positive().optional().meta({\n label: 'Lookback days (full sync)',\n description:\n 'How many calendar days to fetch on a full sync. Defaults to 90.',\n placeholder: '90',\n }),\n })\n .refine(\n (val) =>\n val.serviceAccountJson !== undefined ||\n (val.refreshToken !== undefined &&\n val.clientId !== undefined &&\n val.clientSecret !== undefined),\n {\n message:\n 'Provide either serviceAccountJson or the full OAuth tuple (refreshToken + clientId + clientSecret)',\n },\n ),\n);\n\n// ---------------------------------------------------------------------------\n// Settings / credentials\n// ---------------------------------------------------------------------------\n\nexport interface GA4Settings {\n propertyId: string;\n lookbackDays?: number;\n}\n\nconst ga4Credentials = {\n serviceAccountJson: {\n description: 'Google service account JSON key (base64 or raw JSON)',\n auth: 'optional' as const,\n },\n refreshToken: {\n description: 'Google OAuth 2.0 refresh token',\n auth: 'optional' as const,\n },\n clientId: {\n description: 'Google OAuth 2.0 client ID',\n auth: 'optional' as const,\n },\n clientSecret: {\n description: 'Google OAuth 2.0 client secret',\n auth: 'optional' as const,\n },\n} satisfies CredentialsSchema;\n\ntype GA4Credentials = typeof ga4Credentials;\n\n// ---------------------------------------------------------------------------\n// Sync phases + cursor\n// ---------------------------------------------------------------------------\n\nconst PHASE_ORDER = [\n 'traffic_by_day',\n 'traffic_by_source',\n 'top_pages',\n 'events',\n 'conversions',\n 'geo',\n] as const;\n\ntype GA4Phase = (typeof PHASE_ORDER)[number];\n\ninterface GA4Page {\n offset: number;\n startDate: string;\n endDate: string;\n}\n\ntype GA4SyncCursor = ChunkedSyncCursor<GA4Phase, GA4Page>;\n\nfunction isGA4Page(value: unknown): value is GA4Page {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as {\n offset?: unknown;\n startDate?: unknown;\n endDate?: unknown;\n };\n return (\n typeof v.offset === 'number' &&\n typeof v.startDate === 'string' &&\n typeof v.endDate === 'string'\n );\n}\n\nfunction isGA4SyncCursor(value: unknown): value is GA4SyncCursor {\n if (typeof value !== 'object' || value === null) {\n return false;\n }\n const v = value as { phase?: unknown; page?: unknown };\n if (typeof v.phase !== 'string') {\n return false;\n }\n if (!(PHASE_ORDER as readonly string[]).includes(v.phase)) {\n return false;\n }\n if (v.page !== null && !isGA4Page(v.page)) {\n return false;\n }\n return true;\n}\n\n// ---------------------------------------------------------------------------\n// Phase configs — dimensions + metrics for each resource\n// ---------------------------------------------------------------------------\n\ninterface PhaseConfig {\n dimensions: string[];\n metrics: string[];\n metricName: string;\n}\n\nconst PHASE_CONFIGS: Record<GA4Phase, PhaseConfig> = {\n traffic_by_day: {\n dimensions: ['date'],\n metrics: [\n 'sessions',\n 'totalUsers',\n 'newUsers',\n 'screenPageViews',\n 'engagementRate',\n ],\n metricName: 'ga4_traffic_by_day',\n },\n traffic_by_source: {\n dimensions: ['date', 'sessionSource', 'sessionMedium'],\n metrics: ['sessions', 'conversions'],\n metricName: 'ga4_traffic_by_source',\n },\n top_pages: {\n dimensions: ['date', 'pagePath'],\n metrics: ['screenPageViews', 'averageSessionDuration'],\n metricName: 'ga4_top_pages',\n },\n events: {\n dimensions: ['date', 'eventName'],\n metrics: ['eventCount', 'totalUsers'],\n metricName: 'ga4_events',\n },\n conversions: {\n dimensions: ['date', 'eventName'],\n metrics: ['conversions', 'totalRevenue'],\n metricName: 'ga4_conversions',\n },\n geo: {\n dimensions: ['date', 'country'],\n metrics: ['sessions', 'totalUsers'],\n metricName: 'ga4_geo',\n },\n};\n\nconst ROWS_PER_PAGE = 10_000;\n\n// ---------------------------------------------------------------------------\n// GA4 Data API types\n// ---------------------------------------------------------------------------\n\nexport interface GA4DimensionValue {\n value: string;\n}\n\nexport interface GA4MetricValue {\n value: string;\n}\n\nexport interface GA4ReportRow {\n dimensionValues: GA4DimensionValue[];\n metricValues: GA4MetricValue[];\n}\n\ninterface GA4ReportResponse {\n rows?: GA4ReportRow[];\n rowCount?: number;\n dimensionHeaders?: Array<{ name: string }>;\n metricHeaders?: Array<{ name: string; type: string }>;\n}\n\n// ---------------------------------------------------------------------------\n// Service account / OAuth token helpers\n// ---------------------------------------------------------------------------\n\ninterface ServiceAccountKey {\n client_email: string;\n private_key: string;\n token_uri?: string;\n}\n\ninterface TokenResponse {\n access_token: string;\n expires_in?: number;\n}\n\nfunction base64urlFromBytes(bytes: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]!);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\nfunction base64urlFromString(str: string): string {\n return base64urlFromBytes(new TextEncoder().encode(str));\n}\n\nasync function signRS256JWT(\n payload: Record<string, unknown>,\n privateKeyPem: string,\n): Promise<string> {\n const header = { alg: 'RS256', typ: 'JWT' };\n const headerB64 = base64urlFromString(JSON.stringify(header));\n const payloadB64 = base64urlFromString(JSON.stringify(payload));\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const pemContent = privateKeyPem\n .replace(/-----BEGIN PRIVATE KEY-----/g, '')\n .replace(/-----END PRIVATE KEY-----/g, '')\n .replace(/\\s/g, '');\n const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));\n\n const key = await globalThis.crypto.subtle.importKey(\n 'pkcs8',\n der,\n { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },\n false,\n ['sign'],\n );\n\n const signature = await globalThis.crypto.subtle.sign(\n 'RSASSA-PKCS1-v1_5',\n key,\n new TextEncoder().encode(signingInput),\n );\n\n return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;\n}\n\nfunction parseServiceAccountJson(value: string): ServiceAccountKey {\n const trimmed = value.trim();\n if (trimmed.startsWith('{')) {\n return JSON.parse(trimmed) as ServiceAccountKey;\n }\n const binary = atob(trimmed);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const decoded = new TextDecoder().decode(bytes);\n return JSON.parse(decoded) as ServiceAccountKey;\n}\n\nasync function fetchServiceAccountToken(\n serviceAccountJson: string,\n signal?: AbortSignal,\n): Promise<{ token: string; expiresAt: number }> {\n const sa = parseServiceAccountJson(serviceAccountJson);\n const now = Math.floor(Date.now() / 1000);\n const jwt = await signRS256JWT(\n {\n iss: sa.client_email,\n scope: 'https://www.googleapis.com/auth/analytics.readonly',\n aud: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n exp: now + 3600,\n iat: now,\n },\n sa.private_key,\n );\n\n const body = new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: jwt,\n }).toString();\n\n const res = await request<TokenResponse>({\n url: sa.token_uri ?? 'https://oauth2.googleapis.com/token',\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n return {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n}\n\nasync function fetchRefreshToken(\n refreshToken: string,\n clientId: string,\n clientSecret: string,\n signal?: AbortSignal,\n): Promise<{ token: string; expiresAt: number }> {\n const body = new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n client_id: clientId,\n client_secret: clientSecret,\n }).toString();\n\n const res = await request<TokenResponse>({\n url: 'https://oauth2.googleapis.com/token',\n method: 'POST',\n headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n body,\n signal,\n });\n const expiresIn = res.body.expires_in ?? 3600;\n return {\n token: res.body.access_token,\n expiresAt: Date.now() + (expiresIn - 60) * 1000,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Date helpers\n// ---------------------------------------------------------------------------\n\nfunction toGA4Date(date: Date): string {\n const y = date.getUTCFullYear();\n const m = String(date.getUTCMonth() + 1).padStart(2, '0');\n const d = String(date.getUTCDate()).padStart(2, '0');\n return `${y}-${m}-${d}`;\n}\n\nfunction ga4DateToMs(ga4Date: string): number {\n // GA4 dates arrive as 'YYYYMMDD'\n const y = ga4Date.slice(0, 4);\n const m = ga4Date.slice(4, 6);\n const d = ga4Date.slice(6, 8);\n return Date.UTC(Number(y), Number(m) - 1, Number(d));\n}\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nconst INCREMENTAL_LOOKBACK_DAYS = 30;\n\nfunction getDateRange(\n options: SyncOptions,\n lookbackDays: number,\n): { startDate: string; endDate: string } {\n const now = Date.now();\n const endDate = toGA4Date(new Date(now));\n const days =\n options.mode === 'latest' && options.since\n ? INCREMENTAL_LOOKBACK_DAYS\n : lookbackDays;\n const startMs = now - (days - 1) * MS_PER_DAY;\n return { startDate: toGA4Date(new Date(startMs)), endDate };\n}\n\n// ---------------------------------------------------------------------------\n// Row conversion\n// ---------------------------------------------------------------------------\n\nexport function rowToMetricSample(\n row: GA4ReportRow,\n dimensionHeaders: string[],\n metricHeaders: string[],\n metricName: string,\n): {\n name: string;\n ts: number;\n value: number;\n attributes: Record<string, string | number>;\n} {\n const dims: Record<string, string> = {};\n for (let i = 0; i < dimensionHeaders.length; i++) {\n dims[dimensionHeaders[i]!] = row.dimensionValues[i]?.value ?? '';\n }\n\n const mets: Record<string, number> = {};\n for (let i = 0; i < metricHeaders.length; i++) {\n mets[metricHeaders[i]!] =\n parseFloat(row.metricValues[i]?.value ?? '0') || 0;\n }\n\n const dateStr = dims['date'] ?? '19700101';\n const ts = ga4DateToMs(dateStr);\n const primaryValue = mets[metricHeaders[0]!] ?? 0;\n\n return {\n name: metricName,\n ts,\n value: primaryValue,\n attributes: { ...dims, ...mets },\n };\n}\n\n// ---------------------------------------------------------------------------\n// GA4Connector\n// ---------------------------------------------------------------------------\n\nexport class GA4Connector extends BaseConnector<GA4Settings, GA4Credentials> {\n static readonly id = 'google-analytics';\n\n static create(input: unknown): { connector: GA4Connector } {\n const parsed = configFields.parse(input);\n return {\n connector: new GA4Connector(\n {\n propertyId: parsed.propertyId,\n lookbackDays: parsed.lookbackDays,\n },\n {\n serviceAccountJson: parsed.serviceAccountJson,\n refreshToken: parsed.refreshToken,\n clientId: parsed.clientId,\n clientSecret: parsed.clientSecret,\n },\n ),\n };\n }\n\n readonly id = 'google-analytics';\n override readonly credentials = ga4Credentials;\n\n private cachedToken: { token: string; expiresAt: number } | null = null;\n\n private async getAccessToken(signal?: AbortSignal): Promise<string> {\n if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {\n return this.cachedToken.token;\n }\n\n const { serviceAccountJson, refreshToken, clientId, clientSecret } =\n this.creds;\n\n if (serviceAccountJson) {\n this.cachedToken = await fetchServiceAccountToken(\n serviceAccountJson,\n signal,\n );\n return this.cachedToken.token;\n }\n\n if (refreshToken && clientId && clientSecret) {\n this.cachedToken = await fetchRefreshToken(\n refreshToken,\n clientId,\n clientSecret,\n signal,\n );\n return this.cachedToken.token;\n }\n\n throw new Error(\n 'GA4 connector: provide either serviceAccountJson or (refreshToken + clientId + clientSecret)',\n );\n }\n\n private async runReport(\n accessToken: string,\n phase: GA4Phase,\n dateRange: { startDate: string; endDate: string },\n offset: number,\n signal?: AbortSignal,\n ): Promise<GA4ReportResponse> {\n const { dimensions, metrics } = PHASE_CONFIGS[phase];\n const url = `https://analyticsdata.googleapis.com/v1beta/properties/${this.settings.propertyId}:runReport`;\n\n const body: Record<string, unknown> = {\n dimensions: dimensions.map((name) => ({ name })),\n metrics: metrics.map((name) => ({ name })),\n dateRanges: [\n { startDate: dateRange.startDate, endDate: dateRange.endDate },\n ],\n limit: ROWS_PER_PAGE,\n offset,\n };\n\n const req: HttpRequest = {\n url,\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'User-Agent':\n 'rawdash/connector-google-analytics (+https://rawdash.dev)',\n },\n body: JSON.stringify(body),\n signal,\n };\n\n const res = await request<GA4ReportResponse>(req);\n return res.body;\n }\n\n async sync(\n options: SyncOptions,\n storage: StorageHandle,\n signal?: AbortSignal,\n ): Promise<SyncResult> {\n const lookbackDays = this.settings.lookbackDays ?? 90;\n\n const cursor = isGA4SyncCursor(options.cursor) ? options.cursor : undefined;\n const resumeDateRange =\n cursor?.page && isGA4Page(cursor.page)\n ? { startDate: cursor.page.startDate, endDate: cursor.page.endDate }\n : null;\n const dateRange = resumeDateRange ?? getDateRange(options, lookbackDays);\n\n // Lazily resolve access token once per sync (re-fetched if expired mid-run)\n let accessToken: string | null = null;\n const getToken = async (sig?: AbortSignal): Promise<string> => {\n if (!accessToken) {\n accessToken = await this.getAccessToken(sig);\n }\n return accessToken;\n };\n\n const clearedPhases = new Set<GA4Phase>();\n\n return paginateChunked<GA4Phase, GA4Page>({\n phases: PHASE_ORDER,\n cursor,\n signal,\n fetchPage: async (phase, page, sig) => {\n const token = await getToken(sig);\n const offset = page?.offset ?? 0;\n let response: GA4ReportResponse;\n try {\n response = await this.runReport(token, phase, dateRange, offset, sig);\n } catch (err) {\n console.warn(\n `[ga4] runReport failed, refreshing token and retrying once`,\n err,\n );\n accessToken = null;\n const freshToken = await getToken(sig);\n response = await this.runReport(\n freshToken,\n phase,\n dateRange,\n offset,\n sig,\n );\n }\n const rows = response.rows ?? [];\n const totalRows = response.rowCount ?? 0;\n const nextOffset = offset + rows.length;\n const next: GA4Page | null =\n rows.length > 0 && nextOffset < totalRows\n ? {\n offset: nextOffset,\n startDate: dateRange.startDate,\n endDate: dateRange.endDate,\n }\n : null;\n return { items: rows, next };\n },\n writeBatch: async (phase, items, page) => {\n const cfg = PHASE_CONFIGS[phase];\n\n if (page === null && !clearedPhases.has(phase)) {\n clearedPhases.add(phase);\n await storage.metrics([], { names: [cfg.metricName] });\n }\n\n const rows = items as GA4ReportRow[];\n for (const row of rows) {\n const sample = rowToMetricSample(\n row,\n cfg.dimensions,\n cfg.metrics,\n cfg.metricName,\n );\n await storage.metric(sample);\n }\n },\n });\n }\n}\n"],"mappings":"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","names":["err"]}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@rawdash/connector-google-analytics",
3
+ "version": "0.1.0",
4
+ "description": "Rawdash connector for Google Analytics 4 — syncs traffic, sources, pages, events, conversions, and geo data into the six-shape storage model",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/rawdash/rawdash.git",
10
+ "directory": "packages/connectors/google-analytics"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "@rawdash/source": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "eslint src",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@rawdash/core": "workspace:*",
32
+ "zod": "^4.4.3"
33
+ },
34
+ "devDependencies": {
35
+ "@rawdash/connector-shared": "workspace:*",
36
+ "tsup": "^8.0.0",
37
+ "typescript": "^5.7.2",
38
+ "vitest": "^4.1.4"
39
+ }
40
+ }