@rawdash/connector-google-play-console 0.24.0 → 0.25.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/dist/index.js CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  defineConfigFields,
12
12
  defineConnectorDoc,
13
13
  defineResources,
14
- schemasFromResources
14
+ schemasFromResources,
15
+ selectActivePhases
15
16
  } from "@rawdash/core";
16
17
  import { z } from "zod";
17
18
  var configFields = defineConfigFields(
@@ -32,6 +33,11 @@ var configFields = defineConfigFields(
32
33
  label: "Lookback days (full sync)",
33
34
  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.",
34
35
  placeholder: "30"
36
+ }),
37
+ reviewLimit: z.number().int().positive().max(2e3).optional().meta({
38
+ label: "Review sample size",
39
+ 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
+ placeholder: "200"
35
41
  })
36
42
  })
37
43
  );
@@ -39,7 +45,7 @@ var doc = defineConnectorDoc({
39
45
  displayName: "Google Play Console",
40
46
  category: "engineering",
41
47
  brandColor: "#34A853",
42
- tagline: "Sync daily Android app vitals from the Play Developer Reporting API - crash rate, ANR rate, ratings, and error counts.",
48
+ tagline: "Sync daily Android app vitals from the Play Developer Reporting API (crash rate, ANR rate, error counts) plus user review ratings from the Android Publisher API.",
43
49
  vendor: {
44
50
  name: "Google Play Console",
45
51
  domain: "play.google.com",
@@ -50,7 +56,7 @@ var doc = defineConnectorDoc({
50
56
  summary: "Authenticate against the Play Developer Reporting API and the Android Publisher API with a Google service account JSON key. The service account must be linked to your Play Console developer account.",
51
57
  setup: [
52
58
  "In Google Cloud, create a service account at IAM & Admin -> Service Accounts and download a JSON key.",
53
- 'Enable both the "Google Play Android Developer API" and the "Google Play Developer Reporting API" on the Cloud project.',
59
+ 'Enable both the "Google Play Developer Reporting API" and the "Google Play Android Developer API" on the Cloud project.',
54
60
  '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.',
55
61
  'Store the service account JSON as a secret and reference it as serviceAccountJson: secret("GPLAY_SA_JSON").',
56
62
  "Set packageName to the reverse-DNS application id of the app (e.g. com.example.app)."
@@ -58,7 +64,9 @@ var doc = defineConnectorDoc({
58
64
  },
59
65
  rateLimit: "The Play Developer Reporting API enforces a per-project quota (default 60 requests per minute); 429 responses are retried with exponential backoff.",
60
66
  limitations: [
61
- "Daily vitals (crash rate, ANR rate, ratings, error counts) have a 2-3 day reporting lag on the Play Developer Reporting API; incremental syncs refetch the trailing 3 days.",
67
+ "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
+ "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
+ "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.",
62
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."
63
71
  ]
64
72
  });
@@ -72,8 +80,8 @@ var PHASE_ORDER = [
72
80
  "apps",
73
81
  "crash_rate",
74
82
  "anr_rate",
75
- "ratings",
76
- "errors"
83
+ "errors",
84
+ "reviews"
77
85
  ];
78
86
  var GPLAY_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
79
87
  function isGplayDateString(value) {
@@ -104,33 +112,42 @@ var METRIC_PHASE_CONFIGS = {
104
112
  metricSet: "crashRateMetricSet",
105
113
  metrics: ["crashRate", "distinctUsers"],
106
114
  metricName: "gplay_crash_rate_by_day",
107
- primaryMetric: "crashRate"
115
+ primaryMetric: "crashRate",
116
+ responseTag: "crash_rate"
108
117
  },
109
118
  anr_rate: {
110
119
  metricSet: "anrRateMetricSet",
111
120
  metrics: ["anrRate", "distinctUsers"],
112
121
  metricName: "gplay_anr_rate_by_day",
113
- primaryMetric: "anrRate"
114
- },
115
- ratings: {
116
- metricSet: "ratingsMetricSet",
117
- metrics: ["averageRating", "ratingsCount"],
118
- metricName: "gplay_ratings_by_day",
119
- primaryMetric: "averageRating"
122
+ primaryMetric: "anrRate",
123
+ responseTag: "anr_rate"
120
124
  },
121
125
  errors: {
122
126
  metricSet: "errorCountMetricSet",
123
127
  metrics: ["errorReportCount", "distinctUsers"],
124
128
  metricName: "gplay_error_count_by_day",
125
- primaryMetric: "errorReportCount"
129
+ primaryMetric: "errorReportCount",
130
+ responseTag: "errors"
126
131
  }
127
132
  };
133
+ var GPLAY_APP_RATINGS_METRIC = "gplay_app_ratings";
134
+ var RESOURCE_TO_PHASE = {
135
+ apps: "apps",
136
+ gplay_crash_rate_by_day: "crash_rate",
137
+ gplay_anr_rate_by_day: "anr_rate",
138
+ gplay_error_count_by_day: "errors",
139
+ [GPLAY_APP_RATINGS_METRIC]: "reviews"
140
+ };
128
141
  var SCOPES = [
129
142
  "https://www.googleapis.com/auth/playdeveloperreporting",
130
143
  "https://www.googleapis.com/auth/androidpublisher"
131
144
  ].join(" ");
132
145
  var REPORTING_BASE = "https://playdeveloperreporting.googleapis.com";
133
146
  var PUBLISHER_BASE = "https://androidpublisher.googleapis.com";
147
+ var DAILY_TIME_ZONE = "America/Los_Angeles";
148
+ var DEFAULT_REVIEW_LIMIT = 200;
149
+ var REVIEWS_PAGE_SIZE = 100;
150
+ var MAX_REVIEW_PAGES = 50;
134
151
  function base64urlFromBytes(bytes) {
135
152
  let binary = "";
136
153
  for (let i = 0; i < bytes.length; i++) {
@@ -197,11 +214,14 @@ async function buildServiceAccountJwt(serviceAccountJson) {
197
214
  body
198
215
  };
199
216
  }
217
+ var gplayDateFormatter = new Intl.DateTimeFormat("en-CA", {
218
+ timeZone: DAILY_TIME_ZONE,
219
+ year: "numeric",
220
+ month: "2-digit",
221
+ day: "2-digit"
222
+ });
200
223
  function toGplayDate(date) {
201
- const y = date.getUTCFullYear();
202
- const m = String(date.getUTCMonth() + 1).padStart(2, "0");
203
- const d = String(date.getUTCDate()).padStart(2, "0");
204
- return `${y}-${m}-${d}`;
224
+ return gplayDateFormatter.format(date);
205
225
  }
206
226
  function gplayDateToMs(gplayDate) {
207
227
  const y = gplayDate.slice(0, 4);
@@ -269,6 +289,53 @@ function rowToMetricSample(row, metricsToCollect, metricName, primaryMetric, pac
269
289
  attributes
270
290
  };
271
291
  }
292
+ function timestampToMs(ts) {
293
+ if (!ts || typeof ts.seconds !== "string") {
294
+ return null;
295
+ }
296
+ const seconds = Number(ts.seconds);
297
+ if (!Number.isFinite(seconds)) {
298
+ return null;
299
+ }
300
+ const nanos = typeof ts.nanos === "number" ? ts.nanos : 0;
301
+ return Math.round(seconds * 1e3 + nanos / 1e6);
302
+ }
303
+ function reviewToRatingSample(review, packageName) {
304
+ const userComment = review.comments?.find((c) => c.userComment)?.userComment;
305
+ if (!userComment) {
306
+ return null;
307
+ }
308
+ const rating = userComment.starRating;
309
+ if (typeof rating !== "number" || rating < 1 || rating > 5) {
310
+ return null;
311
+ }
312
+ const ts = timestampToMs(userComment.lastModified);
313
+ if (ts === null) {
314
+ return null;
315
+ }
316
+ const attributes = {
317
+ package_name: packageName,
318
+ review_id: review.reviewId ?? ""
319
+ };
320
+ if (userComment.reviewerLanguage) {
321
+ attributes["reviewer_language"] = userComment.reviewerLanguage;
322
+ }
323
+ if (userComment.device) {
324
+ attributes["device"] = userComment.device;
325
+ }
326
+ if (userComment.appVersionName) {
327
+ attributes["app_version_name"] = userComment.appVersionName;
328
+ }
329
+ if (typeof userComment.androidOsVersion === "number") {
330
+ attributes["android_os_version"] = userComment.androidOsVersion;
331
+ }
332
+ return {
333
+ name: GPLAY_APP_RATINGS_METRIC,
334
+ ts,
335
+ value: rating,
336
+ attributes
337
+ };
338
+ }
272
339
  var dateOnlyTimeline = z.object({
273
340
  startTime: z.object({
274
341
  year: z.number().int(),
@@ -292,38 +359,43 @@ function metricSetSchema() {
292
359
  nextPageToken: z.string().optional()
293
360
  });
294
361
  }
295
- var publisherListingSchema = z.object({
296
- defaultLanguage: z.string().optional(),
297
- listings: z.array(
362
+ var reviewsResponseSchema = z.object({
363
+ reviews: z.array(
298
364
  z.object({
299
- language: z.string(),
300
- title: z.string().optional(),
301
- shortDescription: z.string().optional(),
302
- fullDescription: z.string().optional()
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()
374
+ }).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()
381
+ }).optional()
382
+ })
383
+ ).optional()
303
384
  })
304
- ).optional()
385
+ ).optional(),
386
+ tokenPagination: z.object({ nextPageToken: z.string().optional() }).optional()
305
387
  });
306
388
  var googlePlayConsoleResources = defineResources({
307
389
  apps: {
308
390
  shape: "entity",
309
391
  filterable: [],
310
- description: "Android app the connector is syncing. One entity per configured packageName.",
311
- endpoint: "GET /androidpublisher/v3/applications/{packageName}/listings",
392
+ description: "Android app the connector is syncing. One entity per configured packageName, derived from the connector config; the Play Store listing title is only reachable through an Android Publisher edit and is not fetched.",
312
393
  fields: [
313
394
  {
314
395
  name: "package_name",
315
396
  description: "Reverse-DNS application id (e.g. com.example.app)."
316
- },
317
- {
318
- name: "title",
319
- description: "Play Store listing title in the default language. Empty if the listing has not been fetched yet."
320
- },
321
- {
322
- name: "default_language",
323
- description: "Default language code (BCP-47) configured for the Play Store listings."
324
397
  }
325
- ],
326
- responses: { listings: publisherListingSchema }
398
+ ]
327
399
  },
328
400
  gplay_crash_rate_by_day: {
329
401
  shape: "metric",
@@ -332,7 +404,10 @@ var googlePlayConsoleResources = defineResources({
332
404
  granularity: "day",
333
405
  endpoint: "POST /v1beta1/apps/{packageName}/crashRateMetricSet:query",
334
406
  dimensions: [
335
- { name: "date", description: "Calendar day of the metric sample (UTC)." },
407
+ {
408
+ name: "date",
409
+ description: "Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation)."
410
+ },
336
411
  {
337
412
  name: "package_name",
338
413
  description: "Reverse-DNS application id this sample is reported against."
@@ -347,7 +422,10 @@ var googlePlayConsoleResources = defineResources({
347
422
  granularity: "day",
348
423
  endpoint: "POST /v1beta1/apps/{packageName}/anrRateMetricSet:query",
349
424
  dimensions: [
350
- { name: "date", description: "Calendar day of the metric sample (UTC)." },
425
+ {
426
+ name: "date",
427
+ description: "Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation)."
428
+ },
351
429
  {
352
430
  name: "package_name",
353
431
  description: "Reverse-DNS application id this sample is reported against."
@@ -355,35 +433,54 @@ var googlePlayConsoleResources = defineResources({
355
433
  ],
356
434
  responses: { anr_rate: metricSetSchema() }
357
435
  },
358
- gplay_ratings_by_day: {
436
+ gplay_error_count_by_day: {
359
437
  shape: "metric",
360
- description: "Daily average user rating and rating count from the Play Developer Reporting API.",
361
- unit: "stars",
438
+ description: "Daily count of error reports (crashes + ANRs + handled errors) from the Play Developer Reporting API.",
439
+ unit: "reports",
362
440
  granularity: "day",
363
- endpoint: "POST /v1beta1/apps/{packageName}/ratingsMetricSet:query",
441
+ endpoint: "POST /v1beta1/apps/{packageName}/errorCountMetricSet:query",
364
442
  dimensions: [
365
- { name: "date", description: "Calendar day of the metric sample (UTC)." },
443
+ {
444
+ name: "date",
445
+ description: "Calendar day of the metric sample (America/Los_Angeles, the only timezone the Reporting API supports for daily aggregation)."
446
+ },
366
447
  {
367
448
  name: "package_name",
368
449
  description: "Reverse-DNS application id this sample is reported against."
369
450
  }
370
451
  ],
371
- responses: { ratings: metricSetSchema() }
452
+ responses: { errors: metricSetSchema() }
372
453
  },
373
- gplay_error_count_by_day: {
454
+ gplay_app_ratings: {
374
455
  shape: "metric",
375
- description: "Daily count of error reports (crashes + ANRs + handled errors) from the Play Developer Reporting API.",
376
- unit: "reports",
377
- granularity: "day",
378
- endpoint: "POST /v1beta1/apps/{packageName}/errorCountMetricSet:query",
456
+ description: "Rolling per-review star ratings sampled from the most-recent user reviews via the Android Publisher reviews API (default 200, configurable via reviewLimit). Each sample carries one review with its star rating (1-5) as the value.",
457
+ unit: "stars",
458
+ endpoint: "GET /androidpublisher/v3/applications/{packageName}/reviews",
459
+ notes: "Not the lifetime average shown on the Play Store. The reviews API only returns reviews from roughly the past week, so this is a rolling sample; average over a time window downstream for a smoothed rating.",
379
460
  dimensions: [
380
- { name: "date", description: "Calendar day of the metric sample (UTC)." },
381
461
  {
382
462
  name: "package_name",
383
- description: "Reverse-DNS application id this sample is reported against."
463
+ description: "Reverse-DNS application id this review was filed against."
464
+ },
465
+ { name: "review_id", description: "Unique identifier of the review." },
466
+ {
467
+ name: "reviewer_language",
468
+ description: "BCP-47 language code the review was written in."
469
+ },
470
+ {
471
+ name: "device",
472
+ description: "Codename of the device the review was filed from."
473
+ },
474
+ {
475
+ name: "app_version_name",
476
+ description: "App version name the reviewer was running."
477
+ },
478
+ {
479
+ name: "android_os_version",
480
+ description: "Android SDK version the reviewer was running."
384
481
  }
385
482
  ],
386
- responses: { errors: metricSetSchema() }
483
+ responses: { reviews: reviewsResponseSchema }
387
484
  }
388
485
  });
389
486
  var id = "google-play-console";
@@ -396,7 +493,8 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
396
493
  return new _GooglePlayConsoleConnector(
397
494
  {
398
495
  packageName: parsed.packageName,
399
- lookbackDays: parsed.lookbackDays
496
+ lookbackDays: parsed.lookbackDays,
497
+ reviewLimit: parsed.reviewLimit
400
498
  },
401
499
  {
402
500
  serviceAccountJson: parsed.serviceAccountJson
@@ -434,18 +532,6 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
434
532
  this.cachedToken = await this.fetchOAuthToken(url, body, signal);
435
533
  return this.cachedToken.token;
436
534
  }
437
- async fetchListings(accessToken, signal) {
438
- const url = `${PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(this.settings.packageName)}/listings`;
439
- const res = await this.get(url, {
440
- resource: "listings",
441
- headers: {
442
- Authorization: `Bearer ${accessToken}`,
443
- "User-Agent": connectorUserAgent("google-play-console")
444
- },
445
- signal
446
- });
447
- return res.body;
448
- }
449
535
  async runMetricQuery(accessToken, cfg, dateRange, pageToken, signal) {
450
536
  const url = `${REPORTING_BASE}/v1beta1/apps/${encodeURIComponent(this.settings.packageName)}/${cfg.metricSet}:query`;
451
537
  const [sy, sm, sd] = dateRange.startDate.split("-").map(Number);
@@ -458,13 +544,13 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
458
544
  year: sy,
459
545
  month: sm,
460
546
  day: sd,
461
- timeZone: { id: "UTC" }
547
+ timeZone: { id: DAILY_TIME_ZONE }
462
548
  },
463
549
  endTime: {
464
550
  year: ey,
465
551
  month: em,
466
552
  day: ed,
467
- timeZone: { id: "UTC" }
553
+ timeZone: { id: DAILY_TIME_ZONE }
468
554
  }
469
555
  }
470
556
  };
@@ -472,7 +558,7 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
472
558
  body["pageToken"] = pageToken;
473
559
  }
474
560
  const res = await this.post(url, {
475
- resource: cfg.metricSet,
561
+ resource: cfg.responseTag,
476
562
  headers: {
477
563
  Authorization: `Bearer ${accessToken}`,
478
564
  "Content-Type": "application/json",
@@ -483,33 +569,58 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
483
569
  });
484
570
  return res.body;
485
571
  }
486
- async syncApps(accessToken, storage, signal) {
487
- let title = "";
488
- let defaultLanguage = "";
489
- try {
490
- const listings = await this.fetchListings(accessToken, signal);
491
- defaultLanguage = listings.defaultLanguage ?? "";
492
- const list = listings.listings ?? [];
493
- const def = list.find((l) => l.language === defaultLanguage) ?? list[0];
494
- title = def?.title ?? "";
495
- } catch (err) {
496
- this.logger.warn(
497
- "Failed to fetch Play Console listings; emitting app entity with empty title",
498
- { error: err.message }
499
- );
500
- }
572
+ async syncApps(storage) {
501
573
  const entity = {
502
574
  type: "apps",
503
575
  id: this.settings.packageName,
504
576
  attributes: {
505
- package_name: this.settings.packageName,
506
- title,
507
- default_language: defaultLanguage
577
+ package_name: this.settings.packageName
508
578
  },
509
579
  updated_at: Date.now()
510
580
  };
511
581
  await storage.entities([entity], { types: ["apps"] });
512
582
  }
583
+ async fetchReviews(accessToken, signal) {
584
+ const reviews = [];
585
+ const base = `${PUBLISHER_BASE}/androidpublisher/v3/applications/${encodeURIComponent(this.settings.packageName)}/reviews`;
586
+ let token = void 0;
587
+ for (let page = 0; page < MAX_REVIEW_PAGES; page++) {
588
+ signal?.throwIfAborted();
589
+ const params = new URLSearchParams({
590
+ maxResults: String(REVIEWS_PAGE_SIZE)
591
+ });
592
+ if (token) {
593
+ params.set("token", token);
594
+ }
595
+ const res = await this.get(
596
+ `${base}?${params.toString()}`,
597
+ {
598
+ resource: "reviews",
599
+ headers: {
600
+ Authorization: `Bearer ${accessToken}`,
601
+ "User-Agent": connectorUserAgent("google-play-console")
602
+ },
603
+ signal
604
+ }
605
+ );
606
+ reviews.push(...res.body.reviews ?? []);
607
+ token = res.body.tokenPagination?.nextPageToken;
608
+ if (!token) {
609
+ return reviews;
610
+ }
611
+ }
612
+ this.logger.warn(
613
+ `Stopped paginating Play Console reviews after ${MAX_REVIEW_PAGES} pages; the most-recent reviews are still ranked first but older reviews may be omitted`,
614
+ { packageName: this.settings.packageName }
615
+ );
616
+ return reviews;
617
+ }
618
+ async syncReviews(accessToken, storage, signal) {
619
+ const limit = this.settings.reviewLimit ?? DEFAULT_REVIEW_LIMIT;
620
+ const reviews = await this.fetchReviews(accessToken, signal);
621
+ const samples = reviews.map((review) => reviewToRatingSample(review, this.settings.packageName)).filter((s) => s !== null).sort((a, b) => b.ts - a.ts).slice(0, limit);
622
+ await storage.metrics(samples, { names: [GPLAY_APP_RATINGS_METRIC] });
623
+ }
513
624
  async drainMetricPhase(accessToken, cfg, dateRange, signal) {
514
625
  const rows = [];
515
626
  let pageToken = void 0;
@@ -542,17 +653,26 @@ var GooglePlayConsoleConnector = class _GooglePlayConsoleConnector extends BaseC
542
653
  }
543
654
  return accessToken;
544
655
  };
545
- const resumeIdx = cursor ? PHASE_ORDER.indexOf(cursor.phase) : -1;
656
+ const phases = selectActivePhases(
657
+ (resource) => RESOURCE_TO_PHASE[resource] ?? "apps",
658
+ PHASE_ORDER,
659
+ options.resources ? [...options.resources] : void 0
660
+ );
661
+ const resumeIdx = cursor ? phases.indexOf(cursor.phase) : -1;
546
662
  const startIdx = resumeIdx >= 0 ? resumeIdx : 0;
547
- for (let i = startIdx; i < PHASE_ORDER.length; i++) {
548
- const phase = PHASE_ORDER[i];
663
+ for (let i = startIdx; i < phases.length; i++) {
664
+ const phase = phases[i];
549
665
  if (signal?.aborted) {
550
666
  return { done: false, cursor: { phase, dateRange } };
551
667
  }
552
668
  try {
553
669
  if (phase === "apps") {
670
+ await this.syncApps(storage);
671
+ continue;
672
+ }
673
+ if (phase === "reviews") {
554
674
  const token2 = await getToken(signal);
555
- await this.syncApps(token2, storage, signal);
675
+ await this.syncReviews(token2, storage, signal);
556
676
  continue;
557
677
  }
558
678
  const cfg = METRIC_PHASE_CONFIGS[phase];