@rawdash/connector-google-play-console 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,181 @@
1
- // ../../connector-shared/dist/index.js
1
+ // ../gcp-shared/dist/index.js
2
+ import { z } from "zod";
3
+ import { z as z2 } from "zod";
4
+ import { z as z3 } from "zod";
5
+ var serviceAccountKeySchema = z.object({
6
+ client_email: z.string().min(1),
7
+ private_key: z.string().min(1),
8
+ token_uri: z.string().url().optional(),
9
+ project_id: z.string().optional()
10
+ });
11
+ var tokenResponseSchema = z.object({
12
+ access_token: z.string().min(1),
13
+ expires_in: z.number().int().positive().optional()
14
+ });
15
+ function base64urlFromBytes(bytes) {
16
+ let binary = "";
17
+ for (let i = 0; i < bytes.length; i++) {
18
+ binary += String.fromCharCode(bytes[i]);
19
+ }
20
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
21
+ }
22
+ function base64urlFromString(str) {
23
+ return base64urlFromBytes(new TextEncoder().encode(str));
24
+ }
25
+ async function signRS256JWT(payload, privateKeyPem) {
26
+ const header = { alg: "RS256", typ: "JWT" };
27
+ const headerB64 = base64urlFromString(JSON.stringify(header));
28
+ const payloadB64 = base64urlFromString(JSON.stringify(payload));
29
+ const signingInput = `${headerB64}.${payloadB64}`;
30
+ const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
31
+ const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
32
+ const key = await globalThis.crypto.subtle.importKey(
33
+ "pkcs8",
34
+ der,
35
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
36
+ false,
37
+ ["sign"]
38
+ );
39
+ const signature = await globalThis.crypto.subtle.sign(
40
+ "RSASSA-PKCS1-v1_5",
41
+ key,
42
+ new TextEncoder().encode(signingInput)
43
+ );
44
+ return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
45
+ }
46
+ function parseServiceAccountJson(value) {
47
+ if (typeof value === "object" && value !== null) {
48
+ return serviceAccountKeySchema.parse(value);
49
+ }
50
+ if (typeof value !== "string") {
51
+ throw new Error(
52
+ `serviceAccountJson must be a JSON object, raw JSON string, or base64-encoded JSON, but received ${typeof value}`
53
+ );
54
+ }
55
+ const trimmed = value.trim();
56
+ if (trimmed.startsWith("{")) {
57
+ return serviceAccountKeySchema.parse(JSON.parse(trimmed));
58
+ }
59
+ const binary = atob(trimmed);
60
+ const bytes = new Uint8Array(binary.length);
61
+ for (let i = 0; i < binary.length; i++) {
62
+ bytes[i] = binary.charCodeAt(i);
63
+ }
64
+ const decoded = new TextDecoder().decode(bytes);
65
+ return serviceAccountKeySchema.parse(JSON.parse(decoded));
66
+ }
67
+ async function buildServiceAccountJwt(serviceAccountJson, scope) {
68
+ const sa = parseServiceAccountJson(serviceAccountJson);
69
+ const now = Math.floor(Date.now() / 1e3);
70
+ const jwt = await signRS256JWT(
71
+ {
72
+ iss: sa.client_email,
73
+ scope,
74
+ aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
75
+ exp: now + 3600,
76
+ iat: now
77
+ },
78
+ sa.private_key
79
+ );
80
+ const body = new URLSearchParams({
81
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
82
+ assertion: jwt
83
+ }).toString();
84
+ return {
85
+ url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
86
+ body
87
+ };
88
+ }
89
+ function buildRefreshTokenGrant(credentials) {
90
+ const body = new URLSearchParams({
91
+ grant_type: "refresh_token",
92
+ refresh_token: credentials.refreshToken,
93
+ client_id: credentials.clientId,
94
+ client_secret: credentials.clientSecret
95
+ }).toString();
96
+ return { url: "https://oauth2.googleapis.com/token", body };
97
+ }
98
+ var gcpAuthConfigShape = {
99
+ serviceAccountJson: z2.object({ $secret: z2.string().trim().min(1) }).meta({
100
+ label: "Service Account JSON",
101
+ description: "Contents of the JSON key file for a Google service account with the role required by this connector. Create one at Google Cloud -> IAM & Admin -> Service Accounts and store the JSON as a secret.",
102
+ secret: true
103
+ })
104
+ };
105
+ var HttpClientError = class extends Error {
106
+ response;
107
+ constructor(message, response) {
108
+ super(message);
109
+ this.name = new.target.name;
110
+ this.response = response;
111
+ }
112
+ };
113
+ var AuthError = class extends HttpClientError {
114
+ kind = "auth";
115
+ };
2
116
  var HTTP_CLIENT_VERSION = "0.0.0";
3
117
  var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
118
+ var bqQueryResponseSchema = z3.object({
119
+ jobComplete: z3.boolean().optional(),
120
+ schema: z3.object({
121
+ fields: z3.array(z3.object({ name: z3.string(), type: z3.string() }))
122
+ }).optional(),
123
+ rows: z3.array(
124
+ z3.object({
125
+ f: z3.array(z3.object({ v: z3.string().nullable().optional() }))
126
+ })
127
+ ).optional(),
128
+ pageToken: z3.string().optional(),
129
+ jobReference: z3.object({
130
+ projectId: z3.string(),
131
+ jobId: z3.string(),
132
+ location: z3.string().optional()
133
+ }).optional()
134
+ });
135
+ var GcpAccessTokenProvider = class {
136
+ constructor(opts) {
137
+ this.opts = opts;
138
+ }
139
+ opts;
140
+ cached = null;
141
+ async resolveGrant() {
142
+ const serviceAccountJson = this.opts.getServiceAccountJson();
143
+ if (serviceAccountJson) {
144
+ return buildServiceAccountJwt(serviceAccountJson, this.opts.scope);
145
+ }
146
+ const refreshTokenCredentials = this.opts.getRefreshTokenCredentials?.();
147
+ if (refreshTokenCredentials) {
148
+ return buildRefreshTokenGrant(refreshTokenCredentials);
149
+ }
150
+ throw new AuthError(
151
+ `${this.opts.connectorId}: missing serviceAccountJson or refresh-token credentials`
152
+ );
153
+ }
154
+ async getToken(signal) {
155
+ if (this.cached && Date.now() < this.cached.expiresAt) {
156
+ return this.cached.token;
157
+ }
158
+ const { url, body } = await this.resolveGrant();
159
+ const res = await this.opts.post(url, {
160
+ resource: "oauth_token",
161
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
162
+ body,
163
+ signal
164
+ });
165
+ const expiresIn = res.body.expires_in ?? 3600;
166
+ this.cached = {
167
+ token: res.body.access_token,
168
+ expiresAt: Date.now() + (expiresIn - 60) * 1e3
169
+ };
170
+ return this.cached.token;
171
+ }
172
+ };
173
+
174
+ // ../../connector-shared/dist/index.js
175
+ var HTTP_CLIENT_VERSION2 = "0.0.0";
176
+ var DEFAULT_USER_AGENT2 = `rawdash-connector/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
4
177
  function connectorUserAgent(connectorId) {
5
- return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
178
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
6
179
  }
7
180
 
8
181
  // src/google-play-console.ts
@@ -14,30 +187,276 @@ import {
14
187
  schemasFromResources,
15
188
  selectActivePhases
16
189
  } from "@rawdash/core";
17
- import { z } from "zod";
190
+ import { z as z4 } from "zod";
191
+
192
+ // src/installs.ts
193
+ var INSTALLS_BREAKDOWNS = [
194
+ {
195
+ resource: "gplay_installs_overview_by_day",
196
+ fileDimension: "overview",
197
+ phase: "installs_overview",
198
+ responseTag: "installs_overview",
199
+ dimensionAttr: null,
200
+ dimensionDescription: "",
201
+ description: "Daily install statistics for the app from the Play Console monthly installs report (stats/installs overview CSV). Primary value is Daily Device Installs; uninstalls, upgrades, active-device installs and user-keyed counts are carried as additional attributes."
202
+ },
203
+ {
204
+ resource: "gplay_installs_by_country",
205
+ fileDimension: "country",
206
+ phase: "installs_country",
207
+ responseTag: "installs_country",
208
+ dimensionAttr: "country",
209
+ dimensionDescription: "ISO 3166-1 alpha-2 country/region code the installs are attributed to.",
210
+ description: "Daily install statistics broken down by country/region from the Play Console monthly installs report (stats/installs country CSV)."
211
+ },
212
+ {
213
+ resource: "gplay_installs_by_app_version",
214
+ fileDimension: "app_version",
215
+ phase: "installs_app_version",
216
+ responseTag: "installs_app_version",
217
+ dimensionAttr: "app_version_code",
218
+ dimensionDescription: "Android versionCode the installs are attributed to.",
219
+ description: "Daily install statistics broken down by app version code from the Play Console monthly installs report (stats/installs app_version CSV)."
220
+ },
221
+ {
222
+ resource: "gplay_installs_by_device",
223
+ fileDimension: "device",
224
+ phase: "installs_device",
225
+ responseTag: "installs_device",
226
+ dimensionAttr: "device",
227
+ dimensionDescription: "Device codename the installs are attributed to.",
228
+ description: "Daily install statistics broken down by device from the Play Console monthly installs report (stats/installs device CSV)."
229
+ },
230
+ {
231
+ resource: "gplay_installs_by_os_version",
232
+ fileDimension: "os_version",
233
+ phase: "installs_os_version",
234
+ responseTag: "installs_os_version",
235
+ dimensionAttr: "android_os_version",
236
+ dimensionDescription: "Android API level (SDK version) the installs are attributed to.",
237
+ description: "Daily install statistics broken down by Android OS version from the Play Console monthly installs report (stats/installs os_version CSV)."
238
+ },
239
+ {
240
+ resource: "gplay_installs_by_language",
241
+ fileDimension: "language",
242
+ phase: "installs_language",
243
+ responseTag: "installs_language",
244
+ dimensionAttr: "language",
245
+ dimensionDescription: "BCP-47 language/locale code the installs are attributed to.",
246
+ description: "Daily install statistics broken down by language from the Play Console monthly installs report (stats/installs language CSV)."
247
+ },
248
+ {
249
+ resource: "gplay_installs_by_carrier",
250
+ fileDimension: "carrier",
251
+ phase: "installs_carrier",
252
+ responseTag: "installs_carrier",
253
+ dimensionAttr: "carrier",
254
+ dimensionDescription: "Mobile carrier the installs are attributed to.",
255
+ description: "Daily install statistics broken down by carrier from the Play Console monthly installs report (stats/installs carrier CSV)."
256
+ }
257
+ ];
258
+ var INSTALLS_METRIC_ATTRIBUTES = [
259
+ "current_device_installs",
260
+ "active_device_installs",
261
+ "daily_device_installs",
262
+ "daily_device_uninstalls",
263
+ "daily_device_upgrades",
264
+ "current_user_installs",
265
+ "total_user_installs",
266
+ "daily_user_installs",
267
+ "daily_user_uninstalls"
268
+ ];
269
+ var PRIMARY_METRIC_KEY = "daily_device_installs";
270
+ var METRIC_KEY_ALIASES = {
271
+ installs_on_active_devices: "active_device_installs"
272
+ };
273
+ var KNOWN_METRIC_KEYS = /* @__PURE__ */ new Set([
274
+ ...INSTALLS_METRIC_ATTRIBUTES,
275
+ "installs_on_active_devices"
276
+ ]);
277
+ var INSTALLS_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
278
+ function normalizeInstallsBucketId(value) {
279
+ return value.trim().replace(/^gs:\/\//i, "").replace(/\/.*$/, "").replace(/\/+$/, "");
280
+ }
281
+ function installsObjectPath(packageName, yyyymm, fileDimension) {
282
+ return `stats/installs/installs_${packageName}_${yyyymm}_${fileDimension}.csv`;
283
+ }
284
+ function installsMonthsForRange(startDate, endDate) {
285
+ const start = monthIndex(startDate);
286
+ const end = monthIndex(endDate);
287
+ if (start === null || end === null || end < start) {
288
+ return [];
289
+ }
290
+ const months = [];
291
+ for (let m = start; m <= end; m++) {
292
+ const year = Math.floor(m / 12);
293
+ const month = m % 12 + 1;
294
+ months.push(
295
+ `${String(year).padStart(4, "0")}${String(month).padStart(2, "0")}`
296
+ );
297
+ }
298
+ return months;
299
+ }
300
+ function monthIndex(date) {
301
+ if (!INSTALLS_DATE_RE.test(date)) {
302
+ return null;
303
+ }
304
+ const year = Number(date.slice(0, 4));
305
+ const month = Number(date.slice(5, 7));
306
+ return year * 12 + (month - 1);
307
+ }
308
+ function decodeUtf16Csv(bytes) {
309
+ const littleEndian = !(bytes[0] === 254 && bytes[1] === 255);
310
+ const decoder = new TextDecoder(littleEndian ? "utf-16le" : "utf-16be");
311
+ const text = decoder.decode(bytes);
312
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
313
+ }
314
+ function parseCsvRows(text) {
315
+ const rows = [];
316
+ let field = "";
317
+ let row = [];
318
+ let inQuotes = false;
319
+ let sawContent = false;
320
+ for (let i = 0; i < text.length; i++) {
321
+ const c = text[i];
322
+ if (inQuotes) {
323
+ if (c === '"') {
324
+ if (text[i + 1] === '"') {
325
+ field += '"';
326
+ i++;
327
+ } else {
328
+ inQuotes = false;
329
+ }
330
+ } else {
331
+ field += c;
332
+ }
333
+ continue;
334
+ }
335
+ if (c === '"') {
336
+ inQuotes = true;
337
+ sawContent = true;
338
+ } else if (c === ",") {
339
+ row.push(field);
340
+ field = "";
341
+ sawContent = true;
342
+ } else if (c === "\n" || c === "\r") {
343
+ if (c === "\r" && text[i + 1] === "\n") {
344
+ i++;
345
+ }
346
+ if (sawContent || field.length > 0 || row.length > 0) {
347
+ row.push(field);
348
+ rows.push(row);
349
+ }
350
+ field = "";
351
+ row = [];
352
+ sawContent = false;
353
+ } else {
354
+ field += c;
355
+ sawContent = true;
356
+ }
357
+ }
358
+ if (sawContent || field.length > 0 || row.length > 0) {
359
+ row.push(field);
360
+ rows.push(row);
361
+ }
362
+ return rows;
363
+ }
364
+ function normalizeHeaderKey(header) {
365
+ return header.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
366
+ }
367
+ function installsDateToMs(date) {
368
+ return Date.UTC(
369
+ Number(date.slice(0, 4)),
370
+ Number(date.slice(5, 7)) - 1,
371
+ Number(date.slice(8, 10))
372
+ );
373
+ }
374
+ function parseInstallsCsv(text, breakdown, packageName) {
375
+ const rows = parseCsvRows(text);
376
+ if (rows.length < 2) {
377
+ return [];
378
+ }
379
+ const header = rows[0].map(normalizeHeaderKey);
380
+ const dateIdx = header.indexOf("date");
381
+ if (dateIdx < 0) {
382
+ return [];
383
+ }
384
+ const metricCols = [];
385
+ let dimIdx = -1;
386
+ for (let i = 0; i < header.length; i++) {
387
+ const key = header[i];
388
+ if (i === dateIdx || key === "package_name") {
389
+ continue;
390
+ }
391
+ if (KNOWN_METRIC_KEYS.has(key)) {
392
+ metricCols.push({ idx: i, key: METRIC_KEY_ALIASES[key] ?? key });
393
+ } else if (breakdown.dimensionAttr && dimIdx < 0) {
394
+ dimIdx = i;
395
+ }
396
+ }
397
+ if (!metricCols.some((mc) => mc.key === PRIMARY_METRIC_KEY)) {
398
+ return [];
399
+ }
400
+ const samples = [];
401
+ for (let r = 1; r < rows.length; r++) {
402
+ const cols = rows[r];
403
+ const dateStr = (cols[dateIdx] ?? "").trim();
404
+ if (!INSTALLS_DATE_RE.test(dateStr)) {
405
+ continue;
406
+ }
407
+ const attributes = {
408
+ date: dateStr,
409
+ package_name: packageName
410
+ };
411
+ if (breakdown.dimensionAttr && dimIdx >= 0) {
412
+ attributes[breakdown.dimensionAttr] = (cols[dimIdx] ?? "").trim();
413
+ }
414
+ for (const mc of metricCols) {
415
+ const raw = (cols[mc.idx] ?? "").trim();
416
+ const parsed = raw === "" ? 0 : Number(raw);
417
+ attributes[mc.key] = Number.isFinite(parsed) ? parsed : 0;
418
+ }
419
+ const primary = attributes[PRIMARY_METRIC_KEY];
420
+ const value = typeof primary === "number" ? primary : 0;
421
+ samples.push({
422
+ name: breakdown.resource,
423
+ ts: installsDateToMs(dateStr),
424
+ value,
425
+ attributes
426
+ });
427
+ }
428
+ return samples;
429
+ }
430
+
431
+ // src/google-play-console.ts
18
432
  var configFields = defineConfigFields(
19
- z.object({
20
- packageName: z.string().trim().regex(/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/, {
433
+ z4.object({
434
+ packageName: z4.string().trim().regex(/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/, {
21
435
  message: "packageName must be a reverse-DNS application id (e.g. com.example.app)."
22
436
  }).meta({
23
437
  label: "Package name",
24
438
  description: 'Reverse-DNS application id of the Android app (e.g. com.example.app). Visible in the Play Console URL and on Google Play under "About".',
25
439
  placeholder: "com.example.app"
26
440
  }),
27
- serviceAccountJson: z.object({ $secret: z.string().trim().min(1) }).meta({
441
+ serviceAccountJson: z4.object({ $secret: z4.string().trim().min(1) }).meta({
28
442
  label: "Service Account JSON",
29
443
  description: 'Contents of the JSON key file for a Google service account that has been granted access to your Play Console developer account with at least the "View app information and download bulk reports" permission. Create one at Google Cloud -> IAM & Admin -> Service Accounts.',
30
444
  secret: true
31
445
  }),
32
- lookbackDays: z.number().int().positive().optional().meta({
446
+ lookbackDays: z4.number().int().positive().optional().meta({
33
447
  label: "Lookback days (full sync)",
34
448
  description: "How many calendar days to fetch on a full sync. Defaults to 30. The Play Developer Reporting API exposes daily metrics with a typical 2-3 day reporting lag.",
35
449
  placeholder: "30"
36
450
  }),
37
- reviewLimit: z.number().int().positive().max(2e3).optional().meta({
451
+ reviewLimit: z4.number().int().positive().max(2e3).optional().meta({
38
452
  label: "Review sample size",
39
453
  description: "How many of the most-recent user reviews to emit as gplay_app_ratings samples. Defaults to 200. Reviews are fetched then ranked newest-first before this cap is applied. The Android Publisher reviews API only surfaces reviews from roughly the past week, so this is a rolling sample, not a full history.",
40
454
  placeholder: "200"
455
+ }),
456
+ installsBucketId: z4.string().trim().min(1).optional().meta({
457
+ label: "Installs report bucket id",
458
+ description: 'Cloud Storage bucket id that holds your Play Console reports (e.g. `pubsite_prod_rev_01234567890987654321`), shown via "Copy Cloud Storage URI" on the Play Console Download reports page. Required only for the `gplay_installs_*` resources, which read the monthly stats/installs CSV reports. The bucket is Google-managed; the service account is granted access through Play Console (Users & permissions -> "View app information and download bulk reports", set to Global), not Google Cloud IAM.',
459
+ placeholder: "pubsite_prod_rev_01234567890987654321"
41
460
  })
42
461
  })
43
462
  );
@@ -58,6 +477,7 @@ var doc = defineConnectorDoc({
58
477
  "In Google Cloud, create a service account at IAM & Admin -> Service Accounts and download a JSON key.",
59
478
  'Enable both the "Google Play Developer Reporting API" and the "Google Play Android Developer API" on the Cloud project.',
60
479
  'In Google Play Console open Setup -> API access, link the same Cloud project, then invite the service account email and grant it at least the "View app information and download bulk reports" permission for the app you want to sync.',
480
+ 'For the `gplay_installs_*` resources, grant bucket access inside Play Console, not Google Cloud IAM: the install reports live in a Google-managed Cloud Storage bucket provisioned for your developer account. In Play Console -> Users & permissions, give the service account the account-level "View app information and download bulk reports" permission set to Global (changes can take a few hours to propagate), then copy the bucket id from the Download reports page (the Cloud Storage URI starts with `gs://pubsite_prod_...`) into installsBucketId.',
61
481
  'Store the service account JSON as a secret and reference it as serviceAccountJson: secret("GPLAY_SA_JSON").',
62
482
  "Set packageName to the reverse-DNS application id of the app (e.g. com.example.app)."
63
483
  ]
@@ -67,7 +487,7 @@ var doc = defineConnectorDoc({
67
487
  "Daily vitals (crash rate, ANR rate, error counts) have a 2-3 day reporting lag on the Play Developer Reporting API; incremental syncs refetch the trailing 3 days. Metric days are reported on the America/Los_Angeles calendar, the only timezone the API supports for daily aggregation.",
68
488
  "gplay_app_ratings is a rolling sample of recent reviews from the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value; this is not the lifetime average shown on the Play Store, and the reviews API only surfaces reviews from roughly the past week.",
69
489
  "The apps entity carries only the configured package name; the Play Store listing title is available solely through an Android Publisher edit, which this connector does not create.",
70
- "Install counts and earnings are not exposed through the Reporting API - Google delivers them only as monthly CSV reports in a private Cloud Storage bucket. Those metrics are out of scope for this connector and will land in a follow-up."
490
+ 'The `gplay_installs_*` resources read the monthly stats/installs CSV reports from your Play Console Cloud Storage bucket, not the Reporting API; they require installsBucketId plus the account-level "View app information and download bulk reports" permission granted to the service account in Play Console (the bucket is Google-managed; access is not configured through Google Cloud IAM). Files are published monthly (with daily rows) and a few days in arrears, so the current month fills in over time and the most recent days lag. Earnings/financial reports remain out of scope.'
71
491
  ]
72
492
  });
73
493
  var gplayCredentials = {
@@ -81,8 +501,16 @@ var PHASE_ORDER = [
81
501
  "crash_rate",
82
502
  "anr_rate",
83
503
  "errors",
504
+ "installs_overview",
505
+ "installs_country",
506
+ "installs_app_version",
507
+ "installs_device",
508
+ "installs_os_version",
509
+ "installs_language",
510
+ "installs_carrier",
84
511
  "reviews"
85
512
  ];
513
+ var INSTALLS_PHASE_TO_BREAKDOWN = Object.fromEntries(INSTALLS_BREAKDOWNS.map((b) => [b.phase, b]));
86
514
  var GPLAY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
87
515
  function isGplayDateString(value) {
88
516
  return typeof value === "string" && GPLAY_DATE_RE.test(value);
@@ -136,84 +564,24 @@ var RESOURCE_TO_PHASE = {
136
564
  gplay_crash_rate_by_day: "crash_rate",
137
565
  gplay_anr_rate_by_day: "anr_rate",
138
566
  gplay_error_count_by_day: "errors",
139
- [GPLAY_APP_RATINGS_METRIC]: "reviews"
567
+ [GPLAY_APP_RATINGS_METRIC]: "reviews",
568
+ ...Object.fromEntries(
569
+ INSTALLS_BREAKDOWNS.map((b) => [b.resource, b.phase])
570
+ )
140
571
  };
141
572
  var SCOPES = [
142
573
  "https://www.googleapis.com/auth/playdeveloperreporting",
143
- "https://www.googleapis.com/auth/androidpublisher"
574
+ "https://www.googleapis.com/auth/androidpublisher",
575
+ "https://www.googleapis.com/auth/devstorage.read_only"
144
576
  ].join(" ");
145
577
  var REPORTING_BASE = "https://playdeveloperreporting.googleapis.com";
146
578
  var PUBLISHER_BASE = "https://androidpublisher.googleapis.com";
579
+ var GCS_BASE = "https://storage.googleapis.com";
580
+ var INSTALLS_DOWNLOAD_TIMEOUT_MS = 3e4;
147
581
  var DAILY_TIME_ZONE = "America/Los_Angeles";
148
582
  var DEFAULT_REVIEW_LIMIT = 200;
149
583
  var REVIEWS_PAGE_SIZE = 100;
150
584
  var MAX_REVIEW_PAGES = 50;
151
- function base64urlFromBytes(bytes) {
152
- let binary = "";
153
- for (let i = 0; i < bytes.length; i++) {
154
- binary += String.fromCharCode(bytes[i]);
155
- }
156
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
157
- }
158
- function base64urlFromString(str) {
159
- return base64urlFromBytes(new TextEncoder().encode(str));
160
- }
161
- async function signRS256JWT(payload, privateKeyPem) {
162
- const header = { alg: "RS256", typ: "JWT" };
163
- const headerB64 = base64urlFromString(JSON.stringify(header));
164
- const payloadB64 = base64urlFromString(JSON.stringify(payload));
165
- const signingInput = `${headerB64}.${payloadB64}`;
166
- const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
167
- const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
168
- const key = await globalThis.crypto.subtle.importKey(
169
- "pkcs8",
170
- der,
171
- { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
172
- false,
173
- ["sign"]
174
- );
175
- const signature = await globalThis.crypto.subtle.sign(
176
- "RSASSA-PKCS1-v1_5",
177
- key,
178
- new TextEncoder().encode(signingInput)
179
- );
180
- return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
181
- }
182
- function parseServiceAccountJson(value) {
183
- const trimmed = value.trim();
184
- if (trimmed.startsWith("{")) {
185
- return JSON.parse(trimmed);
186
- }
187
- const binary = atob(trimmed);
188
- const bytes = new Uint8Array(binary.length);
189
- for (let i = 0; i < binary.length; i++) {
190
- bytes[i] = binary.charCodeAt(i);
191
- }
192
- const decoded = new TextDecoder().decode(bytes);
193
- return JSON.parse(decoded);
194
- }
195
- async function buildServiceAccountJwt(serviceAccountJson) {
196
- const sa = parseServiceAccountJson(serviceAccountJson);
197
- const now = Math.floor(Date.now() / 1e3);
198
- const jwt = await signRS256JWT(
199
- {
200
- iss: sa.client_email,
201
- scope: SCOPES,
202
- aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
203
- exp: now + 3600,
204
- iat: now
205
- },
206
- sa.private_key
207
- );
208
- const body = new URLSearchParams({
209
- grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
210
- assertion: jwt
211
- }).toString();
212
- return {
213
- url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
214
- body
215
- };
216
- }
217
585
  var gplayDateFormatter = new Intl.DateTimeFormat("en-CA", {
218
586
  timeZone: DAILY_TIME_ZONE,
219
587
  year: "numeric",
@@ -241,6 +609,14 @@ function partsToGplayDate(parts) {
241
609
  }
242
610
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
243
611
  var INCREMENTAL_LOOKBACK_DAYS = 3;
612
+ function dateRangeToReplaceWindow(range) {
613
+ const start = gplayDateToMs(range.startDate);
614
+ const end = gplayDateToMs(range.endDate) + MS_PER_DAY - 1;
615
+ if (start > end) {
616
+ return void 0;
617
+ }
618
+ return { start, end };
619
+ }
244
620
  function getDateRange(options, lookbackDays) {
245
621
  const now = Date.now();
246
622
  const endDate = toGplayDate(new Date(now));
@@ -336,55 +712,122 @@ function reviewToRatingSample(review, packageName) {
336
712
  attributes
337
713
  };
338
714
  }
339
- var dateOnlyTimeline = z.object({
340
- startTime: z.object({
341
- year: z.number().int(),
342
- month: z.number().int(),
343
- day: z.number().int()
715
+ var dateOnlyTimeline = z4.object({
716
+ startTime: z4.object({
717
+ year: z4.number().int(),
718
+ month: z4.number().int(),
719
+ day: z4.number().int()
344
720
  })
345
721
  });
346
- var metricEntry = z.object({
347
- metric: z.string(),
348
- decimalValue: z.object({
349
- value: z.string()
722
+ var metricEntry = z4.object({
723
+ metric: z4.string(),
724
+ decimalValue: z4.object({
725
+ value: z4.string()
350
726
  }).optional()
351
727
  });
352
728
  function metricSetSchema() {
353
- return z.object({
354
- rows: z.array(
729
+ return z4.object({
730
+ rows: z4.array(
355
731
  dateOnlyTimeline.extend({
356
- metrics: z.array(metricEntry).optional()
732
+ metrics: z4.array(metricEntry).optional()
357
733
  })
358
734
  ).optional(),
359
- nextPageToken: z.string().optional()
735
+ nextPageToken: z4.string().optional()
360
736
  });
361
737
  }
362
- var reviewsResponseSchema = z.object({
363
- reviews: z.array(
364
- z.object({
365
- reviewId: z.string().optional(),
366
- authorName: z.string().optional(),
367
- comments: z.array(
368
- z.object({
369
- userComment: z.object({
370
- text: z.string().optional(),
371
- lastModified: z.object({
372
- seconds: z.string().optional(),
373
- nanos: z.number().optional()
738
+ var reviewsResponseSchema = z4.object({
739
+ reviews: z4.array(
740
+ z4.object({
741
+ reviewId: z4.string().optional(),
742
+ authorName: z4.string().optional(),
743
+ comments: z4.array(
744
+ z4.object({
745
+ userComment: z4.object({
746
+ text: z4.string().optional(),
747
+ lastModified: z4.object({
748
+ seconds: z4.string().optional(),
749
+ nanos: z4.number().optional()
374
750
  }).optional(),
375
- starRating: z.number().int().optional(),
376
- reviewerLanguage: z.string().optional(),
377
- device: z.string().optional(),
378
- androidOsVersion: z.number().int().optional(),
379
- appVersionCode: z.number().int().optional(),
380
- appVersionName: z.string().optional()
751
+ starRating: z4.number().int().optional(),
752
+ reviewerLanguage: z4.string().optional(),
753
+ device: z4.string().optional(),
754
+ androidOsVersion: z4.number().int().optional(),
755
+ appVersionCode: z4.number().int().optional(),
756
+ appVersionName: z4.string().optional()
381
757
  }).optional()
382
758
  })
383
759
  ).optional()
384
760
  })
385
761
  ).optional(),
386
- tokenPagination: z.object({ nextPageToken: z.string().optional() }).optional()
762
+ tokenPagination: z4.object({ nextPageToken: z4.string().optional() }).optional()
387
763
  });
764
+ var installsCsvResponse = z4.string();
765
+ var INSTALLS_DATE_DIMENSION = {
766
+ name: "date",
767
+ description: "Calendar day of the install statistics row, as delivered in the monthly stats/installs CSV."
768
+ };
769
+ var INSTALLS_PACKAGE_DIMENSION = {
770
+ name: "package_name",
771
+ description: "Reverse-DNS application id these install statistics are reported against."
772
+ };
773
+ var INSTALLS_MEASURES = [
774
+ {
775
+ name: "daily_device_installs",
776
+ description: "Devices that newly installed the app on this day (also the primary metric value)."
777
+ },
778
+ {
779
+ name: "daily_device_uninstalls",
780
+ description: "Devices that uninstalled the app on this day."
781
+ },
782
+ {
783
+ name: "daily_device_upgrades",
784
+ description: "Devices that upgraded the app on this day."
785
+ },
786
+ {
787
+ name: "current_device_installs",
788
+ description: "Active devices that have the app installed at end of day."
789
+ },
790
+ {
791
+ name: "active_device_installs",
792
+ description: "Installs on active devices (devices active in the trailing 30 days)."
793
+ },
794
+ {
795
+ name: "current_user_installs",
796
+ description: "Users that have the app installed at end of day."
797
+ },
798
+ {
799
+ name: "total_user_installs",
800
+ description: "Total users that have ever installed the app."
801
+ },
802
+ {
803
+ name: "daily_user_installs",
804
+ description: "Users that newly installed the app on this day."
805
+ },
806
+ {
807
+ name: "daily_user_uninstalls",
808
+ description: "Users that uninstalled the app on this day."
809
+ }
810
+ ];
811
+ function installsResource(breakdown) {
812
+ const dimensions = [INSTALLS_DATE_DIMENSION, INSTALLS_PACKAGE_DIMENSION];
813
+ if (breakdown.dimensionAttr) {
814
+ dimensions.push({
815
+ name: breakdown.dimensionAttr,
816
+ description: breakdown.dimensionDescription
817
+ });
818
+ }
819
+ return {
820
+ shape: "metric",
821
+ description: breakdown.description,
822
+ unit: "installs",
823
+ granularity: "day",
824
+ endpoint: `GET /storage/v1/b/{installsBucketId}/o/stats%2Finstalls%2Finstalls_{packageName}_{YYYYMM}_${breakdown.fileDimension}.csv`,
825
+ notes: "Sourced from the Play Console monthly stats/installs CSV in Cloud Storage. Files are monthly with daily rows and arrive a few days in arrears; the connector refetches the months overlapping the sync window.",
826
+ dimensions,
827
+ measures: INSTALLS_MEASURES,
828
+ responses: { [breakdown.responseTag]: installsCsvResponse }
829
+ };
830
+ }
388
831
  var googlePlayConsoleResources = defineResources({
389
832
  apps: {
390
833
  shape: "entity",
@@ -481,7 +924,14 @@ var googlePlayConsoleResources = defineResources({
481
924
  }
482
925
  ],
483
926
  responses: { reviews: reviewsResponseSchema }
484
- }
927
+ },
928
+ gplay_installs_overview_by_day: installsResource(INSTALLS_BREAKDOWNS[0]),
929
+ gplay_installs_by_country: installsResource(INSTALLS_BREAKDOWNS[1]),
930
+ gplay_installs_by_app_version: installsResource(INSTALLS_BREAKDOWNS[2]),
931
+ gplay_installs_by_device: installsResource(INSTALLS_BREAKDOWNS[3]),
932
+ gplay_installs_by_os_version: installsResource(INSTALLS_BREAKDOWNS[4]),
933
+ gplay_installs_by_language: installsResource(INSTALLS_BREAKDOWNS[5]),
934
+ gplay_installs_by_carrier: installsResource(INSTALLS_BREAKDOWNS[6])
485
935
  });
486
936
  var id = "google-play-console";
487
937
  var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseConnector {
@@ -490,11 +940,21 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
490
940
  static schemas = schemasFromResources(googlePlayConsoleResources);
491
941
  static create(input, ctx) {
492
942
  const parsed = configFields.parse(input);
943
+ let installsBucketId;
944
+ if (parsed.installsBucketId !== void 0) {
945
+ installsBucketId = normalizeInstallsBucketId(parsed.installsBucketId);
946
+ if (installsBucketId.length === 0) {
947
+ throw new Error(
948
+ "Google Play Console connector: installsBucketId must include a bucket name (e.g. pubsite_prod_rev_...)"
949
+ );
950
+ }
951
+ }
493
952
  return new _GooglePlayConsoleConnector(
494
953
  {
495
954
  packageName: parsed.packageName,
496
955
  lookbackDays: parsed.lookbackDays,
497
- reviewLimit: parsed.reviewLimit
956
+ reviewLimit: parsed.reviewLimit,
957
+ installsBucketId
498
958
  },
499
959
  {
500
960
  serviceAccountJson: parsed.serviceAccountJson
@@ -504,33 +964,15 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
504
964
  }
505
965
  id = id;
506
966
  credentials = gplayCredentials;
507
- cachedToken = null;
508
- async fetchOAuthToken(url, body, signal) {
509
- const res = await this.post(url, {
510
- resource: "oauth_token",
511
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
512
- body,
513
- signal
967
+ tokenProvider;
968
+ getAccessToken(signal) {
969
+ this.tokenProvider ??= new GcpAccessTokenProvider({
970
+ connectorId: this.id,
971
+ scope: SCOPES,
972
+ getServiceAccountJson: () => this.creds.serviceAccountJson,
973
+ post: (url, opts) => this.post(url, opts)
514
974
  });
515
- const expiresIn = res.body.expires_in ?? 3600;
516
- return {
517
- token: res.body.access_token,
518
- expiresAt: Date.now() + (expiresIn - 60) * 1e3
519
- };
520
- }
521
- async getAccessToken(signal) {
522
- if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) {
523
- return this.cachedToken.token;
524
- }
525
- const { serviceAccountJson } = this.creds;
526
- if (!serviceAccountJson) {
527
- throw new Error(
528
- "Google Play Console connector: serviceAccountJson credential is required"
529
- );
530
- }
531
- const { url, body } = await buildServiceAccountJwt(serviceAccountJson);
532
- this.cachedToken = await this.fetchOAuthToken(url, body, signal);
533
- return this.cachedToken.token;
975
+ return this.tokenProvider.getToken(signal);
534
976
  }
535
977
  async runMetricQuery(accessToken, cfg, dateRange, pageToken, signal) {
536
978
  const url = `${REPORTING_BASE}/v1beta1/apps/${encodeURIComponent(this.settings.packageName)}/${cfg.metricSet}:query`;
@@ -642,10 +1084,78 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
642
1084
  }
643
1085
  return rows;
644
1086
  }
1087
+ async downloadInstallsCsv(accessToken, bucket, yyyymm, breakdown, signal) {
1088
+ const objectPath = installsObjectPath(
1089
+ this.settings.packageName,
1090
+ yyyymm,
1091
+ breakdown.fileDimension
1092
+ );
1093
+ const url = `${GCS_BASE}/storage/v1/b/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectPath)}?alt=media`;
1094
+ try {
1095
+ const res = await this.request(
1096
+ {
1097
+ url,
1098
+ method: "GET",
1099
+ headers: {
1100
+ Authorization: `Bearer ${accessToken}`,
1101
+ "User-Agent": connectorUserAgent("google-play-console")
1102
+ },
1103
+ parseJson: false,
1104
+ binary: true,
1105
+ timeoutMs: INSTALLS_DOWNLOAD_TIMEOUT_MS,
1106
+ signal
1107
+ },
1108
+ { resource: breakdown.responseTag }
1109
+ );
1110
+ return decodeUtf16Csv(res.body);
1111
+ } catch (err) {
1112
+ const status = err.response?.status;
1113
+ if (status === 404) {
1114
+ return null;
1115
+ }
1116
+ throw err;
1117
+ }
1118
+ }
1119
+ async syncInstallsBreakdown(accessToken, breakdown, dateRange, storage, signal) {
1120
+ const bucket = this.settings.installsBucketId;
1121
+ if (!bucket) {
1122
+ return;
1123
+ }
1124
+ const months = installsMonthsForRange(
1125
+ dateRange.startDate,
1126
+ dateRange.endDate
1127
+ );
1128
+ const samples = [];
1129
+ for (const month of months) {
1130
+ signal?.throwIfAborted();
1131
+ const csv = await this.downloadInstallsCsv(
1132
+ accessToken,
1133
+ bucket,
1134
+ month,
1135
+ breakdown,
1136
+ signal
1137
+ );
1138
+ if (csv === null) {
1139
+ continue;
1140
+ }
1141
+ for (const sample of parseInstallsCsv(
1142
+ csv,
1143
+ breakdown,
1144
+ this.settings.packageName
1145
+ )) {
1146
+ const date = sample.attributes["date"];
1147
+ if (typeof date === "string" && date >= dateRange.startDate && date <= dateRange.endDate) {
1148
+ samples.push(sample);
1149
+ }
1150
+ }
1151
+ }
1152
+ await storage.metrics(samples, { names: [breakdown.resource] });
1153
+ }
645
1154
  async sync(options, storage, signal) {
646
1155
  const lookbackDays = this.settings.lookbackDays ?? 30;
647
1156
  const cursor = isGplaySyncCursor(options.cursor) ? options.cursor : void 0;
648
1157
  const dateRange = cursor?.dateRange ?? getDateRange(options, lookbackDays);
1158
+ const replaceWindow = dateRangeToReplaceWindow(dateRange);
649
1159
  let accessToken = null;
650
1160
  const getToken = async (sig) => {
651
1161
  if (!accessToken) {
@@ -675,6 +1185,25 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
675
1185
  await this.syncReviews(token2, storage, signal);
676
1186
  continue;
677
1187
  }
1188
+ const breakdown = INSTALLS_PHASE_TO_BREAKDOWN[phase];
1189
+ if (breakdown) {
1190
+ if (!this.settings.installsBucketId) {
1191
+ this.logger.warn(
1192
+ "Skipping Google Play installs resource because installsBucketId is not configured",
1193
+ { resource: breakdown.resource }
1194
+ );
1195
+ continue;
1196
+ }
1197
+ const token2 = await getToken(signal);
1198
+ await this.syncInstallsBreakdown(
1199
+ token2,
1200
+ breakdown,
1201
+ dateRange,
1202
+ storage,
1203
+ signal
1204
+ );
1205
+ continue;
1206
+ }
678
1207
  const cfg = METRIC_PHASE_CONFIGS[phase];
679
1208
  const token = await getToken(signal);
680
1209
  const rows = await this.drainMetricPhase(token, cfg, dateRange, signal);
@@ -687,7 +1216,10 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
687
1216
  this.settings.packageName
688
1217
  )
689
1218
  ).filter((s) => s !== null);
690
- await storage.metrics(samples, { names: [cfg.metricName] });
1219
+ await storage.metrics(samples, {
1220
+ names: [cfg.metricName],
1221
+ ...replaceWindow ? { replaceWindow } : {}
1222
+ });
691
1223
  } catch (err) {
692
1224
  if (signal?.aborted) {
693
1225
  return { done: false, cursor: { phase, dateRange } };