analytics and reports for a collector: store-only configs, --json snapshot, App Usage and Play install stock numbers, per-day sales ranges for both stores

This commit is contained in:
CRTkafa 2026-10-26 10:20:27 +07:00
parent b59665c661
commit 5833776a4b
15 changed files with 651 additions and 60 deletions

View file

@ -1,23 +1,27 @@
// Store analytics snapshots next to the listing changes.
// storebound analytics setup [--dry] App Store: create the ONGOING analytics report request (Admin key; once)
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] -> .storebound/analytics/<date>.json
// storebound analytics snapshot [--days 28] [--store apple|play] [--dry] [--json] [--no-write] -> .storebound/analytics/<date>.json
// --json prints the snapshot as one JSON document on stdout (the file is still written unless --no-write or --dry)
// storebound analytics compare [--days 7] [--paths store,storebound.config.ts] [--md]
// App Store: Analytics Reports API (Discovery and Engagement: impressions, product page views; Downloads: first-time
// downloads and redownloads), every segment checked against its md5 checksum. Google Play has no statistics API:
// the monthly CSV exports in your Cloud Storage bucket (play.reportsBucket = "pubsite_prod_rev_<id>", Play Console >
// Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors,
// acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing
// acquisitions, daily user installs, active device installs, total user installs). App Usage (App Store: sessions, active
// devices, crashes per day from the App Sessions and App Crashes reports). A store-only config needs no play.reportsBucket
// (env PLAY_REPORTS_BUCKET or the profile's reportsBucket) and no apple.bundleId (apple.appId is enough). compare lines the snapshots up with the git history of the store files (listing
// text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`.
import { consoleLink } from './lib/console-links';
import { storeReadError } from './lib/google-errors';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { createOnce } from './lib/upsert';
import { playReportsBucket } from './lib/credentials';
import { ascClient, type Asc, type J } from './apple/asc';
import type { Ctx } from './lib/ctx';
import {
type AppleDay, APPLE_REPORTS, appleDaily, appleTotals, bucketName, checksumOk, compareAroundEvents, decodeText, effectMarkdown, effectTable, inWindow,
latestPerDate, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
latestPerDate, mergeAppleUsage, mergeSeries, monthsBetween, snapshotProblem, storeErrors, parseGitLog, parseReport, pickReport, type PlayDay, playDaily, playObjects, playTotals, type Snapshot,
} from './lib/analytics';
import { STORAGE_READ_SCOPE } from './lib/google-auth';
import { done, log, printJson, warn } from './lib/log';
@ -55,9 +59,9 @@ async function reportRows(c: Asc, report: J, start: string, tag: string): Promis
return latestPerDate(parts);
}
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[] }> {
async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: AppleDay[]; notes: string[] }> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const c = await ascClient(root, config.apple!.bundleId ?? '', config.apple?.appId);
const appId = await c.app();
const req = await ongoingRequest(c, appId);
if (!req) throw new Error('no active ONGOING analytics report request: run storebound analytics setup (Admin key), data starts 1-2 days later');
@ -65,25 +69,36 @@ async function appleSnapshot(ctx: Ctx, start: string, end: string): Promise<{ da
const eng = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.engagement.category), APPLE_REPORTS.engagement.match);
const dl = pickReport(reports.filter(r => r.attributes?.category === APPLE_REPORTS.downloads.category), APPLE_REPORTS.downloads.match);
if (!eng || !dl) throw new Error(`reports not found yet (${reports.length} listed): wait a day after setup`);
const daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
return { daily: inWindow(daily, start, end) };
let daily = appleDaily(await reportRows(c, eng, start, eng.attributes.name), await reportRows(c, dl, start, dl.attributes.name));
// App Usage is optional: a team whose request predates the usage reports, or whose usage reports are not out yet, still gets the rest
const notes: string[] = [];
const usage = (m: { category: string; match: RegExp }) => pickReport(reports.filter(r => r.attributes?.category === m.category), m.match);
const ses = usage(APPLE_REPORTS.sessions), cr = usage(APPLE_REPORTS.crashes);
if (ses || cr) {
try {
daily = mergeAppleUsage(daily, ses ? await reportRows(c, ses, start, ses.attributes.name) : [], cr ? await reportRows(c, cr, start, cr.attributes.name) : []);
if (!ses || !cr) notes.push(`App Usage: the ${ses ? 'App Crashes' : 'App Sessions'} report is not listed yet`);
} catch (e) { notes.push(`App Usage not read: ${storeReadError((e as Error).message).error.slice(0, 200)}`); }
} else notes.push('App Usage: the App Sessions / App Crashes reports are not listed yet (they appear a day or two after the first report)');
for (const n of notes) warn(TAG, `apple: ${n}`);
return { daily: inWindow(daily, start, end), notes };
}
async function playSnapshot(ctx: Ctx, start: string, end: string): Promise<{ daily: PlayDay[] }> {
const { config, root } = ctx.loaded;
const bucket = config.play?.reportsBucket;
if (!bucket) throw new Error(`play.reportsBucket is not set (${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
const bucket = playReportsBucket(root, config.play?.reportsBucket);
if (!bucket) throw new Error(`play.reportsBucket is not set (env PLAY_REPORTS_BUCKET or the credential profile's reportsBucket also work; ${consoleLink('play.reports-statistics', '> Copy Cloud Storage URI')})`);
const g = googleClient(root, STORAGE_READ_SCOPE);
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [];
const sp: Record<string, string>[] = [], inst: Record<string, string>[] = [], ov: Record<string, string>[] = [];
for (const month of monthsBetween(start, end)) {
const o = playObjects(config.play!.packageName, month);
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst]] as const) {
for (const [name, into] of [[o.storePerformance, sp], [o.installs, inst], [o.installsOverview, ov]] as const) {
const url = `https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(bucketName(bucket))}/o/${encodeURIComponent(name)}?alt=media`;
try { into.push(...parseReport(decodeText(await g.bytes(url)))); log(TAG, `play: ${name}`); }
catch (e) { if (/-> 404/.test(String(e))) warn(TAG, `play: ${name} not there (yet)`); else throw e; }
}
}
return { daily: inWindow(playDaily(sp, inst), start, end) };
return { daily: inWindow(playDaily(sp, inst, ov), start, end) };
}
function readSnapshots(dir: string): Snapshot[] {
@ -100,7 +115,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (sub === 'setup') {
if (!config.apple) throw new Error('no apple section in the config (Google Play needs no setup: set play.reportsBucket)');
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
const c = await ascClient(root, config.apple.bundleId ?? '', config.apple.appId);
const appId = await c.app();
const cur = await ongoingRequest(c, appId);
if (cur) { done(TAG, `apple: ONGOING report request exists (${cur.id})`); return; }
@ -121,20 +136,24 @@ export async function run(ctx: Ctx): Promise<void> {
const end = day(new Date(now.getTime() - 86_400_000)), start = day(new Date(now.getTime() - days * 86_400_000));
const snap: Snapshot = { date: day(now), window: { start, end, days } };
if ((!store || store === 'apple') && config.apple) {
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; }
try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily), ...(r.notes.length ? { notes: r.notes } : {}) }; }
catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); }
}
if ((!store || store === 'play') && config.play) {
try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; }
catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); }
}
const json = a.has('json');
const rows: (string | number)[][] = [];
if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); }
if (snap.play && !('error' in snap.play)) { const t = snap.play.totals; rows.push(['play', `${t.visitors} visitors`, `${t.acquisitions} acquisitions`, `${t.installs} installs`, pct(t.conversion ?? null)]); }
if (rows.length) console.log(textTable(['store', 'reach', 'interest', 'installs', 'conversion'], rows));
const file = join(dir, `${snap.date}.json`);
const problem = snapshotProblem(snap);
// --json: the collector gets the document (with each store's error) on stdout even when nothing could be read; the exit code still says it failed
if (json) printJson(snap);
if (problem) throw new Error(`no store data, nothing written (${problem})`);
if (a.has('no-write')) { done(TAG, `snapshot ${start} to ${end} (not written: --no-write)`); return; }
if (dry) { done(TAG, `dry run: would write ${file} (${start} to ${end})`); return; }
mkdirSync(dir, { recursive: true });
writeFileSync(file, JSON.stringify(snap, null, 2) + '\n');