Skip to content

Instantly share code, notes, and snippets.

@nateberkopec
Created March 4, 2026 21:37
Show Gist options
  • Select an option

  • Save nateberkopec/d8289920608e12d123a6934a2bd8065a to your computer and use it in GitHub Desktop.

Select an option

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
#!/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&region=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&region=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
@nateberkopec
Copy link
Author

Usage

Requires: heroku CLI (authenticated), jq, curl.

# Inventory a single app
./heroku-inventory --app my-app > inventory.json

# Inventory all apps in a team
./heroku-inventory --team my-team > inventory.json

JSON goes to stdout, progress and summary table go to stderr.

What it collects per app

  • Formation: process types, commands, quantities, dyno sizes
  • Running dynos: count by process type
  • Buildpacks: ordered list (graceful 403 handling)
  • Addons: service name, plan, price (graceful 403 handling)
  • Config vars: WEB_CONCURRENCY and RAILS_MAX_THREADS only (graceful 403 handling)
  • Memory metrics: 24h avg RSS per process type (via Heroku metrics API)
  • Router metrics: 24h avg req/sec for web dynos (via Heroku metrics API)
  • Dyno scaling: min/max/avg dyno count over 24h per process type (via particleboard API)

Apps are processed up to 5 in parallel, and within each app all resource fetches + metrics calls run in parallel.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment