a self-hosted dashboard for Google Play Console data — installs, retention, crashes, sales, reviews, all in one HTML page
🛰️ Live demo: milouk.me/projects/play-analytics
Pulls every metric Google Play exposes for your apps — daily install reports, crash & ANR counts, ratings, store-listing visitors, monthly sales & earnings CSVs, in-app product catalogs, and live reviews — then renders a self-contained dark-mode HTML dashboard for each app and a top-level index that links to all of them. One developer account, any number of apps, one image, configured entirely with env vars.
docker run --rm \
-e DEVELOPER_ACCOUNT_ID=1234567890123456789 \
-e APPS="com.example.app1:My App,com.example.app2:Other App" \
-e GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat key.json)" \
-v "$(pwd)/output:/app/output" \
ghcr.io/milouk/play-analytics:latest
open output/index.htmlOr with the built-in HTTP server:
docker run --rm -p 8080:8080 \
-e DEVELOPER_ACCOUNT_ID=... -e APPS=... \
-e GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat key.json)" \
-e SERVE=true \
ghcr.io/milouk/play-analytics:latest
# → http://localhost:8080/For each app in $APPS:
output/<package>/dashboard.html— single-file HTML, dark theme, Chart.jsoutput/<package>/report.md— Markdown summary (PR/Slack-friendly)
Plus:
output/index.html— landing page with a card per app
Each dashboard surfaces:
| Section | Metrics |
|---|---|
| Headline KPIs | active devices today · user installs · net retention · paid orders · paid conversion · net earnings · avg rating · crashes / ANRs |
| Growth | daily user installs (bars) overlaid with active devices (line) |
| Geography & audience | installs by country (top 15) · language · device · Android OS · app version |
| Quality | daily crashes & ANRs · star distribution from review CSVs |
| Monetisation | every charged sales transaction · revenue by currency · paying countries |
| Voice of customer | full review text with stars, device, version, developer reply |
| Catalog | live in-app product list + subscription list + release tracks |
| Pricing recommendations | per-country PPP-tier diagnostic for every IAP — cut / hold / raise / fine verdicts with one-click apply buttons that copy a CLI command to write the change to Play |
Two sources, both via the service-account key:
gs://pubsite_prod_<developer_account_id>/— the GCS bucket where Play writes your daily install/crash/rating CSVs and monthly sales/earnings zips. Folders:stats/installs/,stats/crashes/,stats/ratings/,stats/store_performance/,reviews/,sales/,earnings/.- Android Publisher API v3 — live catalog (
monetization.onetimeproducts,monetization.subscriptions), the trailing ~7-day review window (reviews.list), and current release tracks + localised listings.
Downloads are incremental (skips files whose local size matches the blob), so a re-run after a fresh report drop is fast.
You need three things: a service account, the developer account ID, and read access for that service account on the reports bucket.
In Google Cloud Console → IAM → Service Accounts → Create. No GCP roles
needed; the permissions live in Play Console and on the bucket. Download
the JSON key — this is the file you pass via
GOOGLE_APPLICATION_CREDENTIALS_JSON.
Play Console → Users and permissions → Invite new user → use the service account email. Grant at minimum:
- App permissions: View app information and download bulk reports
(for every app in
$APPS) - Account permissions (optional, enables live catalog/listings): View store listings, View financial data
The reports bucket lives in a Google-managed project, not yours. The service account needs to be added as a reader:
gsutil iam ch \
serviceAccount:play-analytics@your-project.iam.gserviceaccount.com:roles/storage.objectViewer \
gs://pubsite_prod_<your-developer-account-id>Run this once from a Google account that has Owner on the dev account.
Play Console → Settings → Developer account → Account details → Account ID. It's a 19-digit number.
Use the package name from Play Console — app.foo.bar style. Pretty
names are optional but recommended.
APPS=app.acme.notes:Acme Notes,app.acme.timer:Acme TimerAll via env vars. See .env.example for the full list.
| Variable | Required | Purpose |
|---|---|---|
DEVELOPER_ACCOUNT_ID |
yes | 19-digit ID from Play Console |
APPS |
yes | comma-separated package[:Pretty Name] list |
GOOGLE_APPLICATION_CREDENTIALS_JSON |
one of | JSON content as a string (env-friendly) |
GOOGLE_APPLICATION_CREDENTIALS |
one of | filesystem path to the JSON key |
DATA_DIR |
no | cache for downloaded reports — default /app/data |
OUTPUT_DIR |
no | where dashboards go — default /app/output |
SERVE |
no | true to serve OUTPUT_DIR over HTTP after rendering |
PORT |
no | server port if SERVE=true — default 8080 |
WINDOWS |
no | comma-separated time windows to render — default 7,30,all. Each becomes a tab in the dashboard. Use 7d,14d,30d,90d,all, etc. |
CRON_SCHEDULE |
no | standard 5-field cron expression. When set, the image runs main.py on schedule (via supercronic) — typical: 0 6 * * * for daily 06:00 UTC refresh |
docker run --rm \
-e DEVELOPER_ACCOUNT_ID=... \
-e APPS="pkg.a:App A,pkg.b:App B" \
-e GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat key.json)" \
-v "$(pwd)/data:/app/data" \
-v "$(pwd)/output:/app/output" \
ghcr.io/milouk/play-analytics:latestMounting data/ is optional but makes re-runs near-instant (it's used as
a cache).
cp .env.example .env
# edit .env
docker compose up -dThe compose file enables SERVE=true and exposes port 8080.
name: refresh dashboards
on:
schedule: [{ cron: "0 6 * * *" }]
workflow_dispatch:
jobs:
refresh:
runs-on: ubuntu-latest
permissions: { contents: write }
steps:
- uses: actions/checkout@v4
- run: |
docker run --rm \
-e DEVELOPER_ACCOUNT_ID=${{ secrets.PLAY_DEV_ID }} \
-e APPS=${{ vars.PLAY_APPS }} \
-e GOOGLE_APPLICATION_CREDENTIALS_JSON='${{ secrets.GOOGLE_SA_JSON }}' \
-v ${{ github.workspace }}/output:/app/output \
ghcr.io/milouk/play-analytics:latest
- uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./outputThis publishes your dashboards to GitHub Pages on a schedule, no server required.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
export DEVELOPER_ACCOUNT_ID=...
export APPS="pkg.a:App A"
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
.venv/bin/python main.py # fetch + render
.venv/bin/python main.py --no-fetch # rerender from cache
.venv/bin/python main.py --serve # also start http.server on 8080- Python 3.12, stdlib + four Google client libs (no pandas, no flask, no jinja)
google-cloud-storagefor the reports bucket (drops the gcloud SDK dependency entirely)google-api-python-clientfor the live Android Publisher API- HTML rendered from string templates with embedded
Chart.js(single<script>tag from jsDelivr CDN); each dashboard is a self-contained file - ~10 MB final image, multi-arch (
linux/amd64,linux/arm64)
Each in-app product's dashboard section computes a per-country PPP verdict based on install/buyer signal and your anchor price:
- cut — over the tier target with no paying buyers
- hold — over target and has buyers — touching it risks ARPU
- raise — below target (rare, usually after a too-aggressive cut)
- fine — already at the best achievable tier price
Each actionable row has an apply button that copies a ready-to-paste CLI command to your clipboard:
python apply_pricing.py <package> <productId> --only TR --applyThe CLI runs in dry-run mode without --apply — it fetches the current
product, builds the planned diff, and prints it. With --apply it sends
one atomic monetization.onetimeproducts.patch covering every change at
once, then re-fetches every region to verify the new price took.
There's also an ⚡ apply all N button at the top of each pricing card that copies a single command for the entire actionable batch.
The script will never touch a region without explicit install signal,
will never re-price a region marked hold (has buyers), and refuses to
proceed unless the planned diff is exactly what you'd expect.
- Reports lag ~48 h. Today's data is never present.
Daily Device Uninstallsis unreliable in Google's reports (often all zero). This tool usesUninstall events+Daily User Uninstallsfor retention math; the dashboard shows real numbers.- The Publisher API's
reviews.listreturns only the trailing ~7 days. The monthly review CSVs in the bucket are the source of truth for full history. - Net earnings only appear once a month finalises (around the 15th of the following month).
MIT © Michael Loukeris
Pulls data exclusively from the Google Play Developer Reports and the Android Publisher API v3. No third-party APIs or telemetry.