One-shot operational scripts for exporting raw Mixpanel events as gzipped JSONL chunks. Output is suitable for handoff to PostHog managed migration (via S3 / GCS interop) or for cold-storage of the historical Mixpanel record once the project is decommissioned.
This directory does not get imported by any service; it exists for reproducibility and audit trail.
Streams data.mixpanel.com/api/2.0/export one UTC day at a time, writes each
day to <out>/chunks/events-YYYY-MM-DD.jsonl.gz, paces requests under the
documented 60 queries/hour rate limit, and is safe to interrupt and resume.
| Var | Value |
|---|---|
MP_SERVICE_USER |
Mixpanel service account username (<name>.<rand>.mp-service-account) |
MP_SERVICE_SECRET |
Service account secret (shown once at creation) |
MP_PROJECT_ID |
Project id — 3314908 for Based Hardware |
Service account is created at Mixpanel → Organization Settings → Service
Accounts → New, with Consumer role on the target project. Read-only is
sufficient for /export.
| Var | Default | Notes |
|---|---|---|
MP_START |
2024-03-01 |
First UTC day, inclusive |
MP_END |
yesterday UTC | Last UTC day, inclusive |
MP_OUT |
$HOME/mp-export |
Output directory; persistent disk |
MP_GCS_BUCKET |
unset | If set, gsutil cp each chunk to gs://<bucket>/mixpanel/<project>/ after it lands |
MP_MIN_DISK_GB |
20 |
Refuse to start if free space is below this |
MP_PACE_SECONDS |
65 |
Target cycle time per request (≈55 req/hr at 65s) |
$MP_OUT/
├── chunks/events-YYYY-MM-DD.jsonl.gz one file per UTC day; 0-byte file = empty day
├── manifest.jsonl append-only log: {date, bytes, sha256, lines, ...}
├── failures.jsonl days that exhausted 5 retries; auto-retried on next run
├── run.log full timestamped log
└── .lock flock guard against concurrent invocations
Source of truth is the disk, not the manifest. A day is "done" iff its gzip file exists and either:
- is 0 bytes (sentinel for legitimately empty days), or
- passes
gzip -tintegrity check.
Re-running the script:
- Acquires the flock — refuses to start if another instance holds it.
- Iterates
[MP_START, MP_END]and skips days that passis_day_done. - Re-fetches everything else, including any
failures.jsonlentries from prior runs (they are not "done" on disk, so they get picked up automatically).
This is robust against:
- Ctrl+C /
kill/ OOM / VM reboot mid-stream — the in-flight day's.tmpis discarded; final file appears only aftermv. - Crash between
mvand manifest append — file is "done" on disk, but manifest is missing the entry. Next run sees the file and skips correctly; manifest gets a coverage gap (acceptable, run.log is the audit trail). - Truncated / corrupted final files —
gzip -tcatches them, day is redownloaded.
| HTTP / signal | Action |
|---|---|
200 non-empty |
Save chunk, append manifest entry, advance |
200 empty body |
Save 0-byte sentinel, manifest entry with empty=true |
429 |
Sleep with exponential backoff, retry same day, does not count against 5-attempt budget |
5xx, timeout, connection reset |
Exponential backoff (60s, 120s, 240s, 480s, 960s), max 5 attempts |
401, 403 |
Hard abort (exit 2) — creds are bad, no point continuing |
Disk full / gzip error |
Per-day failure, retried; if persistent the run fails loudly |
Days that exhaust retries are logged to failures.jsonl and the run continues
to the next day. Re-running the script picks them up automatically.
Inside a named tmux session so it survives SSH disconnects:
export MP_SERVICE_USER='posthog-migration.xxx.mp-service-account'
export MP_SERVICE_SECRET='...'
export MP_PROJECT_ID=3314908
# optionally: export MP_GCS_BUCKET=omi-mixpanel-archive
tmux new -d -s mp-export 'bash scripts/migration/mixpanel_export.sh'Read-only attach to watch progress:
tmux attach -r -t mp-exportOr check progress without attaching:
wc -l "$HOME/mp-export/manifest.jsonl" # days completed
tail -n 5 "$HOME/mp-export/run.log" # recent activity
ls "$HOME/mp-export/chunks" | wc -l # files on disk
du -sh "$HOME/mp-export/chunks" # disk usageFor Mar 2024 → today (≈793 days) at the default 65s pace: ~14h unattended. Failures and retries extend it. Resume on next run is exact — no work is redone.
# Total events imported per manifest:
jq -s 'map(.lines // 0) | add' "$HOME/mp-export/manifest.jsonl"
# Compare against Mixpanel insights total for the same window:
# (run from Mixpanel UI or MCP Run-Query / $any_event total math)
# Spot-check a random chunk:
zcat "$HOME/mp-export/chunks/events-2026-04-01.jsonl.gz" | wc -l
zcat "$HOME/mp-export/chunks/events-2026-04-01.jsonl.gz" | head -1 | jq
# Find missing days (gaps in coverage):
ls "$HOME/mp-export/chunks" | sed 's/events-//;s/.jsonl.gz//' | sort > /tmp/got
seq -f '%g' 0 792 | while read i; do date -u -d "2024-03-01 + $i day" +%F; done > /tmp/want
diff /tmp/want /tmp/got | head