@gpc-cli/core 0.10.6 → 0.10.8
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 +26 -21
- package/dist/index.d.ts +40 -1
- package/dist/index.js +198 -69
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2686,6 +2686,100 @@ var METRIC_SET_METRICS = {
|
|
|
2686
2686
|
],
|
|
2687
2687
|
errorCountMetricSet: ["errorReportCount", "distinctUsers"]
|
|
2688
2688
|
};
|
|
2689
|
+
var REQUIRED_DIMENSIONS = {
|
|
2690
|
+
slowStartRateMetricSet: ["startType"],
|
|
2691
|
+
errorCountMetricSet: ["reportType"]
|
|
2692
|
+
};
|
|
2693
|
+
function withRequiredDimensions(metricSet, query) {
|
|
2694
|
+
const required = REQUIRED_DIMENSIONS[metricSet];
|
|
2695
|
+
if (!required) return query;
|
|
2696
|
+
const dimensions = [...required];
|
|
2697
|
+
for (const dim2 of query.dimensions ?? []) {
|
|
2698
|
+
if (!dimensions.includes(dim2)) dimensions.push(dim2);
|
|
2699
|
+
}
|
|
2700
|
+
return { ...query, dimensions };
|
|
2701
|
+
}
|
|
2702
|
+
function readMetricValue(row, metric) {
|
|
2703
|
+
const metrics = row?.metrics;
|
|
2704
|
+
if (!metrics || typeof metrics !== "object") return void 0;
|
|
2705
|
+
let entry;
|
|
2706
|
+
if (Array.isArray(metrics)) {
|
|
2707
|
+
entry = metric ? metrics.find((m) => m?.metric === metric) : metrics[0];
|
|
2708
|
+
} else {
|
|
2709
|
+
const map = metrics;
|
|
2710
|
+
const key = metric ?? Object.keys(map)[0];
|
|
2711
|
+
entry = key ? map[key] : void 0;
|
|
2712
|
+
}
|
|
2713
|
+
const raw = entry?.decimalValue?.value;
|
|
2714
|
+
if (raw === void 0) return void 0;
|
|
2715
|
+
const value = Number(raw);
|
|
2716
|
+
return Number.isFinite(value) ? value : void 0;
|
|
2717
|
+
}
|
|
2718
|
+
function rowDateKey(row) {
|
|
2719
|
+
const t = row.startTime;
|
|
2720
|
+
if (!t) return "";
|
|
2721
|
+
const pad = (n) => String(n ?? 0).padStart(2, "0");
|
|
2722
|
+
return `${t.year ?? 0}-${pad(t.month)}-${pad(t.day)}T${pad(t.hours)}`;
|
|
2723
|
+
}
|
|
2724
|
+
function rowSliceKey(row) {
|
|
2725
|
+
const dims = row.dimensions;
|
|
2726
|
+
if (!dims || typeof dims !== "object") return "";
|
|
2727
|
+
const pairs = Array.isArray(dims) ? dims.map((d) => [d?.dimension ?? "", d?.stringValue ?? d?.int64Value ?? ""]) : Object.entries(dims).map(([k, d]) => [
|
|
2728
|
+
k,
|
|
2729
|
+
d?.stringValue ?? d?.int64Value ?? ""
|
|
2730
|
+
]);
|
|
2731
|
+
return JSON.stringify(pairs.sort());
|
|
2732
|
+
}
|
|
2733
|
+
function latestWorstValue(rows, metric) {
|
|
2734
|
+
if (!rows || rows.length === 0) return void 0;
|
|
2735
|
+
const latest = rows.map(rowDateKey).reduce((a, b) => b > a ? b : a);
|
|
2736
|
+
let worst;
|
|
2737
|
+
for (const row of rows) {
|
|
2738
|
+
if (rowDateKey(row) !== latest) continue;
|
|
2739
|
+
const value = readMetricValue(row, metric);
|
|
2740
|
+
if (value !== void 0 && (worst === void 0 || value > worst)) worst = value;
|
|
2741
|
+
}
|
|
2742
|
+
return worst;
|
|
2743
|
+
}
|
|
2744
|
+
function worstSliceAverage(rows, metric) {
|
|
2745
|
+
const sums = /* @__PURE__ */ new Map();
|
|
2746
|
+
for (const row of rows ?? []) {
|
|
2747
|
+
const value = readMetricValue(row, metric);
|
|
2748
|
+
if (value === void 0) continue;
|
|
2749
|
+
const key = rowSliceKey(row);
|
|
2750
|
+
const entry = sums.get(key) ?? { total: 0, count: 0 };
|
|
2751
|
+
entry.total += value;
|
|
2752
|
+
entry.count++;
|
|
2753
|
+
sums.set(key, entry);
|
|
2754
|
+
}
|
|
2755
|
+
let worst;
|
|
2756
|
+
for (const { total, count } of sums.values()) {
|
|
2757
|
+
const avg = total / count;
|
|
2758
|
+
if (worst === void 0 || avg > worst) worst = avg;
|
|
2759
|
+
}
|
|
2760
|
+
return worst;
|
|
2761
|
+
}
|
|
2762
|
+
function assertRateThreshold(label, threshold) {
|
|
2763
|
+
if (!Number.isFinite(threshold) || threshold < 0) {
|
|
2764
|
+
throw new GpcError(
|
|
2765
|
+
`Invalid ${label} threshold: expected a fraction such as 0.02.`,
|
|
2766
|
+
"THRESHOLD_INVALID",
|
|
2767
|
+
2,
|
|
2768
|
+
"Write rates as fractions: 0.02 means 2%."
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
if (threshold >= 1) {
|
|
2772
|
+
throw new GpcError(
|
|
2773
|
+
`${label} threshold ${threshold} can never be reached: rates are between 0 and 1.`,
|
|
2774
|
+
"THRESHOLD_INVALID",
|
|
2775
|
+
2,
|
|
2776
|
+
`Write rates as fractions: ${threshold / 100} means ${threshold}%. Check vitals.thresholds in your config too.`
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
function isGamesOnlyMetricError(err) {
|
|
2781
|
+
return err?.code === "API_METRIC_GAMES_ONLY";
|
|
2782
|
+
}
|
|
2689
2783
|
async function getFreshnessEndDate(reporting, packageName, metricSet, aggregation = "DAILY") {
|
|
2690
2784
|
try {
|
|
2691
2785
|
const info = await reporting.getMetricSetFreshness(packageName, metricSet);
|
|
@@ -2699,12 +2793,16 @@ async function getFreshnessEndDate(reporting, packageName, metricSet, aggregatio
|
|
|
2699
2793
|
}
|
|
2700
2794
|
return void 0;
|
|
2701
2795
|
}
|
|
2796
|
+
async function latestQueryableEndDate(reporting, packageName, metricSet) {
|
|
2797
|
+
const fallback = new Date(Date.now() - 2 * 24 * 60 * 60 * 1e3);
|
|
2798
|
+
const freshnessEnd = await getFreshnessEndDate(reporting, packageName, metricSet);
|
|
2799
|
+
return freshnessEnd ?? fallback;
|
|
2800
|
+
}
|
|
2702
2801
|
function buildQuery(metricSet, options, freshnessEnd) {
|
|
2703
2802
|
const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
|
|
2704
2803
|
const days = options?.days ?? 30;
|
|
2705
2804
|
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2706
|
-
const
|
|
2707
|
-
const end = freshnessEnd && freshnessEnd < yesterday ? freshnessEnd : yesterday;
|
|
2805
|
+
const end = freshnessEnd ?? new Date(Date.now() - 2 * DAY_MS);
|
|
2708
2806
|
const start = new Date(end.getTime() - days * DAY_MS);
|
|
2709
2807
|
const query = {
|
|
2710
2808
|
metrics,
|
|
@@ -2725,7 +2823,7 @@ function buildQuery(metricSet, options, freshnessEnd) {
|
|
|
2725
2823
|
if (options?.dimension) {
|
|
2726
2824
|
query.dimensions = [options.dimension];
|
|
2727
2825
|
}
|
|
2728
|
-
return query;
|
|
2826
|
+
return withRequiredDimensions(metricSet, query);
|
|
2729
2827
|
}
|
|
2730
2828
|
async function queryMetric(reporting, packageName, metricSet, options) {
|
|
2731
2829
|
const freshnessEnd = await getFreshnessEndDate(
|
|
@@ -2761,16 +2859,29 @@ async function getVitalsOverview(reporting, packageName) {
|
|
|
2761
2859
|
})
|
|
2762
2860
|
);
|
|
2763
2861
|
const overview = {};
|
|
2862
|
+
const unavailable = [];
|
|
2863
|
+
let firstFailure;
|
|
2864
|
+
let succeeded = 0;
|
|
2764
2865
|
for (let i = 0; i < metricSets.length; i++) {
|
|
2765
2866
|
const entry = metricSets[i];
|
|
2766
|
-
if (!entry) continue;
|
|
2767
|
-
const key = entry[1];
|
|
2768
2867
|
const result = results[i];
|
|
2769
|
-
if (!result) continue;
|
|
2868
|
+
if (!entry || !result) continue;
|
|
2869
|
+
const key = entry[1];
|
|
2770
2870
|
if (result.status === "fulfilled") {
|
|
2771
2871
|
overview[key] = result.value.rows || [];
|
|
2872
|
+
succeeded++;
|
|
2873
|
+
continue;
|
|
2772
2874
|
}
|
|
2875
|
+
const err = result.reason;
|
|
2876
|
+
if (!isGamesOnlyMetricError(err)) firstFailure ??= result.reason;
|
|
2877
|
+
unavailable.push({
|
|
2878
|
+
metric: key,
|
|
2879
|
+
code: typeof err?.code === "string" ? err.code : "UNKNOWN_ERROR",
|
|
2880
|
+
reason: isGamesOnlyMetricError(err) ? "Only available for apps categorised as games." : typeof err?.message === "string" ? err.message : String(result.reason)
|
|
2881
|
+
});
|
|
2773
2882
|
}
|
|
2883
|
+
if (succeeded === 0 && firstFailure !== void 0) throw firstFailure;
|
|
2884
|
+
if (unavailable.length > 0) overview.unavailable = unavailable;
|
|
2774
2885
|
return overview;
|
|
2775
2886
|
}
|
|
2776
2887
|
async function getVitalsCrashes(reporting, packageName, options) {
|
|
@@ -2780,8 +2891,7 @@ async function getVitalsAnr(reporting, packageName, options) {
|
|
|
2780
2891
|
return queryMetric(reporting, packageName, "anrRateMetricSet", options);
|
|
2781
2892
|
}
|
|
2782
2893
|
async function getVitalsStartup(reporting, packageName, options) {
|
|
2783
|
-
|
|
2784
|
-
return queryMetric(reporting, packageName, "slowStartRateMetricSet", opts);
|
|
2894
|
+
return queryMetric(reporting, packageName, "slowStartRateMetricSet", options);
|
|
2785
2895
|
}
|
|
2786
2896
|
async function getVitalsRendering(reporting, packageName, options) {
|
|
2787
2897
|
return queryMetric(reporting, packageName, "slowRenderingRateMetricSet", options);
|
|
@@ -2811,8 +2921,7 @@ async function getVitalsMemoryBitmap(reporting, packageName, options) {
|
|
|
2811
2921
|
});
|
|
2812
2922
|
}
|
|
2813
2923
|
async function getVitalsErrorCount(reporting, packageName, options) {
|
|
2814
|
-
|
|
2815
|
-
return queryMetric(reporting, packageName, "errorCountMetricSet", opts);
|
|
2924
|
+
return queryMetric(reporting, packageName, "errorCountMetricSet", options);
|
|
2816
2925
|
}
|
|
2817
2926
|
async function getVitalsAnomalies(reporting, packageName) {
|
|
2818
2927
|
return reporting.getAnomalies(packageName);
|
|
@@ -2836,7 +2945,7 @@ async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
|
|
|
2836
2945
|
month: d.getUTCMonth() + 1,
|
|
2837
2946
|
day: d.getUTCDate()
|
|
2838
2947
|
});
|
|
2839
|
-
const makeQuery = (start, end) => ({
|
|
2948
|
+
const makeQuery = (start, end) => withRequiredDimensions(metricSet, {
|
|
2840
2949
|
metrics,
|
|
2841
2950
|
timelineSpec: {
|
|
2842
2951
|
aggregationPeriod: "DAILY",
|
|
@@ -2940,7 +3049,8 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
|
|
|
2940
3049
|
threshold,
|
|
2941
3050
|
metricSet = "crashRateMetricSet",
|
|
2942
3051
|
onHalt,
|
|
2943
|
-
onPoll
|
|
3052
|
+
onPoll,
|
|
3053
|
+
onError
|
|
2944
3054
|
} = options;
|
|
2945
3055
|
let stopped = false;
|
|
2946
3056
|
let haltTriggered = false;
|
|
@@ -2948,16 +3058,15 @@ function watchVitalsWithAutoHalt(reporting, packageName, options) {
|
|
|
2948
3058
|
if (stopped) return;
|
|
2949
3059
|
try {
|
|
2950
3060
|
const result = await queryMetric(reporting, packageName, metricSet, { days: 1 });
|
|
2951
|
-
const
|
|
2952
|
-
const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
|
|
2953
|
-
const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
|
|
3061
|
+
const value = latestWorstValue(result.rows, METRIC_SET_METRICS[metricSet]?.[0]);
|
|
2954
3062
|
const breached = value !== void 0 && value > threshold;
|
|
2955
3063
|
onPoll?.(value, breached);
|
|
2956
3064
|
if (breached && !haltTriggered && onHalt) {
|
|
2957
3065
|
haltTriggered = true;
|
|
2958
3066
|
await onHalt(value);
|
|
2959
3067
|
}
|
|
2960
|
-
} catch {
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
onError?.(err);
|
|
2961
3070
|
}
|
|
2962
3071
|
if (!stopped) {
|
|
2963
3072
|
timerId = setTimeout(poll, intervalMs);
|
|
@@ -3023,33 +3132,28 @@ var WARN_MARGIN = 0.2;
|
|
|
3023
3132
|
function toApiDate(d) {
|
|
3024
3133
|
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
|
3025
3134
|
}
|
|
3026
|
-
async function queryVitalForStatus(reporting, packageName, metricSet, days, offsetDays = 0) {
|
|
3135
|
+
async function queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, offsetDays = 0) {
|
|
3027
3136
|
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3028
|
-
const baseMs =
|
|
3137
|
+
const baseMs = latestEnd.getTime() - offsetDays * DAY_MS;
|
|
3029
3138
|
const end = new Date(baseMs);
|
|
3030
3139
|
const start = new Date(baseMs - days * DAY_MS);
|
|
3031
3140
|
const metrics = METRIC_SET_METRICS2[metricSet] ?? ["distinctUsers"];
|
|
3032
|
-
const query = {
|
|
3141
|
+
const query = withRequiredDimensions(metricSet, {
|
|
3033
3142
|
metrics,
|
|
3034
3143
|
timelineSpec: {
|
|
3035
3144
|
aggregationPeriod: "DAILY",
|
|
3036
3145
|
startTime: toApiDate(start),
|
|
3037
3146
|
endTime: toApiDate(end)
|
|
3038
3147
|
}
|
|
3039
|
-
};
|
|
3148
|
+
});
|
|
3040
3149
|
const result = await reporting.queryMetricSet(packageName, metricSet, query);
|
|
3041
|
-
|
|
3042
|
-
const values = result.rows.map((row) => {
|
|
3043
|
-
const firstKey = Object.keys(row.metrics)[0];
|
|
3044
|
-
return firstKey ? Number(row.metrics[firstKey]?.decimalValue?.value) : NaN;
|
|
3045
|
-
}).filter((v) => !isNaN(v));
|
|
3046
|
-
if (values.length === 0) return void 0;
|
|
3047
|
-
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
3150
|
+
return worstSliceAverage(result.rows, metrics[0]);
|
|
3048
3151
|
}
|
|
3049
3152
|
async function queryVitalWithTrend(reporting, packageName, metricSet, days) {
|
|
3153
|
+
const latestEnd = await latestQueryableEndDate(reporting, packageName, metricSet);
|
|
3050
3154
|
const [current, previous] = await Promise.all([
|
|
3051
|
-
queryVitalForStatus(reporting, packageName, metricSet, days, 0),
|
|
3052
|
-
queryVitalForStatus(reporting, packageName, metricSet, days, days)
|
|
3155
|
+
queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, 0),
|
|
3156
|
+
queryVitalForStatus(reporting, packageName, metricSet, days, latestEnd, days)
|
|
3053
3157
|
]);
|
|
3054
3158
|
let trend = null;
|
|
3055
3159
|
if (current !== void 0 && previous !== void 0) {
|
|
@@ -3147,7 +3251,7 @@ async function getAppStatus(client, reporting, packageName, options = {}) {
|
|
|
3147
3251
|
if (slowStartResult.status === "rejected")
|
|
3148
3252
|
sectionErrors.push(`vitals/slowStarts: ${String(slowStartResult.reason)}`);
|
|
3149
3253
|
const slowRender = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : SKIPPED_VITAL;
|
|
3150
|
-
if (slowRenderResult.status === "rejected")
|
|
3254
|
+
if (slowRenderResult.status === "rejected" && !isGamesOnlyMetricError(slowRenderResult.reason))
|
|
3151
3255
|
sectionErrors.push(`vitals/slowRender: ${String(slowRenderResult.reason)}`);
|
|
3152
3256
|
const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value.reviews : [];
|
|
3153
3257
|
if (reviewsResult.status === "rejected")
|
|
@@ -3528,31 +3632,34 @@ async function queryWatchMetric(reporting, packageName, metric) {
|
|
|
3528
3632
|
const metricSet = METRIC_TO_SET[metric];
|
|
3529
3633
|
const metricKey = METRIC_KEY[metric];
|
|
3530
3634
|
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3531
|
-
const end = new Date(Date.now() - 2 * DAY_MS);
|
|
3532
|
-
const start = new Date(end.getTime() - 7 * DAY_MS);
|
|
3533
3635
|
const toDate = (d) => ({
|
|
3534
3636
|
year: d.getUTCFullYear(),
|
|
3535
3637
|
month: d.getUTCMonth() + 1,
|
|
3536
3638
|
day: d.getUTCDate()
|
|
3537
3639
|
});
|
|
3538
3640
|
try {
|
|
3539
|
-
const
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
return
|
|
3554
|
-
} catch {
|
|
3555
|
-
|
|
3641
|
+
const end = await latestQueryableEndDate(reporting, packageName, metricSet);
|
|
3642
|
+
const start = new Date(end.getTime() - 7 * DAY_MS);
|
|
3643
|
+
const result = await reporting.queryMetricSet(
|
|
3644
|
+
packageName,
|
|
3645
|
+
metricSet,
|
|
3646
|
+
withRequiredDimensions(metricSet, {
|
|
3647
|
+
metrics: [metricKey, "distinctUsers"],
|
|
3648
|
+
timelineSpec: {
|
|
3649
|
+
aggregationPeriod: "DAILY",
|
|
3650
|
+
startTime: toDate(start),
|
|
3651
|
+
endTime: toDate(end)
|
|
3652
|
+
}
|
|
3653
|
+
})
|
|
3654
|
+
);
|
|
3655
|
+
return { value: latestWorstValue(result.rows, metricKey) };
|
|
3656
|
+
} catch (err) {
|
|
3657
|
+
const code = err?.code;
|
|
3658
|
+
return {
|
|
3659
|
+
value: void 0,
|
|
3660
|
+
error: typeof code === "string" ? code : err instanceof Error ? err.message : String(err),
|
|
3661
|
+
gamesOnly: isGamesOnlyMetricError(err)
|
|
3662
|
+
};
|
|
3556
3663
|
}
|
|
3557
3664
|
}
|
|
3558
3665
|
async function fetchRollout(client, packageName, track) {
|
|
@@ -3631,12 +3738,17 @@ async function runWatch(client, reporting, config, callbacks) {
|
|
|
3631
3738
|
"Watch interval must be at least 60 seconds",
|
|
3632
3739
|
"WATCH_USAGE_ERROR",
|
|
3633
3740
|
2,
|
|
3634
|
-
"Use --interval 60 or higher. Google vitals data is delayed
|
|
3741
|
+
"Use --interval 60 or higher. Google vitals data is delayed 2-4 days, so frequent polling wastes API quota."
|
|
3635
3742
|
);
|
|
3636
3743
|
}
|
|
3744
|
+
for (const [metric, threshold] of Object.entries(config.thresholds)) {
|
|
3745
|
+
if (metric !== "errorCount" && threshold !== void 0) assertRateThreshold(metric, threshold);
|
|
3746
|
+
}
|
|
3637
3747
|
let running = true;
|
|
3638
3748
|
const events = [];
|
|
3639
3749
|
let breachCount = 0;
|
|
3750
|
+
let unreadableRounds = 0;
|
|
3751
|
+
let lastRoundUnreadable = false;
|
|
3640
3752
|
let halted = false;
|
|
3641
3753
|
const startTime = Date.now();
|
|
3642
3754
|
const cleanup = () => {
|
|
@@ -3652,19 +3764,24 @@ async function runWatch(client, reporting, config, callbacks) {
|
|
|
3652
3764
|
const vitals = {};
|
|
3653
3765
|
const metricResults = await Promise.all(
|
|
3654
3766
|
config.metrics.map(async (metric) => {
|
|
3655
|
-
const
|
|
3656
|
-
return { metric,
|
|
3767
|
+
const reading = await queryWatchMetric(reporting, config.packageName, metric);
|
|
3768
|
+
return { metric, ...reading };
|
|
3657
3769
|
})
|
|
3658
3770
|
);
|
|
3659
|
-
|
|
3771
|
+
let unreadable = false;
|
|
3772
|
+
for (const { metric, value, error, gamesOnly } of metricResults) {
|
|
3660
3773
|
const threshold = config.thresholds[metric];
|
|
3774
|
+
if (error && threshold !== void 0 && !gamesOnly) unreadable = true;
|
|
3661
3775
|
const result = threshold !== void 0 ? checkThreshold(value, threshold) : { breached: false };
|
|
3662
3776
|
vitals[metric] = {
|
|
3663
3777
|
value,
|
|
3664
3778
|
threshold,
|
|
3665
|
-
breached: result.breached
|
|
3779
|
+
breached: result.breached,
|
|
3780
|
+
...error ? { error } : {}
|
|
3666
3781
|
};
|
|
3667
3782
|
}
|
|
3783
|
+
if (unreadable) unreadableRounds++;
|
|
3784
|
+
lastRoundUnreadable = unreadable;
|
|
3668
3785
|
const breaches = Object.entries(vitals).filter(([, v]) => v.breached).map(([k]) => k);
|
|
3669
3786
|
const event = {
|
|
3670
3787
|
round,
|
|
@@ -3716,6 +3833,8 @@ async function runWatch(client, reporting, config, callbacks) {
|
|
|
3716
3833
|
durationMs: Date.now() - startTime,
|
|
3717
3834
|
finalRollout: events[events.length - 1]?.rollout?.userFraction ?? null,
|
|
3718
3835
|
breachCount,
|
|
3836
|
+
unreadableRounds,
|
|
3837
|
+
lastRoundUnreadable,
|
|
3719
3838
|
halted,
|
|
3720
3839
|
events
|
|
3721
3840
|
};
|
|
@@ -5411,10 +5530,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
|
|
|
5411
5530
|
if (state.gates) {
|
|
5412
5531
|
if (state.gates.crashes?.max !== void 0) {
|
|
5413
5532
|
const result = await getVitalsCrashes(reportingClient, packageName, { days: 1 });
|
|
5414
|
-
const
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
if (value !== void 0 && value > state.gates.crashes.max / 100) {
|
|
5533
|
+
const value = latestWorstValue(result.rows, "crashRate");
|
|
5534
|
+
if (value === void 0) throw trainGateNoData("Crash", "crashes");
|
|
5535
|
+
if (value > state.gates.crashes.max / 100) {
|
|
5418
5536
|
state.status = "paused";
|
|
5419
5537
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5420
5538
|
await writeTrainState(packageName, state);
|
|
@@ -5428,10 +5546,9 @@ async function advanceTrain(apiClient, reportingClient, packageName) {
|
|
|
5428
5546
|
}
|
|
5429
5547
|
if (state.gates.anr?.max !== void 0) {
|
|
5430
5548
|
const result = await getVitalsAnr(reportingClient, packageName, { days: 1 });
|
|
5431
|
-
const
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
if (value !== void 0 && value > state.gates.anr.max / 100) {
|
|
5549
|
+
const value = latestWorstValue(result.rows, "anrRate");
|
|
5550
|
+
if (value === void 0) throw trainGateNoData("ANR", "anr");
|
|
5551
|
+
if (value > state.gates.anr.max / 100) {
|
|
5435
5552
|
state.status = "paused";
|
|
5436
5553
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5437
5554
|
await writeTrainState(packageName, state);
|
|
@@ -5463,6 +5580,14 @@ async function executeStage(apiClient, packageName, state, stageIndex) {
|
|
|
5463
5580
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5464
5581
|
await writeTrainState(packageName, state);
|
|
5465
5582
|
}
|
|
5583
|
+
function trainGateNoData(label, command) {
|
|
5584
|
+
return new GpcError(
|
|
5585
|
+
`${label} gate has no data to check, so the train was not advanced.`,
|
|
5586
|
+
"TRAIN_GATE_NO_DATA",
|
|
5587
|
+
6,
|
|
5588
|
+
`Vitals data often lags 2-4 days. Run the train again later, or check with: gpc vitals ${command} --days 3`
|
|
5589
|
+
);
|
|
5590
|
+
}
|
|
5466
5591
|
|
|
5467
5592
|
// src/commands/games.ts
|
|
5468
5593
|
import { mkdir as mkdir5, readdir as readdir4, readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
|
|
@@ -6683,7 +6808,7 @@ var manifestScanner = {
|
|
|
6683
6808
|
ruleId: "geofencing-foreground-service",
|
|
6684
6809
|
severity: "warning",
|
|
6685
6810
|
title: `Possible geofencing via foreground service "${service.name}"`,
|
|
6686
|
-
message: `Service "${service.name}" uses foregroundServiceType "location" and the app declares ACCESS_BACKGROUND_LOCATION. Google Play no longer approves geofencing as a foreground service use case (sensitive-permissions policy announced April 15, 2026)
|
|
6811
|
+
message: `Service "${service.name}" uses foregroundServiceType "location" and the app declares ACCESS_BACKGROUND_LOCATION. Google Play no longer approves geofencing as a foreground service use case (sensitive-permissions policy announced April 15, 2026).`,
|
|
6687
6812
|
suggestion: 'If this service performs geofencing, migrate to the Geofencing API (LocationServices.getGeofencingClient) or WorkManager/AlarmManager. If this is legitimate background location tracking (navigation, fitness), suppress this rule via .preflightrc.json: "disabledRules": ["geofencing-foreground-service"].',
|
|
6688
6813
|
policyUrl: "https://support.google.com/googleplay/android-developer/answer/16926792"
|
|
6689
6814
|
});
|
|
@@ -6773,7 +6898,7 @@ var RESTRICTED_PERMISSIONS = [
|
|
|
6773
6898
|
permission: "android.permission.READ_CALL_LOG",
|
|
6774
6899
|
severity: "critical",
|
|
6775
6900
|
title: "READ_CALL_LOG requires declaration form",
|
|
6776
|
-
message: "READ_CALL_LOG is restricted to default phone/assistant handler apps.
|
|
6901
|
+
message: "READ_CALL_LOG is restricted to default phone/assistant handler apps. From January 27, 2027, account verification via phone call is no longer an accepted use case for the Call Log permissions.",
|
|
6777
6902
|
suggestion: "Remove READ_CALL_LOG unless your app is a default phone or assistant handler. For phone-number verification, use the Digital Credentials API; for one-time codes, use the SMS Retriever API instead of reading the call log.",
|
|
6778
6903
|
policyUrl: "https://support.google.com/googleplay/android-developer/answer/10208820"
|
|
6779
6904
|
},
|
|
@@ -6916,14 +7041,14 @@ var permissionsScanner = {
|
|
|
6916
7041
|
].filter((p) => manifest.permissions.includes(p) && !allowed.has(p));
|
|
6917
7042
|
if (contactsPerms.length > 0) {
|
|
6918
7043
|
const names = contactsPerms.map((p) => p.split(".").pop()).join(", ");
|
|
6919
|
-
const enforced = manifest.targetSdk >= 37 ? ` Your app targets API ${manifest.targetSdk}, so this
|
|
7044
|
+
const enforced = manifest.targetSdk >= 37 ? ` Your app targets API ${manifest.targetSdk}, so this applies to it.` : "";
|
|
6920
7045
|
findings.push({
|
|
6921
7046
|
scanner: "permissions",
|
|
6922
7047
|
ruleId: "contacts-permission-broad",
|
|
6923
7048
|
severity: "warning",
|
|
6924
7049
|
title: "Broad contacts access requires migration to Contact Picker",
|
|
6925
|
-
message: `Your app declares ${names}. Google Play's Contacts Permissions policy requires the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) unless broad access is core to your app. Mandatory for apps targeting Android 17 (API 37+) on
|
|
6926
|
-
suggestion: "Migrate to the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) for user-initiated contact selection, or
|
|
7050
|
+
message: `Your app declares ${names}. Google Play's Contacts Permissions policy requires the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) unless broad access is core to your app and you submit a Play Console declaration. Mandatory for apps targeting Android 17 (API 37+) on January 27, 2027.${enforced}`,
|
|
7051
|
+
suggestion: "Migrate to the Android Contact Picker (Intent.ACTION_PICK_CONTACTS) for user-initiated contact selection, or submit the contacts declaration in Play Console before January 27, 2027 if the picker cannot serve your core functionality. Remove READ_CONTACTS/WRITE_CONTACTS unless your app is a dialer, messaging, or contacts management app.",
|
|
6927
7052
|
policyUrl: "https://support.google.com/googleplay/android-developer/answer/16935362"
|
|
6928
7053
|
});
|
|
6929
7054
|
}
|
|
@@ -6935,8 +7060,8 @@ var permissionsScanner = {
|
|
|
6935
7060
|
ruleId: "location-minimal-scope",
|
|
6936
7061
|
severity: targetsApi37 ? "warning" : "info",
|
|
6937
7062
|
title: "Precise location \u2014 consider the location button (minimum scope)",
|
|
6938
|
-
message: `Your app declares ACCESS_FINE_LOCATION. Google Play's Location Permissions policy
|
|
6939
|
-
suggestion: 'For one-time/transactional precise location, use the system location button instead of requesting ACCESS_FINE_LOCATION up front. Only keep continuous fine location if navigation, fitness, or a similar feature is core functionality. Suppress via .preflightrc.json "disabledRules": ["location-minimal-scope"] if fine location is essential.',
|
|
7063
|
+
message: `Your app declares ACCESS_FINE_LOCATION. Google Play's Location Permissions policy requires the Android location button for one-time or infrequent precise location, and a Play Console declaration explaining why coarse location or the location button is not enough. Mandatory for apps targeting Android 17 (API 37+) on January 27, 2027.${targetsApi37 ? ` Your app targets API ${manifest.targetSdk}.` : ""}`,
|
|
7064
|
+
suggestion: 'For one-time/transactional precise location, use the system location button instead of requesting ACCESS_FINE_LOCATION up front. Only keep continuous fine location if navigation, fitness, or a similar feature is core functionality, and complete the precise location declaration in Play Console. Suppress via .preflightrc.json "disabledRules": ["location-minimal-scope"] if fine location is essential.',
|
|
6940
7065
|
policyUrl: "https://support.google.com/googleplay/android-developer/answer/17033915"
|
|
6941
7066
|
});
|
|
6942
7067
|
}
|
|
@@ -10404,6 +10529,7 @@ export {
|
|
|
10404
10529
|
analyzeReviews2 as analyzeReviews,
|
|
10405
10530
|
annotateListResult,
|
|
10406
10531
|
applyReleaseNotes,
|
|
10532
|
+
assertRateThreshold,
|
|
10407
10533
|
assignRelease,
|
|
10408
10534
|
batchGetOrders,
|
|
10409
10535
|
batchSyncInAppProducts,
|
|
@@ -10558,6 +10684,7 @@ export {
|
|
|
10558
10684
|
isValidStatsDimension,
|
|
10559
10685
|
isVersionedNotesDir,
|
|
10560
10686
|
isZip,
|
|
10687
|
+
latestWorstValue,
|
|
10561
10688
|
lintListing,
|
|
10562
10689
|
lintListings,
|
|
10563
10690
|
lintLocalListings,
|
|
@@ -10609,6 +10736,7 @@ export {
|
|
|
10609
10736
|
pushLeaderboardConfigs,
|
|
10610
10737
|
pushListings,
|
|
10611
10738
|
readListingsFromDir,
|
|
10739
|
+
readMetricValue,
|
|
10612
10740
|
readReleaseNotesForVersion,
|
|
10613
10741
|
readReleaseNotesFromDir,
|
|
10614
10742
|
readinessBadgeColor,
|
|
@@ -10698,6 +10826,7 @@ export {
|
|
|
10698
10826
|
waitForBundleProcessing,
|
|
10699
10827
|
watchVitalsWithAutoHalt,
|
|
10700
10828
|
wordDiff,
|
|
10829
|
+
worstSliceAverage,
|
|
10701
10830
|
writeAuditLog,
|
|
10702
10831
|
writeListingsToDir,
|
|
10703
10832
|
writeMigrationOutput
|