@gpc-cli/core 0.10.6 → 0.10.7

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 CHANGED
@@ -13,34 +13,39 @@ npm install @gpc-cli/core
13
13
  ## Usage
14
14
 
15
15
  ```typescript
16
+ import { resolveAuth } from "@gpc-cli/auth";
17
+ import { createApiClient, createReportingClient } from "@gpc-cli/api";
16
18
  import {
17
19
  uploadRelease,
18
20
  promoteRelease,
19
- getVitalsOverview,
21
+ getVitalsCrashes,
22
+ latestWorstValue,
20
23
  listReviews,
21
24
  analyzeBundle,
22
25
  formatOutput,
23
26
  } from "@gpc-cli/core";
24
27
 
25
- // Upload a release
26
- const result = await uploadRelease(context, {
27
- file: "app.aab",
28
- track: "internal",
29
- validateOnly: true, // dry-run: server-side validation without committing
30
- });
31
-
32
- // Promote between tracks
33
- await promoteRelease(context, {
34
- from: "internal",
35
- to: "production",
36
- rollout: 0.1,
37
- });
38
-
39
- // Check vitals
40
- const vitals = await getVitalsOverview(context);
41
- console.log(formatOutput(vitals, "table")); // also: "json", "csv", "tsv"
42
-
43
- // Analyze bundle size
28
+ const auth = await resolveAuth({ serviceAccountPath: "./play-sa.json" });
29
+ const client = createApiClient({ auth });
30
+ const reporting = createReportingClient({ auth });
31
+ const app = "com.example.app";
32
+
33
+ // Upload a release (validateOnly: server-side validation without committing)
34
+ await uploadRelease(client, app, "app.aab", { track: "internal", validateOnly: true });
35
+
36
+ // Promote between tracks at a 10% staged rollout
37
+ await promoteRelease(client, app, "internal", "production", { userFraction: 0.1 });
38
+
39
+ // Gate on vitals: rates are fractions (0.02 = 2%)
40
+ const crashes = await getVitalsCrashes(reporting, app, { days: 7 });
41
+ const crashRate = latestWorstValue(crashes.rows, "crashRate");
42
+ if (crashRate !== undefined && crashRate > 0.02) process.exitCode = 6;
43
+
44
+ // Recent reviews as a table (also: "json", "csv", "tsv")
45
+ const { reviews } = await listReviews(client, app);
46
+ console.log(formatOutput(reviews, "table"));
47
+
48
+ // Analyze bundle size offline
44
49
  const analysis = await analyzeBundle("./app.aab");
45
50
  ```
46
51
 
@@ -52,7 +57,7 @@ const analysis = await analyzeBundle("./app.aab");
52
57
  | **Listings** | `getListings`, `updateListing`, `pullListings`, `pushListings`, `diffListings` |
53
58
  | **Images** | `listImages`, `uploadImage`, `deleteImage` |
54
59
  | **Reviews** | `listReviews`, `getReview`, `replyToReview`, `exportReviews` |
55
- | **Vitals** | `getVitalsOverview`, `getVitalsCrashes`, `getVitalsAnr`, `getVitalsStartup`, `compareVitalsTrend`, `checkThreshold` |
60
+ | **Vitals** | `getVitalsOverview`, `getVitalsCrashes`, `getVitalsAnr`, `getVitalsStartup`, `compareVitalsTrend`, `checkThreshold`, `latestWorstValue`, `readMetricValue` |
56
61
  | **Subscriptions** | `listSubscriptions`, `createSubscription`, `updateSubscription`, `deleteSubscription`, `listOffers`, `createOffer` |
57
62
  | **IAP** | `listInAppProducts`, `createInAppProduct`, `syncInAppProducts` |
58
63
  | **Purchases** | `getProductPurchase`, `acknowledgeProductPurchase`, `refundOrder` |
package/dist/index.d.ts CHANGED
@@ -720,12 +720,43 @@ interface VitalsOverview {
720
720
  slowRenderingRate?: MetricRow[];
721
721
  excessiveWakeupRate?: MetricRow[];
722
722
  stuckWakelockRate?: MetricRow[];
723
+ /** Metrics that could not be read, with the reason. Absent when every query succeeded. */
724
+ unavailable?: {
725
+ metric: string;
726
+ code: string;
727
+ reason: string;
728
+ }[];
723
729
  }
724
730
  interface ThresholdResult {
725
731
  breached: boolean;
726
732
  value: number | undefined;
727
733
  threshold: number;
728
734
  }
735
+ /**
736
+ * Numeric value of `metric` in a row (the first metric when no name is given).
737
+ * Google returns `metrics` as an array of `{ metric, decimalValue }`; the
738
+ * `MetricRow` type describes a name-keyed map, so both shapes are read. A lookup
739
+ * by name on the array always missed, which left `gpc watch` blind.
740
+ */
741
+ declare function readMetricValue(row: MetricRow | undefined, metric?: string): number | undefined;
742
+ /**
743
+ * Worst (highest) value of `metric` on the most recent day in `rows`. Rows come
744
+ * one per day per slice (start type, report type, --dim value), so "the last
745
+ * row" is an arbitrary slice; a gate must compare the worst one.
746
+ */
747
+ declare function latestWorstValue(rows: MetricRow[] | undefined, metric?: string): number | undefined;
748
+ /**
749
+ * Average of `metric` over the window for each slice, then the worst slice. Without
750
+ * dimensions this is the plain average; with start types it reports the worst of
751
+ * cold, warm and hot instead of blending them.
752
+ */
753
+ declare function worstSliceAverage(rows: MetricRow[] | undefined, metric?: string): number | undefined;
754
+ /**
755
+ * Rejects a rate threshold that can never be reached. Google reports rates as
756
+ * fractions (0.02 = 2%), so 1 or more is almost always a percentage written by
757
+ * mistake, and it left the gate silently open.
758
+ */
759
+ declare function assertRateThreshold(label: string, threshold: number): void;
729
760
  declare function getVitalsOverview(reporting: ReportingApiClient, packageName: string): Promise<VitalsOverview>;
730
761
  declare function getVitalsCrashes(reporting: ReportingApiClient, packageName: string, options?: VitalsQueryOptions): Promise<MetricSetResponse>;
731
762
  declare function getVitalsAnr(reporting: ReportingApiClient, packageName: string, options?: VitalsQueryOptions): Promise<MetricSetResponse>;
@@ -781,6 +812,8 @@ interface WatchVitalsOptions {
781
812
  onHalt?: (value: number) => Promise<void>;
782
813
  /** Called on each poll result (value may be undefined if no data). */
783
814
  onPoll?: (value: number | undefined, breached: boolean) => void;
815
+ /** Called when a poll fails; polling continues. */
816
+ onError?: (err: unknown) => void;
784
817
  }
785
818
  /**
786
819
  * Poll vitals on an interval; invoke onHalt if threshold is breached.
@@ -810,6 +843,8 @@ interface WatchVitalReading {
810
843
  value: number | undefined;
811
844
  threshold: number | undefined;
812
845
  breached: boolean;
846
+ /** Set when the metric could not be read this round (error code, or message). */
847
+ error?: string;
813
848
  }
814
849
  interface WatchEvent {
815
850
  round: number;
@@ -824,6 +859,10 @@ interface WatchSummary {
824
859
  durationMs: number;
825
860
  finalRollout: number | null;
826
861
  breachCount: number;
862
+ /** Rounds in which a metric with a threshold could not be read (games-only metrics excluded). */
863
+ unreadableRounds: number;
864
+ /** True when the last round had such an unreadable metric: the gate ended blind. */
865
+ lastRoundUnreadable: boolean;
827
866
  halted: boolean;
828
867
  events: WatchEvent[];
829
868
  }
@@ -2013,4 +2052,4 @@ declare function readinessBadgeColor(grade: ReadinessGrade): string;
2013
2052
  declare function readinessBadgeUrl(score: ReadinessScore): string;
2014
2053
  declare function readinessBadgeMarkdown(score: ReadinessScore): string;
2015
2054
 
2016
- export { ApiError, type ApiSigningFingerprint, type AppInfo, type AppStatus, type ApplyReleaseNotesResult, type AuditEntry, type BatchSyncResult, type BundleAnalysis, type BundleComparison, type BundleEntry, type BundleSizeCheckResult, type BundleSizeConfig, type BundlesWaitOptions, type ChangelogEntry, type ChecklistInput, type ChecklistItem, type ChecklistResult, type CommandContext, type CommitCluster, type CommitResult, ConfigError, type CreateEnterpriseAppParams, DEFAULT_LIMITS, DEFAULT_MODELS, DEFAULT_PREFLIGHT_CONFIG, DEFAULT_WATCH_THRESHOLDS, type DecodedNotification, type DiffToken, type DiscoverPluginsOptions, type DiscoveredPlugin, type DryRunPublishResult, type DryRunResult, type DryRunUploadResult, type ErrorReason, type ExportImagesOptions, type ExportImagesSummary, type ExternalTransactionRefundOptions, type FastlaneDetection, type FastlaneLane, type FetchChangelogOptions, type FieldLintResult, type FileValidationResult, type FinancialReportDownload, type FindingSeverity, GOOGLE_PLAY_LANGUAGES, type GameConfigDiff, type GamesPushOptions, type GamesSyncResult, type GenerateOptions, type GeneratedChangelog, type GetAppStatusOptions, type GitNotesOptions, type GitReleaseNotes, type GitRunner, GpcError, type ImageSyncDetail, type ImageSyncOptions, type ImageSyncResult, type ImageValidationResult, type InitOptions, type InitResult, type InternalSharingUploadResult, type KeystoreFingerprint, type ListGamesConfigOptions, type ListIapOptions, type ListReportsResult, type ListSubscriptionsOptions, type ListUsersOptions, type ListVoidedOptions, type ListingDiff, type ListingFieldLimits, type ListingLintResult, type ListingsResult, type LoadedPlugin, type LocaleBundle, type LocaleEntry, type MigrationResult, NetworkError, type OneTimeProductDiff, type OutputMode, PERMISSION_PROPAGATION_WARNING, PLACEHOLDER_TEXT, PLAY_STORE_LIMIT, PROVIDER_WHITELIST, type ParsedCommit, type ParsedManifest, type ParsedMonth, type PlayStoreFormat, type PlayStoreRenderOptions, PluginManager, type PreflightConfig, type PreflightFinding, type PreflightOptions, type PreflightResult, type PreflightScanner, type Provider, type PublishOptions, type PublishResult, type PushResult, type QuotaUsage, RENDERERS, REVIEW_SORT_PRESETS, type RawCommit, type ReadinessBreakdownEntry, type ReadinessGrade, type ReadinessScore, type ReadinessSignal, type ReadinessSignalStatus, type RefundReviewPreference, type ReleaseDiff, type ReleaseNotesValidation, type ReleaseStatusResult, type Renderer, type ReportObject, type ReportsAuth, type ResolveAiConfigOptions, type ResolveLocalesOptions, type ReviewAnalysis, type ReviewExportOptions, type ReviewOrderRefundOptions, type ReviewOrderRefundResult, type ReviewSortPreset, type ReviewsFilterOptions, type RevocationRefundType, type RtdnStatus, SECTION_ORDER, SENSITIVE_ARG_KEYS, SENSITIVE_KEYS, SEVERITY_ORDER, type ScaffoldOptions, type ScaffoldResult, type SigningConsistencyResult, type SigningKeyComparison, type Spinner, type StatsReportDownload, type StatusDiff, type StatusRelease, type StatusReviews, type StatusVitalMetric, type SubscriptionAnalytics, type SubscriptionDiff, type SyncResult, type ThresholdResult, type TrainConfig, type TrainState, type TranslateBundleOptions, type TranslatedBundle, type TranslationFailure, type TranslationPath, type TranslationResult, type Translator, type TranslatorConfig, type UploadResult, VALID_WATCH_METRICS, type ValidateCheck, type ValidateOptions, type ValidateResult, type ValidatedPluginApproval, type VersionVitalsComparison, type VersionVitalsRow, type VitalsOverview, type VitalsQueryOptions, type VitalsTrendComparison, type WatchAction, type WatchCallbacks, type WatchConfig, type WatchEvent, type WatchMetric, type WatchOptions, type WatchRollout, type WatchSummary, type WatchVitalReading, type WatchVitalsOptions, type WebhookPayload, type ZipCsvEntry, abortTrain, acknowledgeProductPurchase, activateBasePlan, activateOffer, addRecoveryTargeting, addTesters, advanceTrain, analyzeBundle, analyzeRemoteListings, analyzeReviews, annotateListResult, applyReleaseNotes, assignRelease, batchGetOrders, batchSyncInAppProducts, buildChecklist, buildExternalTransactionRefund, buildLocaleBundle, bundleToReleaseNotes, cancelRecoveryAction, cancelSubscriptionPurchase, cancelSubscriptionV2, checkBundleSize, checkSigningConsistency, checkThreshold, classifyError, clearAuditLog, commitWithRescue, compareBundles, compareFingerprints, compareVersionVitals, compareVitalsTrend, computeStatusDiff, consumeProductPurchase, convertRegionPrices, createAchievementConfig, createAuditEntry, createDeviceTier, createEnterpriseApp, createExternalTransaction, createGrant, createInAppProduct, createLeaderboardConfig, createOffer, createOneTimeOffer, createOneTimeProduct, createRecoveryAction, createSpinner, createSubscription, createTrack, createTranslator, deactivateBasePlan, deactivateOffer, decodeCsvText, decodeNotification, decodeStatsCsv, defaultGitRunner, deferSubscriptionPurchase, deferSubscriptionV2, deleteAchievementConfig, deleteBasePlan, deleteGrant, deleteImage, deleteInAppProduct, deleteLeaderboardConfig, deleteListing, deleteOffer, deleteOneTimeOffer, deleteOneTimeProduct, deleteSubscription, deployRecoveryAction, detectFastlane, detectOutputFormat, diffAchievementConfig, diffLeaderboardConfig, diffListings, diffListingsCommand, diffListingsEnhanced, diffOneTimeProduct, diffReleases, diffSubscription, discoverPluginEntries, discoverPlugins, dispatchWebhook, downloadFinancialReport, downloadGeneratedApk, downloadReportObject, downloadStatsReport, exportImages, exportReviews, extractCsvEntriesFromZip, fetchAggregateCost, fetchChangelog, fetchReleaseNotes, findBundle, formatChangelogEntry, formatCustomPayload, formatDiscordPayload, formatJunit, formatMoney, formatNotification, formatOutput, formatPathLabel, formatSlackPayload, formatStatusDiff, formatStatusSummary, formatStatusTable, formatWordDiff, generateChangelog, generateMigrationPlan, generateNotesFromGit, getAchievementConfig, getAllScannerNames, getApiSigningFingerprint, getAppInfo, getAppStatus, getCountryAvailability, getDeviceTier, getExternalTransaction, getInAppProduct, getKeystoreFingerprint, getLeaderboardConfig, getListings, getOffer, getOneTimeOffer, getOneTimeProduct, getOrderDetails, getProductPurchase, getProductPurchaseV2, getQuotaUsage, getReleasesStatus, getReview, getRtdnStatus, getSubscription, getSubscriptionAnalytics, getSubscriptionPurchase, getTrainStatus, getUser, getVitalsAnomalies, getVitalsAnr, getVitalsBattery, getVitalsCrashes, getVitalsErrorCount, getVitalsLmk, getVitalsMemory, getVitalsMemoryBitmap, getVitalsMemoryRss, getVitalsOverview, getVitalsRendering, getVitalsStartup, gradeFromPercent, gunzipIfNeeded, handleBreach, importDataSafety, importTestersFromCsv, initAudit, initProject, inviteUser, isFinancialReportType, isGzip, isStatsReportType, isValidBcp47, isValidReportType, isValidStatsDimension, isVersionedNotesDir, isZip, lintListing, lintListings, lintLocalListings, listAchievementConfigs, listAchievements, listAuditEvents, listBundles, listDeviceTiers, listGeneratedApks, listGrants, listImages, listInAppProducts, listLeaderboardConfigs, listLeaderboards, listOffers, listOneTimeOffers, listOneTimeProducts, listRecoveryActions, listReportObjects, listReports, listReviews, listSubscriptions, listTesters, listTracks, listUsers, listVoidedPurchases, loadPreflightConfig, loadStatusCache, maybePaginate, migratePrices, monthToken, moreResultsFooter, normalizeFingerprint, parseAppfile, parseCommit, parseFastfile, parseGrantArg, parseKeytoolOutput, parseMonth, parseRemoteUrl, pauseTrain, promoteRelease, publish, publishEnterpriseApp, pullAchievementConfigs, pullLeaderboardConfigs, pullListings, pushAchievementConfigs, pushLeaderboardConfigs, pushListings, readListingsFromDir, readReleaseNotesForVersion, readReleaseNotesFromDir, readinessBadgeColor, readinessBadgeMarkdown, readinessBadgeUrl, redactAuditArgs, redactSensitive, refundExternalTransaction, refundOrder, relativeTime, removeTesters, removeUser, renderChecklistMarkdown, renderJson, renderMarkdown, renderPlayStore, renderPlayStoreJson, renderPlayStoreMd, renderPlayStorePrompt, renderPrompt, replyToReview, reportAccessDeniedError, reportBucketNotFoundError, reportObjectNotFoundError, reportPrefix, resolveAiConfig, resolveLocales, resolveReportsBucket, retryOnUploadNotComplete, reviewOrderRefund, revokeSubscriptionPurchase, runPreflight, runWatch, runWatchLoop, safePath, safePathWithin, saveStatusCache, scaffoldPlugin, scoreReadiness, searchAuditEvents, searchVitalsErrors, sendNotification, sendWebhook, setAchievementIcon, setLeaderboardIcon, sha256File, sortResults, sortReviews, startTrain, statusHasBreach, syncImages, syncInAppProducts, topFiles, trackBreachState, translateBundle, updateAchievementConfig, updateAppDetails, updateDataSafety, updateGrant, updateInAppProduct, updateLeaderboardConfig, updateListing, updateOffer, updateOneTimeOffer, updateOneTimeProduct, updateRollout, updateSubscription, updateTrackConfig, updateUser, uploadExternallyHosted, uploadImage, uploadInternalSharing, uploadRelease, validateAndCommit, validateBundleForApply, validateImage, validateLanguageCode, validatePackageName, validatePluginForApproval, validatePreSubmission, validateReleaseNotes, validateSku, validateTrackName, validateUploadFile, validateVersionCode, waitForBundle, waitForBundleProcessing, watchVitalsWithAutoHalt, wordDiff, writeAuditLog, writeListingsToDir, writeMigrationOutput };
2055
+ export { ApiError, type ApiSigningFingerprint, type AppInfo, type AppStatus, type ApplyReleaseNotesResult, type AuditEntry, type BatchSyncResult, type BundleAnalysis, type BundleComparison, type BundleEntry, type BundleSizeCheckResult, type BundleSizeConfig, type BundlesWaitOptions, type ChangelogEntry, type ChecklistInput, type ChecklistItem, type ChecklistResult, type CommandContext, type CommitCluster, type CommitResult, ConfigError, type CreateEnterpriseAppParams, DEFAULT_LIMITS, DEFAULT_MODELS, DEFAULT_PREFLIGHT_CONFIG, DEFAULT_WATCH_THRESHOLDS, type DecodedNotification, type DiffToken, type DiscoverPluginsOptions, type DiscoveredPlugin, type DryRunPublishResult, type DryRunResult, type DryRunUploadResult, type ErrorReason, type ExportImagesOptions, type ExportImagesSummary, type ExternalTransactionRefundOptions, type FastlaneDetection, type FastlaneLane, type FetchChangelogOptions, type FieldLintResult, type FileValidationResult, type FinancialReportDownload, type FindingSeverity, GOOGLE_PLAY_LANGUAGES, type GameConfigDiff, type GamesPushOptions, type GamesSyncResult, type GenerateOptions, type GeneratedChangelog, type GetAppStatusOptions, type GitNotesOptions, type GitReleaseNotes, type GitRunner, GpcError, type ImageSyncDetail, type ImageSyncOptions, type ImageSyncResult, type ImageValidationResult, type InitOptions, type InitResult, type InternalSharingUploadResult, type KeystoreFingerprint, type ListGamesConfigOptions, type ListIapOptions, type ListReportsResult, type ListSubscriptionsOptions, type ListUsersOptions, type ListVoidedOptions, type ListingDiff, type ListingFieldLimits, type ListingLintResult, type ListingsResult, type LoadedPlugin, type LocaleBundle, type LocaleEntry, type MigrationResult, NetworkError, type OneTimeProductDiff, type OutputMode, PERMISSION_PROPAGATION_WARNING, PLACEHOLDER_TEXT, PLAY_STORE_LIMIT, PROVIDER_WHITELIST, type ParsedCommit, type ParsedManifest, type ParsedMonth, type PlayStoreFormat, type PlayStoreRenderOptions, PluginManager, type PreflightConfig, type PreflightFinding, type PreflightOptions, type PreflightResult, type PreflightScanner, type Provider, type PublishOptions, type PublishResult, type PushResult, type QuotaUsage, RENDERERS, REVIEW_SORT_PRESETS, type RawCommit, type ReadinessBreakdownEntry, type ReadinessGrade, type ReadinessScore, type ReadinessSignal, type ReadinessSignalStatus, type RefundReviewPreference, type ReleaseDiff, type ReleaseNotesValidation, type ReleaseStatusResult, type Renderer, type ReportObject, type ReportsAuth, type ResolveAiConfigOptions, type ResolveLocalesOptions, type ReviewAnalysis, type ReviewExportOptions, type ReviewOrderRefundOptions, type ReviewOrderRefundResult, type ReviewSortPreset, type ReviewsFilterOptions, type RevocationRefundType, type RtdnStatus, SECTION_ORDER, SENSITIVE_ARG_KEYS, SENSITIVE_KEYS, SEVERITY_ORDER, type ScaffoldOptions, type ScaffoldResult, type SigningConsistencyResult, type SigningKeyComparison, type Spinner, type StatsReportDownload, type StatusDiff, type StatusRelease, type StatusReviews, type StatusVitalMetric, type SubscriptionAnalytics, type SubscriptionDiff, type SyncResult, type ThresholdResult, type TrainConfig, type TrainState, type TranslateBundleOptions, type TranslatedBundle, type TranslationFailure, type TranslationPath, type TranslationResult, type Translator, type TranslatorConfig, type UploadResult, VALID_WATCH_METRICS, type ValidateCheck, type ValidateOptions, type ValidateResult, type ValidatedPluginApproval, type VersionVitalsComparison, type VersionVitalsRow, type VitalsOverview, type VitalsQueryOptions, type VitalsTrendComparison, type WatchAction, type WatchCallbacks, type WatchConfig, type WatchEvent, type WatchMetric, type WatchOptions, type WatchRollout, type WatchSummary, type WatchVitalReading, type WatchVitalsOptions, type WebhookPayload, type ZipCsvEntry, abortTrain, acknowledgeProductPurchase, activateBasePlan, activateOffer, addRecoveryTargeting, addTesters, advanceTrain, analyzeBundle, analyzeRemoteListings, analyzeReviews, annotateListResult, applyReleaseNotes, assertRateThreshold, assignRelease, batchGetOrders, batchSyncInAppProducts, buildChecklist, buildExternalTransactionRefund, buildLocaleBundle, bundleToReleaseNotes, cancelRecoveryAction, cancelSubscriptionPurchase, cancelSubscriptionV2, checkBundleSize, checkSigningConsistency, checkThreshold, classifyError, clearAuditLog, commitWithRescue, compareBundles, compareFingerprints, compareVersionVitals, compareVitalsTrend, computeStatusDiff, consumeProductPurchase, convertRegionPrices, createAchievementConfig, createAuditEntry, createDeviceTier, createEnterpriseApp, createExternalTransaction, createGrant, createInAppProduct, createLeaderboardConfig, createOffer, createOneTimeOffer, createOneTimeProduct, createRecoveryAction, createSpinner, createSubscription, createTrack, createTranslator, deactivateBasePlan, deactivateOffer, decodeCsvText, decodeNotification, decodeStatsCsv, defaultGitRunner, deferSubscriptionPurchase, deferSubscriptionV2, deleteAchievementConfig, deleteBasePlan, deleteGrant, deleteImage, deleteInAppProduct, deleteLeaderboardConfig, deleteListing, deleteOffer, deleteOneTimeOffer, deleteOneTimeProduct, deleteSubscription, deployRecoveryAction, detectFastlane, detectOutputFormat, diffAchievementConfig, diffLeaderboardConfig, diffListings, diffListingsCommand, diffListingsEnhanced, diffOneTimeProduct, diffReleases, diffSubscription, discoverPluginEntries, discoverPlugins, dispatchWebhook, downloadFinancialReport, downloadGeneratedApk, downloadReportObject, downloadStatsReport, exportImages, exportReviews, extractCsvEntriesFromZip, fetchAggregateCost, fetchChangelog, fetchReleaseNotes, findBundle, formatChangelogEntry, formatCustomPayload, formatDiscordPayload, formatJunit, formatMoney, formatNotification, formatOutput, formatPathLabel, formatSlackPayload, formatStatusDiff, formatStatusSummary, formatStatusTable, formatWordDiff, generateChangelog, generateMigrationPlan, generateNotesFromGit, getAchievementConfig, getAllScannerNames, getApiSigningFingerprint, getAppInfo, getAppStatus, getCountryAvailability, getDeviceTier, getExternalTransaction, getInAppProduct, getKeystoreFingerprint, getLeaderboardConfig, getListings, getOffer, getOneTimeOffer, getOneTimeProduct, getOrderDetails, getProductPurchase, getProductPurchaseV2, getQuotaUsage, getReleasesStatus, getReview, getRtdnStatus, getSubscription, getSubscriptionAnalytics, getSubscriptionPurchase, getTrainStatus, getUser, getVitalsAnomalies, getVitalsAnr, getVitalsBattery, getVitalsCrashes, getVitalsErrorCount, getVitalsLmk, getVitalsMemory, getVitalsMemoryBitmap, getVitalsMemoryRss, getVitalsOverview, getVitalsRendering, getVitalsStartup, gradeFromPercent, gunzipIfNeeded, handleBreach, importDataSafety, importTestersFromCsv, initAudit, initProject, inviteUser, isFinancialReportType, isGzip, isStatsReportType, isValidBcp47, isValidReportType, isValidStatsDimension, isVersionedNotesDir, isZip, latestWorstValue, lintListing, lintListings, lintLocalListings, listAchievementConfigs, listAchievements, listAuditEvents, listBundles, listDeviceTiers, listGeneratedApks, listGrants, listImages, listInAppProducts, listLeaderboardConfigs, listLeaderboards, listOffers, listOneTimeOffers, listOneTimeProducts, listRecoveryActions, listReportObjects, listReports, listReviews, listSubscriptions, listTesters, listTracks, listUsers, listVoidedPurchases, loadPreflightConfig, loadStatusCache, maybePaginate, migratePrices, monthToken, moreResultsFooter, normalizeFingerprint, parseAppfile, parseCommit, parseFastfile, parseGrantArg, parseKeytoolOutput, parseMonth, parseRemoteUrl, pauseTrain, promoteRelease, publish, publishEnterpriseApp, pullAchievementConfigs, pullLeaderboardConfigs, pullListings, pushAchievementConfigs, pushLeaderboardConfigs, pushListings, readListingsFromDir, readMetricValue, readReleaseNotesForVersion, readReleaseNotesFromDir, readinessBadgeColor, readinessBadgeMarkdown, readinessBadgeUrl, redactAuditArgs, redactSensitive, refundExternalTransaction, refundOrder, relativeTime, removeTesters, removeUser, renderChecklistMarkdown, renderJson, renderMarkdown, renderPlayStore, renderPlayStoreJson, renderPlayStoreMd, renderPlayStorePrompt, renderPrompt, replyToReview, reportAccessDeniedError, reportBucketNotFoundError, reportObjectNotFoundError, reportPrefix, resolveAiConfig, resolveLocales, resolveReportsBucket, retryOnUploadNotComplete, reviewOrderRefund, revokeSubscriptionPurchase, runPreflight, runWatch, runWatchLoop, safePath, safePathWithin, saveStatusCache, scaffoldPlugin, scoreReadiness, searchAuditEvents, searchVitalsErrors, sendNotification, sendWebhook, setAchievementIcon, setLeaderboardIcon, sha256File, sortResults, sortReviews, startTrain, statusHasBreach, syncImages, syncInAppProducts, topFiles, trackBreachState, translateBundle, updateAchievementConfig, updateAppDetails, updateDataSafety, updateGrant, updateInAppProduct, updateLeaderboardConfig, updateListing, updateOffer, updateOneTimeOffer, updateOneTimeProduct, updateRollout, updateSubscription, updateTrackConfig, updateUser, uploadExternallyHosted, uploadImage, uploadInternalSharing, uploadRelease, validateAndCommit, validateBundleForApply, validateImage, validateLanguageCode, validatePackageName, validatePluginForApproval, validatePreSubmission, validateReleaseNotes, validateSku, validateTrackName, validateUploadFile, validateVersionCode, waitForBundle, waitForBundleProcessing, watchVitalsWithAutoHalt, wordDiff, worstSliceAverage, writeAuditLog, writeListingsToDir, writeMigrationOutput };
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 yesterday = new Date(Date.now() - DAY_MS);
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
- const opts = options?.dimension ? options : { ...options, dimension: "startType" };
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
- const opts = options?.dimension ? options : { ...options, dimension: "reportType" };
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 latestRow = result.rows?.[result.rows.length - 1];
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 = Date.now() - 2 * DAY_MS - offsetDays * DAY_MS;
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
- if (!result.rows || result.rows.length === 0) return void 0;
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 result = await reporting.queryMetricSet(packageName, metricSet, {
3540
- metrics: [metricKey, "distinctUsers"],
3541
- timelineSpec: {
3542
- aggregationPeriod: "DAILY",
3543
- startTime: toDate(start),
3544
- endTime: toDate(end)
3545
- }
3546
- });
3547
- if (!result.rows || result.rows.length === 0) return void 0;
3548
- const values = result.rows.map((row) => {
3549
- const val = row.metrics[metricKey]?.decimalValue?.value;
3550
- return val !== void 0 ? Number(val) : NaN;
3551
- }).filter((v) => !isNaN(v));
3552
- if (values.length === 0) return void 0;
3553
- return values.reduce((a, b) => a + b, 0) / values.length;
3554
- } catch {
3555
- return void 0;
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 24-48h, so frequent polling wastes API quota."
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 value = await queryWatchMetric(reporting, config.packageName, metric);
3656
- return { metric, value };
3767
+ const reading = await queryWatchMetric(reporting, config.packageName, metric);
3768
+ return { metric, ...reading };
3657
3769
  })
3658
3770
  );
3659
- for (const { metric, value } of metricResults) {
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 latestRow = result.rows?.[result.rows.length - 1];
5415
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5416
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
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 latestRow = result.rows?.[result.rows.length - 1];
5432
- const firstMetric = latestRow?.metrics ? Object.keys(latestRow.metrics)[0] : void 0;
5433
- const value = firstMetric ? Number(latestRow?.metrics[firstMetric]?.decimalValue?.value) : void 0;
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";
@@ -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