@rawdash/connector-google-play-console 0.28.0 → 0.29.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 +60 -9
- package/dist/index.d.ts +273 -2
- package/dist/index.js +670 -138
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,181 @@
|
|
|
1
|
-
//
|
|
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}/${
|
|
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
|
-
|
|
20
|
-
packageName:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
340
|
-
startTime:
|
|
341
|
-
year:
|
|
342
|
-
month:
|
|
343
|
-
day:
|
|
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 =
|
|
347
|
-
metric:
|
|
348
|
-
decimalValue:
|
|
349
|
-
value:
|
|
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
|
|
354
|
-
rows:
|
|
729
|
+
return z4.object({
|
|
730
|
+
rows: z4.array(
|
|
355
731
|
dateOnlyTimeline.extend({
|
|
356
|
-
metrics:
|
|
732
|
+
metrics: z4.array(metricEntry).optional()
|
|
357
733
|
})
|
|
358
734
|
).optional(),
|
|
359
|
-
nextPageToken:
|
|
735
|
+
nextPageToken: z4.string().optional()
|
|
360
736
|
});
|
|
361
737
|
}
|
|
362
|
-
var reviewsResponseSchema =
|
|
363
|
-
reviews:
|
|
364
|
-
|
|
365
|
-
reviewId:
|
|
366
|
-
authorName:
|
|
367
|
-
comments:
|
|
368
|
-
|
|
369
|
-
userComment:
|
|
370
|
-
text:
|
|
371
|
-
lastModified:
|
|
372
|
-
seconds:
|
|
373
|
-
nanos:
|
|
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:
|
|
376
|
-
reviewerLanguage:
|
|
377
|
-
device:
|
|
378
|
-
androidOsVersion:
|
|
379
|
-
appVersionCode:
|
|
380
|
-
appVersionName:
|
|
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:
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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, {
|
|
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 } };
|