Skip to content

Commit e2f1620

Browse files
authored
Merge pull request #11 from sidequery/posthog-import-idempotency
Add idempotent PostHog import
2 parents 2939bcd + 8883051 commit e2f1620

7 files changed

Lines changed: 3292 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ tower-http = { version = "0.5", features = ["trace"] }
2727
url = "2.5"
2828
worker = { version = "0.7", features = ["http"] }
2929
tower-service = "0.3.3"
30-
uuid = { version = "1.7", features = ["v4", "serde", "js"] }
30+
uuid = { version = "1.7", features = ["v4", "v5", "serde", "js"] }
3131
getrandom = { version = "0.2", features = ["js"] }
3232
regex = "1"
3333

README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,118 @@ catalog.drop_table(("default", "<table-name>"), purge_requested=True)
409409
PY
410410
```
411411

412+
## Import existing PostHog data
413+
414+
Hogflare includes a host-side importer for backfilling an existing PostHog project into the same Cloudflare Pipeline sink used by the Worker. It reads PostHog's private API with a personal API key, then writes normalized rows to the pipeline:
415+
416+
- persons as `$identify` rows
417+
- groups as `$groupidentify` rows
418+
- historical events from HogQL with original `timestamp`, `created_at`, and PostHog event `uuid` when available
419+
420+
The importer writes historical rows directly to the pipeline. It does not mutate Worker Durable Object state.
421+
422+
Required inputs:
423+
424+
```bash
425+
export POSTHOG_PROJECT_ID="<project_id>"
426+
export POSTHOG_PERSONAL_API_KEY="phx_..."
427+
export CLOUDFLARE_PIPELINE_ENDPOINT="https://<stream-id>.ingest.cloudflare.com"
428+
export CLOUDFLARE_PIPELINE_AUTH_TOKEN="<pipeline token>" # if your stream requires it
429+
```
430+
431+
Optional inputs:
432+
433+
```bash
434+
export POSTHOG_HOST="https://us.posthog.com" # or https://eu.posthog.com / self-hosted URL
435+
export POSTHOG_ENVIRONMENT_ID="<environment_id>" # recommended for current PostHog persons/groups APIs
436+
export HOGFLARE_API_KEY="phc_..."
437+
export POSTHOG_TEAM_ID="1"
438+
export POSTHOG_GROUP_TYPE_0="company"
439+
export IMPORT_FROM="2025-01-01"
440+
export IMPORT_TO="2025-02-01"
441+
export IMPORT_BATCH_SIZE="500"
442+
export IMPORT_PERSONS_OFFSET="0" # resume guardrails
443+
export IMPORT_EVENTS_OFFSET="0"
444+
export IMPORT_EVENTS_AFTER_TIMESTAMP="2024-09-21T03:24:11Z"
445+
export IMPORT_EVENTS_AFTER_UUID="0192129b-c354-77b4-b496-9be7ec571fb4"
446+
export IMPORT_EVENT_UUIDS_FILE="/tmp/missing-event-uuids.txt"
447+
export IMPORT_EVENT_WINDOW_DAYS="7"
448+
export IMPORT_EVENT_WINDOW_HOURS="6" # use days or hours, not both
449+
export IMPORT_MAX_PERSONS="1000" # optional guardrails for smoke tests
450+
export IMPORT_MAX_GROUPS="1000"
451+
export IMPORT_MAX_EVENTS="1000"
452+
export IMPORT_STATE_FILE=".hogflare-import-state.jsonl"
453+
export IMPORT_TARGET_ACCOUNT_ID="<cloudflare_account_id>"
454+
export IMPORT_TARGET_BUCKET="<r2_bucket>"
455+
export IMPORT_TARGET_TABLE="default.hogflare_events_v3"
456+
export WRANGLER_R2_SQL_AUTH_TOKEN="<r2 sql token>"
457+
export IMPORT_CLOUDFLARE_API_TOKEN="<token with Pipelines read>" # optional auto flush discovery
458+
export IMPORT_PIPELINE_FLUSH_SECS="300" # fallback if Pipelines read is unavailable
459+
```
460+
461+
Production imports require R2 SQL target checks by default. The importer uses stable import
462+
keys, queries the target before each batch, and skips rows that are already present.
463+
Cloudflare Pipeline/R2 is append-only and does not enforce uniqueness by itself. Passing
464+
`--no-target-check` or `IMPORT_TARGET_CHECKS=false` opts out and should only be used for
465+
local tests.
466+
467+
Retry behavior is intentionally conservative. Import sends are not blindly retried after a
468+
transport or response error because the pipeline may have accepted the batch even if the
469+
client did not receive the response. The importer aligns its wait window to the Cloudflare
470+
Pipeline sink rolling policy when `IMPORT_CLOUDFLARE_API_TOKEN` can read Pipelines. Without
471+
that API access, it uses `IMPORT_PIPELINE_FLUSH_SECS`, defaulting conservatively to 300
472+
seconds. The wait is `max(60s, 2 * flush + 30s)`, unless `IMPORT_TARGET_WAIT_SECS` is set
473+
explicitly.
474+
475+
The local state file makes normal same-machine resumes cheap, but it is not a substitute for
476+
target checks if the state file is lost, multiple importers run concurrently, or a send has an
477+
unknown commit state.
478+
479+
Run a dry run first:
480+
481+
```bash
482+
cargo run --bin import_posthog -- --dry-run
483+
```
484+
485+
Run the import:
486+
487+
```bash
488+
cargo run --bin import_posthog
489+
```
490+
491+
You can also pass flags instead of env vars:
492+
493+
```bash
494+
cargo run --bin import_posthog -- \
495+
--posthog-host https://us.posthog.com \
496+
--project-id 12345 \
497+
--environment-id 67890 \
498+
--personal-api-key "$POSTHOG_PERSONAL_API_KEY" \
499+
--pipeline-endpoint "$CLOUDFLARE_PIPELINE_ENDPOINT" \
500+
--pipeline-auth-token "$CLOUDFLARE_PIPELINE_AUTH_TOKEN" \
501+
--hogflare-api-key phc_example \
502+
--from 2025-01-01 \
503+
--to 2025-02-01 \
504+
--persons-offset 0 \
505+
--events-offset 0 \
506+
--events-after-timestamp 2024-09-21T03:24:11Z \
507+
--events-after-uuid 0192129b-c354-77b4-b496-9be7ec571fb4 \
508+
--event-uuids-file /tmp/missing-event-uuids.txt \
509+
--event-window-hours 6 \
510+
--max-persons 1000 \
511+
--max-groups 1000 \
512+
--max-events 1000 \
513+
--import-state-file .hogflare-import-state.jsonl \
514+
--target-account-id "$CLOUDFLARE_ACCOUNT_ID" \
515+
--target-bucket hogflare \
516+
--target-table default.hogflare_events_v3 \
517+
--target-auth-token "$WRANGLER_R2_SQL_AUTH_TOKEN" \
518+
--cloudflare-api-token "$CLOUDFLARE_API_TOKEN"
519+
```
520+
521+
Use `--skip-persons`, `--skip-groups`, or `--skip-events` to import only part of the project.
522+
Use `--skip-person-output` when resuming an event import after person rows were already written; it still loads people for event hydration.
523+
412524
## PostHog compatibility
413525

414526
### Ingestion endpoints

src/bin/import_posthog.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use hogflare::importer::{run_import, ImportConfig, ImportError};
2+
3+
#[tokio::main]
4+
async fn main() {
5+
dotenvy::from_filename(".env.local")
6+
.or_else(|_| dotenvy::dotenv())
7+
.ok();
8+
9+
match run().await {
10+
Ok(()) => {}
11+
Err(ImportError::Usage(message)) => {
12+
println!("{message}");
13+
}
14+
Err(err) => {
15+
eprintln!("posthog import failed: {err}");
16+
std::process::exit(1);
17+
}
18+
}
19+
}
20+
21+
async fn run() -> Result<(), ImportError> {
22+
let config = ImportConfig::from_env_and_args(std::env::args().skip(1))?;
23+
let dry_run = config.dry_run;
24+
let summary = run_import(config).await?;
25+
26+
let mode = if dry_run { "dry run" } else { "import" };
27+
println!(
28+
"PostHog {mode} complete: persons={}, groups={}, events={}, skipped={}, pipeline_batches={}",
29+
summary.persons, summary.groups, summary.events, summary.skipped, summary.pipeline_batches
30+
);
31+
32+
Ok(())
33+
}

0 commit comments

Comments
 (0)