Created
March 4, 2026 21:37
-
-
Save nateberkopec/d8289920608e12d123a6934a2bd8065a to your computer and use it in GitHub Desktop.
heroku-inventory: Catalog all Heroku resources (formation, dynos, addons, buildpacks, config, metrics) for a team or app as JSON
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -uo pipefail | |
| usage() { | |
| echo "heroku-inventory: Catalog Heroku resources" >&2 | |
| echo "" >&2 | |
| echo "Usage:" >&2 | |
| echo " heroku-inventory --team <team-name>" >&2 | |
| echo " heroku-inventory --app <app-name>" >&2 | |
| echo "" >&2 | |
| echo "JSON output goes to stdout, progress/summary to stderr." >&2 | |
| exit 1 | |
| } | |
| MODE="" | |
| TARGET="" | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --team) MODE="team"; TARGET="$2"; shift 2 ;; | |
| --app) MODE="app"; TARGET="$2"; shift 2 ;; | |
| -h|--help) usage ;; | |
| *) echo "Unknown arg: $1" >&2; usage ;; | |
| esac | |
| done | |
| [[ -z "$MODE" || -z "$TARGET" ]] && usage | |
| TOKEN=$(heroku auth:token 2>/dev/null) | |
| API="https://api.heroku.com" | |
| METRICS_API="https://api.metrics.heroku.com" | |
| PARTICLEBOARD="https://particleboard.heroku.com" | |
| heroku_api() { | |
| curl -sf -H "Authorization: Bearer $TOKEN" \ | |
| -H "Accept: application/vnd.heroku+json; version=3" \ | |
| "$API$1" 2>/dev/null | |
| } | |
| metrics_api() { | |
| curl -sf -H "Authorization: Bearer $TOKEN" \ | |
| -H "Accept: application/vnd.heroku+json; version=3.monitoring-events" \ | |
| -H "Origin: https://dashboard.heroku.com" \ | |
| -H "x-origin: https://dashboard.heroku.com" \ | |
| "$METRICS_API$1" 2>/dev/null | |
| } | |
| particleboard_api() { | |
| curl -sf -H "Authorization: Bearer $TOKEN" \ | |
| -H "Accept: application/vnd.api+json" \ | |
| -H "Content-Type: application/vnd.api+json" \ | |
| -H "Origin: https://dashboard.heroku.com" \ | |
| "$PARTICLEBOARD$1" 2>/dev/null | |
| } | |
| export TOKEN API METRICS_API PARTICLEBOARD | |
| export -f heroku_api metrics_api particleboard_api | |
| # Get app list | |
| if [[ "$MODE" == "team" ]]; then | |
| mapfile -t APPS < <(heroku_api "/teams/$TARGET/apps" | jq -r '.[].name' | sort) | |
| else | |
| APPS=("$TARGET") | |
| fi | |
| if [[ ${#APPS[@]} -eq 0 ]]; then | |
| echo "No apps found." >&2 | |
| exit 1 | |
| fi | |
| echo "Found ${#APPS[@]} app(s)" >&2 | |
| # Time window for metrics: last 24h | |
| NOW_EPOCH=$(date +%s) | |
| START_EPOCH=$((NOW_EPOCH - 86400)) | |
| START_ISO=$(date -u -r "$START_EPOCH" +%Y-%m-%dT%H:%M:%SZ) | |
| END_ISO=$(date -u -r "$NOW_EPOCH" +%Y-%m-%dT%H:%M:%SZ) | |
| export NOW_EPOCH START_EPOCH START_ISO END_ISO | |
| TMPDIR=$(mktemp -d) | |
| trap "rm -rf $TMPDIR" EXIT | |
| # Process a single app — writes JSON to $TMPDIR/<app>/result.json | |
| process_app() { | |
| local APP_NAME="$1" | |
| local OUT="$TMPDIR/$APP_NAME" | |
| mkdir -p "$OUT" | |
| # Get app UUID | |
| local APP_JSON | |
| APP_JSON=$(heroku_api "/apps/$APP_NAME") || { echo '{}' > "$OUT/result.json"; return; } | |
| local APP_UUID | |
| APP_UUID=$(echo "$APP_JSON" | jq -r '.id') | |
| local APP_ERRORS="[]" | |
| # Parallel fetch: formation, dynos, buildpacks, addons, config | |
| (heroku_api "/apps/$APP_NAME/formation" || echo '[]') > "$OUT/formation_raw.json" & | |
| (heroku ps --json -a "$APP_NAME" 2>/dev/null || echo '[]') > "$OUT/dynos_raw.json" & | |
| (heroku_api "/apps/$APP_NAME/buildpack-installations" || echo "FORBIDDEN") > "$OUT/bp_raw.txt" & | |
| (heroku addons --json -a "$APP_NAME" 2>/dev/null || echo "FORBIDDEN") > "$OUT/addons_raw.txt" & | |
| (heroku config --json -a "$APP_NAME" 2>/dev/null || echo "FORBIDDEN") > "$OUT/config_raw.txt" & | |
| wait | |
| # Formation | |
| local FORMATION_OUT | |
| FORMATION_OUT=$(jq '[.[] | {type, command, quantity, size}]' "$OUT/formation_raw.json" 2>/dev/null) | |
| [[ -z "$FORMATION_OUT" ]] && FORMATION_OUT="[]" | |
| # Running dynos — count by type | |
| local DYNOS_OUT | |
| DYNOS_OUT=$(jq '[group_by(.type)[] | {type: .[0].type, count: length}]' "$OUT/dynos_raw.json" 2>/dev/null) | |
| [[ -z "$DYNOS_OUT" ]] && DYNOS_OUT="[]" | |
| # Buildpacks | |
| local BP_RAW BUILDPACKS_OUT | |
| BP_RAW=$(cat "$OUT/bp_raw.txt") | |
| if [[ "$BP_RAW" == "FORBIDDEN" ]]; then | |
| BUILDPACKS_OUT="[]" | |
| echo "buildpacks" >> "$OUT/forbidden.txt" | |
| APP_ERRORS=$(echo "$APP_ERRORS" | jq '. + ["buildpacks: 403 forbidden"]') | |
| else | |
| BUILDPACKS_OUT=$(echo "$BP_RAW" | jq '[.[].buildpack.url]' 2>/dev/null) | |
| [[ -z "$BUILDPACKS_OUT" ]] && BUILDPACKS_OUT="[]" | |
| fi | |
| # Addons | |
| local ADDONS_RAW ADDONS_OUT | |
| ADDONS_RAW=$(cat "$OUT/addons_raw.txt") | |
| if [[ "$ADDONS_RAW" == *"FORBIDDEN"* ]] || [[ "$ADDONS_RAW" == *"forbidden"* ]] || [[ "$ADDONS_RAW" == *"HTTP 403"* ]]; then | |
| ADDONS_OUT="[]" | |
| echo "addons" >> "$OUT/forbidden.txt" | |
| APP_ERRORS=$(echo "$APP_ERRORS" | jq '. + ["addons: 403 forbidden"]') | |
| else | |
| ADDONS_OUT=$(echo "$ADDONS_RAW" | jq '[.[] | {service: .addon_service.name, plan: .plan.name, price_cents: (.plan.price.cents // null)}]' 2>/dev/null) | |
| [[ -z "$ADDONS_OUT" ]] && ADDONS_OUT="[]" | |
| fi | |
| # Config | |
| local CONFIG_RAW CONFIG_OUT | |
| CONFIG_RAW=$(cat "$OUT/config_raw.txt") | |
| if [[ "$CONFIG_RAW" == *"FORBIDDEN"* ]] || [[ "$CONFIG_RAW" == *"forbidden"* ]] || [[ "$CONFIG_RAW" == *"HTTP 403"* ]]; then | |
| CONFIG_OUT="{}" | |
| echo "config" >> "$OUT/forbidden.txt" | |
| APP_ERRORS=$(echo "$APP_ERRORS" | jq '. + ["config: 403 forbidden"]') | |
| else | |
| CONFIG_OUT=$(echo "$CONFIG_RAW" | jq '{WEB_CONCURRENCY: .WEB_CONCURRENCY, RAILS_MAX_THREADS: .RAILS_MAX_THREADS} | with_entries(select(.value != null))' 2>/dev/null) | |
| [[ -z "$CONFIG_OUT" ]] && CONFIG_OUT="{}" | |
| fi | |
| # Metrics per process type — fetch all memory+router+scale in parallel | |
| local PROC_TYPES | |
| PROC_TYPES=$(jq -r '.[].type' "$OUT/formation_raw.json" 2>/dev/null || true) | |
| for PTYPE in $PROC_TYPES; do | |
| [[ -z "$PTYPE" ]] && continue | |
| metrics_api "/metrics/$APP_UUID/dyno/memory?process_type=$PTYPE&start_time=$START_ISO&end_time=$END_ISO&step=60m®ion=us" > "$OUT/mem_$PTYPE.json" & | |
| if [[ "$PTYPE" == "web" ]]; then | |
| metrics_api "/metrics/$APP_UUID/router/status?process_type=web&start_time=$START_ISO&end_time=$END_ISO&step=60m®ion=us" > "$OUT/router.json" & | |
| fi | |
| particleboard_api "/apps/$APP_UUID/$PTYPE/scale_events" > "$OUT/scale_$PTYPE.json" & | |
| done | |
| wait | |
| # Assemble metrics | |
| local METRICS_OUT="{}" | |
| for PTYPE in $PROC_TYPES; do | |
| [[ -z "$PTYPE" ]] && continue | |
| local AVG_MEM_MB="null" | |
| if [[ -s "$OUT/mem_$PTYPE.json" ]]; then | |
| AVG_MEM_MB=$(jq ' | |
| [.data["memory.used.bytes.mean"] // [] | .[] | select(. != null and . > 0)] | | |
| if length > 0 then (add / length / 1048576 * 10 | round / 10) else null end | |
| ' "$OUT/mem_$PTYPE.json" 2>/dev/null || echo "null") | |
| fi | |
| local AVG_RPS="null" | |
| if [[ "$PTYPE" == "web" && -s "$OUT/router.json" ]]; then | |
| AVG_RPS=$(jq ' | |
| [.data | to_entries[] | .value[] | numbers] | add // 0 | | |
| . / 86400 * 10 | round / 10 | |
| ' "$OUT/router.json" 2>/dev/null || echo "null") | |
| fi | |
| local CURRENT_QTY | |
| CURRENT_QTY=$(jq --arg pt "$PTYPE" '[.[] | select(.type == $pt)][0].quantity // 1' "$OUT/formation_raw.json" 2>/dev/null || echo 1) | |
| local DYNO_STATS | |
| if [[ -s "$OUT/scale_$PTYPE.json" ]] && jq -e '.data | length > 0' "$OUT/scale_$PTYPE.json" &>/dev/null; then | |
| DYNO_STATS=$(jq --argjson start "$START_EPOCH" --argjson end "$NOW_EPOCH" --argjson cur "$CURRENT_QTY" ' | |
| [.data[].attributes | { | |
| qty: .quantity, | |
| prev: (."previous-quantity"), | |
| ts: (."updated-at" | split(".")[0] + "Z" | fromdateiso8601) | |
| }] | | |
| [.[] | select(.ts >= $start and .ts <= $end)] | | |
| sort_by(.ts) | | |
| if length == 0 then | |
| {min: $cur, max: $cur, avg: $cur} | |
| else | |
| . as $events | | |
| [$events[].qty, $events[0].prev] | [.[] | select(. != null)] | | |
| {min: min, max: max} + | |
| {avg: ([$events[].qty] | add / length * 10 | round / 10)} | |
| end | |
| ' "$OUT/scale_$PTYPE.json" 2>/dev/null || echo "{\"min\":$CURRENT_QTY,\"max\":$CURRENT_QTY,\"avg\":$CURRENT_QTY}") | |
| else | |
| DYNO_STATS="{\"min\":$CURRENT_QTY,\"max\":$CURRENT_QTY,\"avg\":$CURRENT_QTY}" | |
| fi | |
| local METRIC_ENTRY | |
| if [[ "$PTYPE" == "web" ]]; then | |
| METRIC_ENTRY=$(jq -n --argjson mem "$AVG_MEM_MB" --argjson rps "$AVG_RPS" --argjson ds "$DYNO_STATS" \ | |
| '{avg_memory_mb: $mem, avg_req_per_sec: $rps, dyno_count: $ds}') | |
| else | |
| METRIC_ENTRY=$(jq -n --argjson mem "$AVG_MEM_MB" --argjson ds "$DYNO_STATS" \ | |
| '{avg_memory_mb: $mem, dyno_count: $ds}') | |
| fi | |
| METRICS_OUT=$(echo "$METRICS_OUT" | jq --arg pt "$PTYPE" --argjson me "$METRIC_ENTRY" '. + {($pt): $me}') | |
| done | |
| # Write final app JSON | |
| jq -n \ | |
| --arg name "$APP_NAME" \ | |
| --argjson formation "$FORMATION_OUT" \ | |
| --argjson running_dynos "$DYNOS_OUT" \ | |
| --argjson buildpacks "$BUILDPACKS_OUT" \ | |
| --argjson addons "$ADDONS_OUT" \ | |
| --argjson config "$CONFIG_OUT" \ | |
| --argjson metrics "$METRICS_OUT" \ | |
| --argjson errors "$APP_ERRORS" \ | |
| '{name: $name, formation: $formation, running_dynos: $running_dynos, buildpacks: $buildpacks, addons: $addons, config: $config, metrics: $metrics, errors: $errors}' \ | |
| > "$OUT/result.json" | |
| } | |
| export -f process_app | |
| export TMPDIR | |
| # Process all apps in parallel (up to 5 concurrent) | |
| PIDS=() | |
| for APP_NAME in "${APPS[@]}"; do | |
| process_app "$APP_NAME" & | |
| PIDS+=($!) | |
| # Throttle: max 5 concurrent | |
| if [[ ${#PIDS[@]} -ge 5 ]]; then | |
| wait "${PIDS[0]}" | |
| PIDS=("${PIDS[@]:1}") | |
| fi | |
| done | |
| wait | |
| # Show progress | |
| for APP_NAME in "${APPS[@]}"; do | |
| if [[ -s "$TMPDIR/$APP_NAME/result.json" ]]; then | |
| echo " + $APP_NAME" >&2 | |
| else | |
| echo " ! $APP_NAME (failed)" >&2 | |
| fi | |
| done | |
| # Assemble results — slurp all result.json files | |
| RESULT_FILES=() | |
| ADDONS_FORBIDDEN="" | |
| CONFIG_FORBIDDEN="" | |
| BUILDPACKS_FORBIDDEN="" | |
| for APP_NAME in "${APPS[@]}"; do | |
| if [[ -s "$TMPDIR/$APP_NAME/result.json" ]]; then | |
| RESULT_FILES+=("$TMPDIR/$APP_NAME/result.json") | |
| fi | |
| if [[ -f "$TMPDIR/$APP_NAME/forbidden.txt" ]]; then | |
| grep -q "addons" "$TMPDIR/$APP_NAME/forbidden.txt" 2>/dev/null && ADDONS_FORBIDDEN="$ADDONS_FORBIDDEN $APP_NAME" | |
| grep -q "config" "$TMPDIR/$APP_NAME/forbidden.txt" 2>/dev/null && CONFIG_FORBIDDEN="$CONFIG_FORBIDDEN $APP_NAME" | |
| grep -q "buildpacks" "$TMPDIR/$APP_NAME/forbidden.txt" 2>/dev/null && BUILDPACKS_FORBIDDEN="$BUILDPACKS_FORBIDDEN $APP_NAME" | |
| fi | |
| done | |
| if [[ ${#RESULT_FILES[@]} -gt 0 ]]; then | |
| APPS_JSON=$(jq -s '.' "${RESULT_FILES[@]}") | |
| else | |
| APPS_JSON="[]" | |
| fi | |
| # Build errors_summary | |
| to_json_arr() { echo "$1" | tr -s ' ' '\n' | sed '/^$/d' | jq -R . | jq -s .; } | |
| ADDONS_ERR=$(to_json_arr "$ADDONS_FORBIDDEN") | |
| CONFIG_ERR=$(to_json_arr "$CONFIG_FORBIDDEN") | |
| BP_ERR=$(to_json_arr "$BUILDPACKS_FORBIDDEN") | |
| GENERATED=$(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| if [[ "$MODE" == "team" ]]; then | |
| LABEL="team" | |
| else | |
| LABEL="app" | |
| fi | |
| # Final JSON to stdout | |
| jq -n \ | |
| --arg label "$LABEL" \ | |
| --arg target "$TARGET" \ | |
| --arg generated "$GENERATED" \ | |
| --argjson apps "$APPS_JSON" \ | |
| --argjson addons_err "$ADDONS_ERR" \ | |
| --argjson config_err "$CONFIG_ERR" \ | |
| --argjson bp_err "$BP_ERR" \ | |
| '{($label): $target, generated_at: $generated, apps: $apps, errors_summary: {addons_forbidden: $addons_err, config_forbidden: $config_err, buildpacks_forbidden: $bp_err}}' | |
| # Summary to stderr | |
| echo "" >&2 | |
| printf "%-30s %6s %6s %6s %s\n" "App" "Dynos" "Form" "Addons" "Metrics" >&2 | |
| printf "%-30s %6s %6s %6s %s\n" "---" "-----" "----" "------" "-------" >&2 | |
| for APP_NAME in "${APPS[@]}"; do | |
| if [[ -s "$TMPDIR/$APP_NAME/result.json" ]]; then | |
| read -r DYNO_CT FORM_CT ADDON_CT MET_CT < <(jq -r '[ | |
| ([.running_dynos[].count] | add // 0), | |
| (.formation | length), | |
| (.addons | length), | |
| (.metrics | keys | length) | |
| ] | @tsv' "$TMPDIR/$APP_NAME/result.json") | |
| printf "%-30s %6s %6s %6s %s\n" "$APP_NAME" "$DYNO_CT" "$FORM_CT" "$ADDON_CT" "$MET_CT proc types" >&2 | |
| fi | |
| done | |
| # Report permission errors | |
| ADDONS_FORBIDDEN=$(echo "$ADDONS_FORBIDDEN" | xargs) | |
| CONFIG_FORBIDDEN=$(echo "$CONFIG_FORBIDDEN" | xargs) | |
| BUILDPACKS_FORBIDDEN=$(echo "$BUILDPACKS_FORBIDDEN" | xargs) | |
| if [[ -n "$ADDONS_FORBIDDEN" || -n "$CONFIG_FORBIDDEN" || -n "$BUILDPACKS_FORBIDDEN" ]]; then | |
| echo "" >&2 | |
| echo "Permission errors (403):" >&2 | |
| [[ -n "$ADDONS_FORBIDDEN" ]] && echo " Addons: $ADDONS_FORBIDDEN" >&2 | |
| [[ -n "$CONFIG_FORBIDDEN" ]] && echo " Config: $CONFIG_FORBIDDEN" >&2 | |
| [[ -n "$BUILDPACKS_FORBIDDEN" ]] && echo " Buildpacks: $BUILDPACKS_FORBIDDEN" >&2 | |
| fi |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage
Requires:
herokuCLI (authenticated),jq,curl.JSON goes to stdout, progress and summary table go to stderr.
What it collects per app
Apps are processed up to 5 in parallel, and within each app all resource fetches + metrics calls run in parallel.