From 22634f0807a2b380650f592e90002dee0120e6b3 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Tue, 21 Jan 2025 14:17:38 +0200 Subject: [PATCH] feat: batch imports svc (#27321) --- .github/workflows/rust-docker-build.yml | 7 + posthog/migrations/0551_batchimport.py | 50 +++ posthog/migrations/max_migration.txt | 2 +- posthog/models/__init__.py | 2 + posthog/models/batch_imports.py | 93 ++++++ ...fc4944a76de2997b1fd077d25a5d3fdc6029b.json | 68 ++++ ...fe53a98e367556b6e62681410e4786f6777f6.json | 19 ++ ...ed811a5c261c94b9440b8faccf026597f9cd0.json | 22 ++ ...30bb226c060fde9f24eea420f77ab3ec62a9f.json | 15 + rust/Cargo.lock | 87 ++++++ rust/Cargo.toml | 5 +- rust/batch-import-worker/Cargo.toml | 35 +++ rust/batch-import-worker/src/config.rs | 29 ++ rust/batch-import-worker/src/context.rs | 84 +++++ rust/batch-import-worker/src/emit/kafka.rs | 144 +++++++++ rust/batch-import-worker/src/emit/mod.rs | 83 +++++ rust/batch-import-worker/src/job/config.rs | 171 ++++++++++ rust/batch-import-worker/src/job/mod.rs | 293 ++++++++++++++++++ rust/batch-import-worker/src/job/model.rs | 256 +++++++++++++++ rust/batch-import-worker/src/lib.rs | 6 + rust/batch-import-worker/src/main.rs | 100 ++++++ .../src/parse/content/captured.rs | 43 +++ .../src/parse/content/mixpanel.rs | 87 ++++++ .../src/parse/content/mod.rs | 19 ++ rust/batch-import-worker/src/parse/format.rs | 253 +++++++++++++++ rust/batch-import-worker/src/parse/mod.rs | 9 + rust/batch-import-worker/src/source/folder.rs | 116 +++++++ rust/batch-import-worker/src/source/mod.rs | 12 + .../src/source/url_list.rs | 273 ++++++++++++++++ .../tests/capture_request_dump.jsonl | 8 + rust/capture/src/api.rs | 3 - rust/capture/src/test_endpoint.rs | 14 +- rust/capture/src/v0_endpoint.rs | 14 +- rust/capture/src/v0_request.rs | 93 +----- rust/common/kafka/src/config.rs | 3 + rust/common/kafka/src/kafka_producer.rs | 14 +- rust/common/kafka/src/lib.rs | 1 + rust/common/kafka/src/test.rs | 1 + rust/common/kafka/src/transaction.rs | 148 +++++++++ rust/common/types/src/event.rs | 76 ++++- rust/common/types/src/lib.rs | 5 + rust/common/types/src/util.rs | 16 + rust/cymbal/src/config.rs | 7 - 43 files changed, 2665 insertions(+), 121 deletions(-) create mode 100644 posthog/migrations/0551_batchimport.py create mode 100644 posthog/models/batch_imports.py create mode 100644 rust/.sqlx/query-0f547fc3a70bfd5b11b804dc971fc4944a76de2997b1fd077d25a5d3fdc6029b.json create mode 100644 rust/.sqlx/query-1f55549e1c3e14f539594ba9554fe53a98e367556b6e62681410e4786f6777f6.json create mode 100644 rust/.sqlx/query-ace06e5c2b903628287dad09cf2ed811a5c261c94b9440b8faccf026597f9cd0.json create mode 100644 rust/.sqlx/query-c9d960277e49c289bcb6d682d3d30bb226c060fde9f24eea420f77ab3ec62a9f.json create mode 100644 rust/batch-import-worker/Cargo.toml create mode 100644 rust/batch-import-worker/src/config.rs create mode 100644 rust/batch-import-worker/src/context.rs create mode 100644 rust/batch-import-worker/src/emit/kafka.rs create mode 100644 rust/batch-import-worker/src/emit/mod.rs create mode 100644 rust/batch-import-worker/src/job/config.rs create mode 100644 rust/batch-import-worker/src/job/mod.rs create mode 100644 rust/batch-import-worker/src/job/model.rs create mode 100644 rust/batch-import-worker/src/lib.rs create mode 100644 rust/batch-import-worker/src/main.rs create mode 100644 rust/batch-import-worker/src/parse/content/captured.rs create mode 100644 rust/batch-import-worker/src/parse/content/mixpanel.rs create mode 100644 rust/batch-import-worker/src/parse/content/mod.rs create mode 100644 rust/batch-import-worker/src/parse/format.rs create mode 100644 rust/batch-import-worker/src/parse/mod.rs create mode 100644 rust/batch-import-worker/src/source/folder.rs create mode 100644 rust/batch-import-worker/src/source/mod.rs create mode 100644 rust/batch-import-worker/src/source/url_list.rs create mode 100644 rust/batch-import-worker/tests/capture_request_dump.jsonl create mode 100644 rust/common/kafka/src/transaction.rs create mode 100644 rust/common/types/src/util.rs diff --git a/.github/workflows/rust-docker-build.yml b/.github/workflows/rust-docker-build.yml index 4237599d9b311..02622c99a6207 100644 --- a/.github/workflows/rust-docker-build.yml +++ b/.github/workflows/rust-docker-build.yml @@ -33,6 +33,8 @@ jobs: dockerfile: ./rust/Dockerfile - image: cymbal dockerfile: ./rust/Dockerfile + - image: batch-import-worker + dockerfile: ./rust/Dockerfile runs-on: depot-ubuntu-22.04-4 permissions: id-token: write # allow issuing OIDC tokens for this workflow run @@ -44,6 +46,7 @@ jobs: cyclotron-fetch_digest: ${{ steps.digest.outputs.cyclotron-fetch_digest }} cyclotron-janitor_digest: ${{ steps.digest.outputs.cyclotron-janitor_digest }} property-defs-rs_digest: ${{ steps.digest.outputs.property-defs-rs_digest }} + batch-import-worker_digest: ${{ steps.digest.outputs.batch-import-worker_digest }} hook-api_digest: ${{ steps.digest.outputs.hook-api_digest }} hook-janitor_digest: ${{ steps.digest.outputs.hook-janitor_digest }} hook-worker_digest: ${{ steps.digest.outputs.hook-worker_digest }} @@ -142,6 +145,10 @@ jobs: values: image: sha: '${{ needs.build.outputs.property-defs-rs_digest }}' + - release: batch-import-worker + values: + image: + sha: '${{ needs.build.outputs.batch-import-worker_digest }}' - release: cymbal values: image: diff --git a/posthog/migrations/0551_batchimport.py b/posthog/migrations/0551_batchimport.py new file mode 100644 index 0000000000000..3330ca70fad90 --- /dev/null +++ b/posthog/migrations/0551_batchimport.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.15 on 2024-12-16 11:55 + +from django.db import migrations, models +import django.db.models.deletion +import posthog.helpers.encrypted_fields +import posthog.models.utils + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0550_migrate_action_webhooks_to_destinations"), + ] + + operations = [ + migrations.CreateModel( + name="BatchImport", + fields=[ + ( + "id", + models.UUIDField( + default=posthog.models.utils.UUIDT, editable=False, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("lease_id", models.TextField(blank=True, null=True)), + ("leased_until", models.DateTimeField(blank=True, null=True)), + ( + "status", + models.TextField( + choices=[ + ("completed", "Completed"), + ("failed", "Failed"), + ("paused", "Paused"), + ("running", "Running"), + ], + default="running", + ), + ), + ("status_message", models.TextField(blank=True, null=True)), + ("state", models.JSONField(blank=True, null=True)), + ("import_config", models.JSONField()), + ("secrets", posthog.helpers.encrypted_fields.EncryptedJSONStringField()), + ("team", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="posthog.team")), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index d53531e2cbe5e..0e0faf15e19e7 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0550_migrate_action_webhooks_to_destinations +0551_batchimport diff --git a/posthog/models/__init__.py b/posthog/models/__init__.py index ad88a8c429be5..80607a52c46b9 100644 --- a/posthog/models/__init__.py +++ b/posthog/models/__init__.py @@ -21,6 +21,7 @@ from .annotation import Annotation from .async_deletion import AsyncDeletion, DeletionType from .async_migration import AsyncMigration, AsyncMigrationError, MigrationStatus +from .batch_imports import BatchImport from .cohort import Cohort, CohortPeople from .comment import Comment from .dashboard import Dashboard @@ -100,6 +101,7 @@ "BatchExportBackfill", "BatchExportDestination", "BatchExportRun", + "BatchImport", "Cohort", "CohortPeople", "Dashboard", diff --git a/posthog/models/batch_imports.py b/posthog/models/batch_imports.py new file mode 100644 index 0000000000000..f3cbd6a30dfac --- /dev/null +++ b/posthog/models/batch_imports.py @@ -0,0 +1,93 @@ +from django.db import models + +from posthog.models.utils import UUIDModel +from posthog.models.team import Team + +from posthog.helpers.encrypted_fields import EncryptedJSONStringField + +from typing import Self +from enum import Enum + + +class ContentType(str, Enum): + MIXPANEL = "mixpanel" + CAPTURED = "captured" + + def serialize(self) -> dict: + return {"type": self.value} + + +class BatchImport(UUIDModel): + class Status(models.TextChoices): + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + PAUSED = "paused", "Paused" + RUNNING = "running", "Running" + + team = models.ForeignKey(Team, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + lease_id = models.TextField(null=True, blank=True) + leased_until = models.DateTimeField(null=True, blank=True) + status = models.TextField(choices=Status.choices, default=Status.RUNNING) + status_message = models.TextField(null=True, blank=True) + state = models.JSONField(null=True, blank=True) + import_config = models.JSONField() + secrets = EncryptedJSONStringField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._config_builder = BatchImportConfigBuilder(self) + + @property + def config(self) -> "BatchImportConfigBuilder": + return self._config_builder + + +# Mostly used for manual job creation +class BatchImportConfigBuilder: + def __init__(self, batch_import: BatchImport): + self.batch_import = batch_import + self.batch_import.import_config = {} + self.batch_import.secrets = {} + + def json_lines(self, content_type: ContentType, skip_blanks: bool = True) -> Self: + self.batch_import.import_config["data_format"] = { + "type": "json_lines", + "skip_blanks": skip_blanks, + "content": content_type.serialize(), + } + return self + + def from_folder(self, path: str) -> Self: + self.batch_import.import_config["source"] = {"type": "folder", "path": path} + return self + + def from_urls( + self, urls: list[str], urls_key: str = "urls", allow_internal_ips: bool = False, timeout_seconds: int = 30 + ) -> Self: + self.batch_import.import_config["source"] = { + "type": "url_list", + "urls_key": urls_key, + "allow_internal_ips": allow_internal_ips, + "timeout_seconds": timeout_seconds, + } + self.batch_import.secrets[urls_key] = urls + return self + + def to_stdout(self, as_json: bool = True) -> Self: + self.batch_import.import_config["sink"] = {"type": "stdout", "as_json": as_json} + return self + + def to_file(self, path: str, as_json: bool = True, cleanup: bool = False) -> Self: + self.batch_import.import_config["sink"] = {"type": "file", "path": path, "as_json": as_json, "cleanup": cleanup} + return self + + def to_kafka(self, topic: str, send_rate: int, transaction_timeout_seconds: int) -> Self: + self.batch_import.import_config["sink"] = { + "type": "kafka", + "topic": topic, + "send_rate": send_rate, + "transaction_timeout_seconds": transaction_timeout_seconds, + } + return self diff --git a/rust/.sqlx/query-0f547fc3a70bfd5b11b804dc971fc4944a76de2997b1fd077d25a5d3fdc6029b.json b/rust/.sqlx/query-0f547fc3a70bfd5b11b804dc971fc4944a76de2997b1fd077d25a5d3fdc6029b.json new file mode 100644 index 0000000000000..7578ffc19820b --- /dev/null +++ b/rust/.sqlx/query-0f547fc3a70bfd5b11b804dc971fc4944a76de2997b1fd077d25a5d3fdc6029b.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH next_job AS (\n SELECT *, lease_id as previous_lease_id\n FROM posthog_batchimport\n WHERE status = 'running' AND coalesce(leased_until, now()) <= now()\n ORDER BY created_at\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE posthog_batchimport\n SET\n status = 'running',\n leased_until = now() + interval '5 minutes'\n FROM next_job\n WHERE posthog_batchimport.id = next_job.id\n RETURNING\n posthog_batchimport.id,\n posthog_batchimport.team_id,\n posthog_batchimport.created_at,\n posthog_batchimport.updated_at,\n posthog_batchimport.status_message,\n posthog_batchimport.state,\n posthog_batchimport.import_config,\n posthog_batchimport.secrets,\n next_job.previous_lease_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "status_message", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "state", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "import_config", + "type_info": "Jsonb" + }, + { + "ordinal": 7, + "name": "secrets", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "previous_lease_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "0f547fc3a70bfd5b11b804dc971fc4944a76de2997b1fd077d25a5d3fdc6029b" +} diff --git a/rust/.sqlx/query-1f55549e1c3e14f539594ba9554fe53a98e367556b6e62681410e4786f6777f6.json b/rust/.sqlx/query-1f55549e1c3e14f539594ba9554fe53a98e367556b6e62681410e4786f6777f6.json new file mode 100644 index 0000000000000..ebedc80b0574c --- /dev/null +++ b/rust/.sqlx/query-1f55549e1c3e14f539594ba9554fe53a98e367556b6e62681410e4786f6777f6.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE posthog_batchimport\n SET\n status = $2,\n status_message = $3,\n state = $4,\n updated_at = now(),\n lease_id = $5,\n leased_until = $6\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Jsonb", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "1f55549e1c3e14f539594ba9554fe53a98e367556b6e62681410e4786f6777f6" +} diff --git a/rust/.sqlx/query-ace06e5c2b903628287dad09cf2ed811a5c261c94b9440b8faccf026597f9cd0.json b/rust/.sqlx/query-ace06e5c2b903628287dad09cf2ed811a5c261c94b9440b8faccf026597f9cd0.json new file mode 100644 index 0000000000000..0949e9195261f --- /dev/null +++ b/rust/.sqlx/query-ace06e5c2b903628287dad09cf2ed811a5c261c94b9440b8faccf026597f9cd0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT api_token FROM posthog_team WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "api_token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ace06e5c2b903628287dad09cf2ed811a5c261c94b9440b8faccf026597f9cd0" +} diff --git a/rust/.sqlx/query-c9d960277e49c289bcb6d682d3d30bb226c060fde9f24eea420f77ab3ec62a9f.json b/rust/.sqlx/query-c9d960277e49c289bcb6d682d3d30bb226c060fde9f24eea420f77ab3ec62a9f.json new file mode 100644 index 0000000000000..9619b10a9dc67 --- /dev/null +++ b/rust/.sqlx/query-c9d960277e49c289bcb6d682d3d30bb226c060fde9f24eea420f77ab3ec62a9f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE posthog_batchimport\n SET\n lease_id = null,\n leased_until = null,\n status = 'paused',\n status_message = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c9d960277e49c289bcb6d682d3d30bb226c060fde9f24eea420f77ab3ec62a9f" +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 686b39d14ff2d..193b478d91957 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -976,6 +976,36 @@ dependencies = [ "regex", ] +[[package]] +name = "batch-import-worker" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.7.5", + "base64 0.22.0", + "chrono", + "common-alloc", + "common-dns", + "common-kafka", + "common-metrics", + "common-types", + "envconfig", + "fernet", + "health", + "httpmock", + "rayon", + "reqwest 0.12.3", + "serde", + "serde_json", + "sqlx", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "better_scoped_tls" version = "0.1.2" @@ -1333,6 +1363,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1942,6 +1982,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "fernet" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66b725fe9483b9ee72ccaec072b15eb8ad95a3ae63a8c798d5748883b72fd33" +dependencies = [ + "base64 0.22.0", + "byteorder", + "getrandom", + "openssl", + "zeroize", +] + [[package]] name = "ff" version = "0.12.1" @@ -4197,6 +4250,26 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rdkafka" version = "0.37.0" @@ -6585,6 +6658,20 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", +] [[package]] name = "zip" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4450404cca14f..3f9c98a524721 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "property-defs-rs", + "batch-import-worker", "capture", "common/health", "common/metrics", @@ -23,7 +24,6 @@ members = [ [workspace.lints.rust] # See https://doc.rust-lang.org/stable/rustc/lints/listing/allowed-by-default.html -unsafe_code = "forbid" # forbid cannot be ignored with an annotation unstable_features = "forbid" macro_use_extern_crate = "forbid" let_underscore_drop = "deny" @@ -97,3 +97,6 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } aws-sdk-s3 = "1.58.0" mockall = "0.13.0" moka = { version = "0.12.8", features = ["sync", "future"] } + +# Used for decrypting django encrypted fields +fernet = "0.2" diff --git a/rust/batch-import-worker/Cargo.toml b/rust/batch-import-worker/Cargo.toml new file mode 100644 index 0000000000000..48ae9cae61e8e --- /dev/null +++ b/rust/batch-import-worker/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "batch-import-worker" +version = "0.0.1" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tokio = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +serde_json = { workspace = true } +serde = { workspace = true } +common-kafka = { path = "../common/kafka" } +common-alloc = { path = "../common/alloc" } +common-types = { path = "../common/types" } +common-metrics = { path = "../common/metrics" } +common-dns = { path = "../common/dns" } +health = { path = "../common/health" } +anyhow = { workspace = true } +envconfig = { workspace = true } +fernet = { workspace = true } +base64 = { workspace = true } +axum = { workspace = true } +reqwest = { workspace = true } +rayon = "1.10.0" + +[dev-dependencies] +tempfile = "3.8" +httpmock = { workspace = true } diff --git a/rust/batch-import-worker/src/config.rs b/rust/batch-import-worker/src/config.rs new file mode 100644 index 0000000000000..2b40097d13ba6 --- /dev/null +++ b/rust/batch-import-worker/src/config.rs @@ -0,0 +1,29 @@ +use common_kafka::config::KafkaConfig; +use envconfig::Envconfig; + +#[derive(Envconfig, Clone)] +pub struct Config { + // ~100MB + #[envconfig(default = "100000000")] + pub chunk_size: usize, + + #[envconfig(from = "BIND_HOST", default = "::")] + pub host: String, + + #[envconfig(from = "BIND_PORT", default = "3301")] + pub port: u16, + + #[envconfig(nested = true)] + pub kafka: KafkaConfig, + + #[envconfig(default = "postgres://posthog:posthog@localhost:5432/posthog")] + pub database_url: String, + + // Rust service connect directly to postgres, not via pgbouncer, so we keep this low + #[envconfig(default = "4")] + pub max_pg_connections: u32, + + // Same test key as the plugin server + #[envconfig(default = "00beef0000beef0000beef0000beef00")] + pub encryption_keys: String, // comma separated list of fernet keys +} diff --git a/rust/batch-import-worker/src/context.rs b/rust/batch-import-worker/src/context.rs new file mode 100644 index 0000000000000..2a0be89488989 --- /dev/null +++ b/rust/batch-import-worker/src/context.rs @@ -0,0 +1,84 @@ +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::Error; +use health::HealthRegistry; +use sqlx::postgres::PgPoolOptions; + +use crate::config::Config; + +pub struct AppContext { + pub config: Config, + pub db: sqlx::PgPool, + pub encryption_keys: Vec, // fernet, base64-urlsafe encoded 32-byte long key + pub health_registry: HealthRegistry, + pub running: AtomicBool, // Set to false on SIGTERM, etc. +} + +impl AppContext { + pub async fn new(config: &Config) -> Result { + let health_registry = HealthRegistry::new("liveness"); + + let options = PgPoolOptions::new().max_connections(config.max_pg_connections); + let db = options.connect(&config.database_url).await?; + + let ctx = Self { + config: config.clone(), + db, + encryption_keys: config + .encryption_keys + .split(",") + .map(|s| s.to_string()) + .collect(), + health_registry, + running: AtomicBool::new(true), + }; + + Ok(ctx) + } + + pub async fn get_token_for_team_id(&self, team_id: i32) -> Result { + Ok( + sqlx::query_scalar!("SELECT api_token FROM posthog_team WHERE id = $1", team_id) + .fetch_one(&self.db) + .await?, + ) + } + + pub fn is_running(&self) -> bool { + self.running.load(std::sync::atomic::Ordering::SeqCst) + } + + pub fn stop(&self) { + self.running + .store(false, std::sync::atomic::Ordering::SeqCst); + } + + // Listen for all signals that indicate we should shut down, and if we receive one, stop the app. + // Handled signals are SIGTERM and SIGINT + #[cfg(unix)] + pub fn spawn_shutdown_listener(self: Arc) { + use tokio::signal::unix::SignalKind; + use tracing::info; + + tokio::spawn(async move { + let mut term = tokio::signal::unix::signal(SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + let mut int = tokio::signal::unix::signal(SignalKind::interrupt()) + .expect("failed to register SIGINT handler"); + + let recvd = tokio::select! { + _ = term.recv() => "SIGTERM", + _ = int.recv() => "SIGINT", + }; + + info!(signal = recvd, "Received signal, shutting down"); + self.stop(); + }); + } + + #[cfg(windows)] + pub fn spawn_shutdown_listener(self: Arc) { + unimplemented!() // We simply do not support running this code in a windows environment + } +} diff --git a/rust/batch-import-worker/src/emit/kafka.rs b/rust/batch-import-worker/src/emit/kafka.rs new file mode 100644 index 0000000000000..15c5d3868b751 --- /dev/null +++ b/rust/batch-import-worker/src/emit/kafka.rs @@ -0,0 +1,144 @@ +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use anyhow::Error; +use async_trait::async_trait; +use common_kafka::transaction::{KafkaTransaction, TransactionalProducer}; +use common_types::InternallyCapturedEvent; +use tracing::info; + +use crate::{context::AppContext, job::config::KafkaEmitterConfig}; + +use super::Emitter; + +pub struct KafkaEmitter { + state: EmitterState, + topic: String, + send_rate: u64, // Messages sent per second + last_send_finished_time: Option, +} + +enum EmitterState { + Idle(TransactionalProducer), + Transition, + Writing { + txn: KafkaTransaction, + start: Instant, + count: AtomicUsize, + }, +} + +// TODO - this interface kinda sucks - really the emitter should be using typestate or +// something internally, but the trait interface isn't well designed to allow for that +impl EmitterState { + fn begin(&mut self) -> Result<(), Error> { + let taken = std::mem::replace(self, Self::Transition); + match taken { + Self::Idle(producer) => { + let transaction = producer.begin()?; + *self = Self::Writing { + txn: transaction, + start: Instant::now(), + count: AtomicUsize::new(0), + }; + Ok(()) + } + _ => { + *self = taken; + Err(Error::msg("Invalid state transition")) + } + } + } + + fn commit(&mut self) -> Result<(usize, Instant), Error> { + let taken = std::mem::replace(self, Self::Transition); + match taken { + Self::Writing { txn, count, start } => { + let producer = txn.commit()?; + *self = Self::Idle(producer); + Ok((count.load(Ordering::SeqCst), start)) + } + _ => { + *self = taken; + Err(Error::msg("Invalid state transition")) + } + } + } +} + +impl KafkaEmitter { + pub async fn new( + emitter_config: KafkaEmitterConfig, + transactional_id: &str, // Kafka transactional ID + context: Arc, + ) -> Result { + let producer = TransactionalProducer::from_config( + &context.config.kafka, + transactional_id, + Duration::from_secs(emitter_config.transaction_timeout_seconds), + )?; + + let state = EmitterState::Idle(producer); + + Ok(Self { + state, + topic: emitter_config.topic, + send_rate: emitter_config.send_rate, + last_send_finished_time: None, + }) + } + + fn get_min_txn_duration(&self, txn_count: usize, txn_start: Instant) -> Duration { + // Get how long the send must take if this is the first send + let send_rate = self.send_rate as f64; + let batch_size = txn_count as f64; + let mut min_duration = Duration::from_secs_f64(batch_size / send_rate); + + // If we've sent before, and there's a gap between the last send and now, we can subtract that + // from the minimum duration, since it's a period we spent not-sending + if let Some(instant) = self.last_send_finished_time { + let gap = txn_start - instant; + min_duration = min_duration.saturating_sub(gap); + } + min_duration + } +} + +#[async_trait] +impl Emitter for KafkaEmitter { + async fn begin_write(&mut self) -> Result<(), Error> { + self.state.begin() + } + + async fn emit(&self, data: &[InternallyCapturedEvent]) -> Result<(), Error> { + let EmitterState::Writing { txn, count, .. } = &self.state else { + return Err(Error::msg("Cannot emit in a non-writing state")); + }; + + txn.send_keyed_iter_to_kafka(&self.topic, |e| Some(e.inner.key()), data.iter()) + .await?; + + count.fetch_add(data.len(), Ordering::SeqCst); + + Ok(()) + } + + async fn commit_write(&mut self) -> Result<(), Error> { + let (count, start) = self.state.commit()?; + let min_duration = self.get_min_txn_duration(count, start); + let txn_elapsed = start.elapsed(); + let to_sleep = min_duration.saturating_sub(txn_elapsed); + info!( + "sent {} messages in {:?}, minimum send duration is {:?}, sleeping for {:?}", + count, txn_elapsed, min_duration, to_sleep + ); + tokio::time::sleep(to_sleep).await; + self.last_send_finished_time = Some(Instant::now()); + Ok(()) + } +} diff --git a/rust/batch-import-worker/src/emit/mod.rs b/rust/batch-import-worker/src/emit/mod.rs new file mode 100644 index 0000000000000..f72fbc79e3d60 --- /dev/null +++ b/rust/batch-import-worker/src/emit/mod.rs @@ -0,0 +1,83 @@ +use anyhow::Error; +use async_trait::async_trait; +use common_types::InternallyCapturedEvent; +use tokio::io::AsyncWriteExt; +use tracing::info; + +pub mod kafka; + +#[async_trait] +pub trait Emitter: Send + Sync { + async fn begin_write(&mut self) -> Result<(), Error> { + Ok(()) + } + + async fn emit(&self, data: &[InternallyCapturedEvent]) -> Result<(), Error>; + + async fn commit_write(&mut self) -> Result<(), Error> { + Ok(()) + } +} + +pub struct StdoutEmitter { + pub as_json: bool, +} + +#[async_trait] +impl Emitter for StdoutEmitter { + async fn emit(&self, data: &[InternallyCapturedEvent]) -> Result<(), Error> { + for event in data { + if self.as_json { + println!("{}", serde_json::to_string(&event)?); + } else { + println!("{:?}", event); + } + } + Ok(()) + } +} + +pub struct NoOpEmitter; + +#[async_trait] +impl Emitter for NoOpEmitter { + async fn emit(&self, _data: &[InternallyCapturedEvent]) -> Result<(), Error> { + Ok(()) + } +} + +pub struct FileEmitter { + pub path: String, + pub as_json: bool, +} + +impl FileEmitter { + pub async fn new(path: String, as_json: bool, cleanup: bool) -> Result { + info!("Creating file emitter at {}", path); + if cleanup { + tokio::fs::remove_file(&path).await.ok(); + } + Ok(Self { path, as_json }) + } +} + +#[async_trait] +impl Emitter for FileEmitter { + async fn emit(&self, data: &[InternallyCapturedEvent]) -> Result<(), Error> { + info!("Writing {} events to file {}", data.len(), self.path); + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .await?; + for event in data { + let data = if self.as_json { + format!("{}\n", serde_json::to_string(&event)?) + } else { + format!("{:?}\n", event) + }; + file.write_all(data.as_bytes()).await?; + } + Ok(()) + } +} diff --git a/rust/batch-import-worker/src/job/config.rs b/rust/batch-import-worker/src/job/config.rs new file mode 100644 index 0000000000000..82c0d251a60b0 --- /dev/null +++ b/rust/batch-import-worker/src/job/config.rs @@ -0,0 +1,171 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use anyhow::Error; +use base64::{prelude::BASE64_URL_SAFE, Engine}; +use fernet::MultiFernet; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + context::AppContext, + emit::{kafka::KafkaEmitter, Emitter, FileEmitter, NoOpEmitter, StdoutEmitter}, + parse::format::FormatConfig, + source::{folder::FolderSource, url_list::UrlList, DataSource}, +}; + +use super::model::JobModel; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct JobConfig { + pub source: SourceConfig, + // What format is the data in, e.g. Mixpanel events stored in json-lines + pub data_format: FormatConfig, + pub sink: SinkConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SourceConfig { + Folder(FolderSourceConfig), + UrlList(UrlListConfig), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct UrlListConfig { + urls_key: String, + #[serde(default)] + allow_internal_ips: bool, + #[serde(default = "UrlListConfig::default_timeout_seconds")] + timeout_seconds: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FolderSourceConfig { + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum SinkConfig { + Stdout { + as_json: bool, + }, + File { + path: String, + as_json: bool, + cleanup: bool, + }, + Kafka(KafkaEmitterConfig), + NoOp, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KafkaEmitterConfig { + pub topic: String, + pub send_rate: u64, + pub transaction_timeout_seconds: u64, +} + +#[derive(Debug, Clone)] +pub struct JobSecrets { + pub secrets: HashMap, +} + +// Jobsecrets is a json object, encrypted using fernet and then base64 urlsafe encoded. +impl JobSecrets { + // Data is base64 url-safe encoded + pub fn decrypt(data: &str, keys: &[String]) -> Result { + // Matching the plugin server, each key is 32 btyes of utf8 data, which we + // then, treating as raw bytes, b64 encode before passing into fernet. I'm + // a little thrown off by this - seems like keys could just be the already + // encoded strings, but :shrug:, this is how it's done there, so it's how + // I'll do it here + let fernets: Vec<_> = keys + .iter() + .map(|k| k.as_bytes()) + .map(|b| BASE64_URL_SAFE.encode(b)) + .filter_map(|k| fernet::Fernet::new(&k)) + .collect(); + let fernet = MultiFernet::new(fernets); + let decrypted = fernet.decrypt(data)?; + let secrets = serde_json::from_slice(&decrypted)?; + Ok(Self { secrets }) + } + + pub fn encrypt(&self, keys: &[String]) -> Result { + let fernet = MultiFernet::new(keys.iter().filter_map(|k| fernet::Fernet::new(k)).collect()); + let serialized = serde_json::to_vec(&self.secrets)?; + let encrypted = fernet.encrypt(&serialized); + Ok(encrypted) + } +} + +impl SourceConfig { + pub async fn construct( + &self, + secrets: &JobSecrets, + _context: Arc, + ) -> Result, Error> { + match self { + SourceConfig::Folder(config) => Ok(Box::new(config.create_source().await?)), + SourceConfig::UrlList(config) => Ok(Box::new(config.create_source(secrets).await?)), + } + } +} + +impl SinkConfig { + pub async fn construct( + &self, + context: Arc, + model: &JobModel, + ) -> Result, Error> { + match self { + SinkConfig::Stdout { as_json } => Ok(Box::new(StdoutEmitter { as_json: *as_json })), + SinkConfig::NoOp => Ok(Box::new(NoOpEmitter {})), + SinkConfig::File { + path, + as_json, + cleanup, + } => Ok(Box::new( + FileEmitter::new(path.clone(), *as_json, *cleanup).await?, + )), + SinkConfig::Kafka(kafka_emitter_config) => Ok(Box::new( + // We use the job id as the kafka transactional id, since it's persistent across + // e.g. restarts and worker-job-passing, but still allows multiple jobs/workers to + // emit to kafka at the same time. + KafkaEmitter::new(kafka_emitter_config.clone(), &model.id.to_string(), context) + .await?, + )), + } + } +} + +impl FolderSourceConfig { + pub async fn create_source(&self) -> Result { + FolderSource::new(self.path.clone()).await + } +} + +impl UrlListConfig { + pub async fn create_source(&self, secrets: &JobSecrets) -> Result { + let urls = secrets + .secrets + .get(&self.urls_key) + .ok_or(Error::msg(format!("Missing urls as key {}", self.urls_key)))?; + + let urls: Vec = serde_json::from_value(urls.clone())?; + + UrlList::new( + urls, + self.allow_internal_ips, + Duration::from_secs(self.timeout_seconds), + ) + .await + } + + fn default_timeout_seconds() -> u64 { + 30 + } +} diff --git a/rust/batch-import-worker/src/job/mod.rs b/rust/batch-import-worker/src/job/mod.rs new file mode 100644 index 0000000000000..fd70b9d17bdad --- /dev/null +++ b/rust/batch-import-worker/src/job/mod.rs @@ -0,0 +1,293 @@ +use std::sync::Arc; + +use anyhow::{Context, Error}; + +use common_types::InternallyCapturedEvent; +use model::{JobModel, JobState, PartState}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +use crate::{ + context::AppContext, + emit::Emitter, + parse::{format::ParserFn, Parsed}, + source::DataSource, +}; + +pub mod config; +pub mod model; + +pub struct Job { + pub context: Arc, + + // The job maintains a copy of the job state, outside of the model, + // because process in a pipelined fashion, and to do that we need to + // seperate "in-memory job state" from "database job state" + pub state: Mutex, + // We also maintain a copy of the model. This and the above are wrapped in a mutex + // because we simultaneously modify both, modifying our in-memory state when we + // fetch and parse data, and then updating the model state when we commit data + pub model: Mutex, + + pub source: Box, + pub transform: Arc, + + // We keep a mutex here so we can mutably borrow this and the job state at the same time + pub sink: Mutex>, + + // We want to fetch data and send it at the same time, and this acts as a temporary store + // for the data we've fetched, but not yet sent + checkpoint: Mutex>, +} + +struct Checkpoint { + key: String, + data: Parsed>, +} + +impl Job { + pub async fn new(mut model: JobModel, context: Arc) -> Result { + let source = model + .import_config + .source + .construct(&model.secrets, context.clone()) + .await?; + + let transform = Box::new( + model + .import_config + .data_format + .get_parser(&model, context.clone()) + .await?, + ); + + let sink = model + .import_config + .sink + .construct(context.clone(), &model) + .await?; + + let mut state = model + .state + .as_ref() + .cloned() + .unwrap_or_else(|| JobState { parts: vec![] }); + + if state.parts.is_empty() { + info!("Found job with no parts, initializing parts list"); + // If we have no parts, we assume this is the first time picking up the job, + // and populate the parts list + let mut parts = Vec::new(); + let keys = source.keys().await?; + for key in keys { + let size = source.size(&key).await?; + debug!("Got size for part {}: {}", key, size); + parts.push(PartState { + key, + current_offset: 0, + total_size: size, + }); + } + state.parts = parts; + model.state = Some(state.clone()); + info!("Initialized parts list: {:?}", state.parts); + } + + Ok(Self { + context, + model: Mutex::new(model), + state: Mutex::new(state), + source, + transform: Arc::new(transform), + sink: Mutex::new(sink), + checkpoint: Mutex::new(None), + }) + } + + pub async fn process(self) -> Result, Error> { + let next_chunk_fut = self.get_next_chunk(); + let next_commit_fut = self.do_commit(); + + let (next_chunk, next_commit) = tokio::join!(next_chunk_fut, next_commit_fut); + + if let Err(e) = next_commit { + // If we fail to commit, we just log and bail out - the job will be paused if it needs to be, + // but this pod should restart, in case it's sink is in some bad state + error!("Failed to commit chunk: {}", e); + return Err(e); + } + + let next = match next_chunk { + Ok(Some(chunk)) => chunk, + Ok(None) => { + // We're done fetching and parsing, so we can complete the job + self.successfully_complete().await?; + return Ok(None); + } + Err(e) => { + // If we fail to fetch and parse, we need to pause the job (assuming manual intervention is required) and + // return an Ok(None) - this pod can continue to process other jobs, it just can't work on this one + error!("Failed to fetch and parse chunk: {}", e); + self.model + .lock() + .await + .pause( + self.context.clone(), + format!("Failed to fetch and parse chunk: {}", e), + ) + .await?; + return Ok(None); + } + }; + + let mut checkpoint = self.checkpoint.lock().await; + *checkpoint = Some(Checkpoint { + key: next.0, + data: next.1, + }); + + drop(checkpoint); + + // This wasn't the last part/chunk, so we return the job to let it be processed again + Ok(Some(self)) + } + + async fn get_next_chunk( + &self, + ) -> Result>)>, Error> { + let mut state = self.state.lock().await; + + let Some(next_part) = state.parts.iter_mut().find(|p| !p.is_done()) else { + info!("Found no next part, returning"); + return Ok(None); // We're done fetching + }; + + info!("Fetching part chunk {:?}", next_part); + + let next_chunk = self + .source + .get_chunk( + &next_part.key, + next_part.current_offset, + self.context.config.chunk_size, + ) + .await + .context(format!("Fetching part chunk {:?}", next_part))?; + + let is_last_chunk = next_part.current_offset + next_chunk.len() > next_part.total_size; + + let chunk_bytes = next_chunk.len(); + + info!("Fetched part chunk {:?}", next_part); + let m_tf = self.transform.clone(); + // This is computationally expensive, so we run it in a blocking task + let parsed = tokio::task::spawn_blocking(move || (m_tf)(next_chunk)) + .await? + .context(format!("Processing part chunk {:?}", next_part))?; + + info!( + "Parsed part chunk {:?}, consumed {} bytes", + next_part, parsed.consumed + ); + + // If this is the last chunk, and we didn't consume all of it, or we didn't manage to + // consume any of this chunk, we've got a bad chunk, and should pause the job with an error. + if parsed.consumed < chunk_bytes && is_last_chunk || parsed.data.is_empty() { + return Err(Error::msg(format!( + "Failed to parse any data from part {} at offset {}", + next_part.key, next_part.current_offset + ))); + } + + // Update the in-memory part state (the read will be committed to the DB once the write is done) + next_part.current_offset += parsed.consumed; + + Ok(Some((next_part.key.clone(), parsed))) + } + + async fn do_commit(&self) -> Result<(), Error> { + self.shutdown_guard()?; + let mut checkpoint_lock = self.checkpoint.lock().await; + + let Some(checkpoint) = checkpoint_lock.take() else { + info!("No checkpointed data to commit, returning"); + return Ok(()); // We've got no checkpointed data to commit, so we're done + }; + + let (key, parsed) = (checkpoint.key, checkpoint.data); + + info!("Committing part {} consumed {} bytes", key, parsed.consumed); + info!("Committing {} events", parsed.data.len()); + + let mut sink = self.sink.lock().await; + self.shutdown_guard()?; + // If this fails, we just bail out, and then eventually someone else will pick up the job again and re-process this chunk + sink.begin_write().await?; + info!("Writing {} events", parsed.data.len()); + // If this fails, as above + self.shutdown_guard()?; + sink.emit(&parsed.data).await?; + // This is where things get tricky - if we fail to commit the chunk to the sink in the next step, and we've told PG we've + // committed the chunk, we'll bail out, and whoever comes next will end up skipping this chunk. To prevent this, we do a two + // stage commit, where we pause the job before committing the chunk to the sink, and then only unpause it after the sink commit, + // such that if we get interrupted between the two, the job will be paused, and manual intervention will be required to resume it. + // This operator can then confirm whether the sink commit succeeded or not (by looking at the last event written, or by + // looking at logs, or both). The jobs status message is set to enable this kind of debugging. + self.shutdown_guard()?; // This is the last time we call this during the commit - if we get this far, we want to commit fully if at all possible + info!("Beginning PG part commit"); + self.begin_part_commit(&key, parsed.consumed).await?; + info!("Beginning emitter part commit"); + sink.commit_write().await?; + info!("Finishing PG part commit"); + self.complete_commit().await?; + info!("Committed part {} consumed {} bytes", key, parsed.consumed); + + Ok(()) + } + + async fn successfully_complete(self) -> Result<(), Error> { + let mut model = self.model.lock().await; + model.complete(&self.context.db).await + } + + // Writes the new partstate to the DB, and sets the job status to paused, such that if there's an issue with the sink commit, the job + // will be paused, and manual intervention will be required to resume it + async fn begin_part_commit(&self, key: &str, consumed: usize) -> Result<(), Error> { + let mut model = self.model.lock().await; + let Some(model_state) = &mut model.state else { + return Err(Error::msg("No model state found")); + }; + + // Iterate through the parts list and update the relevant part + let Some(part) = model_state.parts.iter_mut().find(|p| p.key == key) else { + return Err(Error::msg(format!("No part found with key {}", key))); + }; + + part.current_offset += consumed; + + let status_message = format!( + "Starting commit of part {} to offset {}, consumed {} additional bytes", + key, part.current_offset, consumed + ); + + model.pause(self.context.clone(), status_message).await + } + + // Unpauses the job + async fn complete_commit(&self) -> Result<(), Error> { + let mut model = self.model.lock().await; + model.unpause(self.context.clone()).await + } + + // Used during the commit operations as a shorthand way to bail before an operation if we get a shutdown signal + fn shutdown_guard(&self) -> Result<(), Error> { + if !self.context.is_running() { + warn!("Running flag set to flase during job processing, bailing"); + Err(Error::msg( + "Running flag set to flase during job processing, bailing", + )) + } else { + Ok(()) + } + } +} diff --git a/rust/batch-import-worker/src/job/model.rs b/rust/batch-import-worker/src/job/model.rs new file mode 100644 index 0000000000000..1c709e9206c94 --- /dev/null +++ b/rust/batch-import-worker/src/job/model.rs @@ -0,0 +1,256 @@ +use std::{fmt::Display, sync::Arc}; + +use anyhow::{Context, Error}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tracing::warn; +use uuid::Uuid; + +use crate::context::AppContext; + +use super::config::{JobConfig, JobSecrets}; + +#[derive(Debug, Clone)] +pub struct JobModel { + pub id: Uuid, + pub team_id: i32, + pub created_at: DateTime, + pub updated_at: DateTime, + + pub lease_id: Option, + pub leased_until: Option>, + + pub status: JobStatus, + pub status_message: Option, + + pub state: Option, + pub import_config: JobConfig, + pub secrets: JobSecrets, + + // Not actually in the model, but we calculate it on fetch to let us reason about whether + // we're resuming an interrupted job + pub was_leased: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Paused, + Failed, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub struct JobState { + // Parts are sorted, and we iterate through them in order, to let us import + // from oldest to newest + pub parts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub struct PartState { + pub key: String, + pub current_offset: usize, + pub total_size: usize, +} + +impl PartState { + pub fn is_done(&self) -> bool { + self.current_offset == self.total_size + } +} + +impl JobModel { + pub async fn claim_next_job(context: Arc) -> Result, Error> { + // We use select for update to lock a row, then update it, returning the updated row + let row = sqlx::query_as!( + JobRow, + r#" + WITH next_job AS ( + SELECT *, lease_id as previous_lease_id + FROM posthog_batchimport + WHERE status = 'running' AND coalesce(leased_until, now()) <= now() + ORDER BY created_at + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + UPDATE posthog_batchimport + SET + status = 'running', + leased_until = now() + interval '5 minutes' + FROM next_job + WHERE posthog_batchimport.id = next_job.id + RETURNING + posthog_batchimport.id, + posthog_batchimport.team_id, + posthog_batchimport.created_at, + posthog_batchimport.updated_at, + posthog_batchimport.status_message, + posthog_batchimport.state, + posthog_batchimport.import_config, + posthog_batchimport.secrets, + next_job.previous_lease_id + "#, + ) + .fetch_optional(&context.db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let id = row.id; + + match (row, context.encryption_keys.as_slice()) + .try_into() + .context("Failed to parse job row") + { + Ok(model) => Ok(Some(model)), + Err(e) => { + // If we failed to parse a job, we pause it and leave it for manual intervention + sqlx::query!( + r#" + UPDATE posthog_batchimport + SET + lease_id = null, + leased_until = null, + status = 'paused', + status_message = $2 + WHERE id = $1 + "#, + id, + format!("{:?}", e) // We like context + ) + .execute(&context.db) + .await?; + + warn!("Failed to parse job {}: {:?}", id, e); + + // We return None here because "failure to parse the job" shouldn't cause the + // worker to crash (unlike e.g. failures to talk to PG in this function, which + // should) + Ok(None) + } + } + } + + pub async fn flush(&mut self, pool: &PgPool, extend_lease: bool) -> Result<(), Error> { + if extend_lease { + self.lease_id = Some(Uuid::now_v7().to_string()); + } else { + self.lease_id = None; + }; + + if extend_lease { + self.leased_until = + Some(self.leased_until.unwrap_or_else(Utc::now) + chrono::Duration::minutes(5)); + } else { + self.leased_until = None; + }; + + sqlx::query!( + r#" + UPDATE posthog_batchimport + SET + status = $2, + status_message = $3, + state = $4, + updated_at = now(), + lease_id = $5, + leased_until = $6 + WHERE id = $1 + "#, + self.id, + self.status.to_string(), + self.status_message, + serde_json::to_value(&self.state)?, + self.lease_id, + self.leased_until + ) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn pause(&mut self, context: Arc, reason: String) -> Result<(), Error> { + self.status = JobStatus::Paused; + self.status_message = Some(reason); + self.flush(&context.db, false).await + } + + pub async fn unpause(&mut self, context: Arc) -> Result<(), Error> { + self.status = JobStatus::Running; + self.status_message = None; + self.flush(&context.db, true).await + } + + pub async fn fail(&mut self, pool: &PgPool, reason: String) -> Result<(), Error> { + self.status = JobStatus::Failed; + self.status_message = Some(reason); + self.flush(pool, false).await + } + + pub async fn complete(&mut self, pool: &PgPool) -> Result<(), Error> { + self.status = JobStatus::Completed; + self.flush(pool, false).await + } +} + +impl Display for JobStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JobStatus::Running => write!(f, "running"), + JobStatus::Paused => write!(f, "paused"), + JobStatus::Failed => write!(f, "failed"), + JobStatus::Completed => write!(f, "completed"), + } + } +} + +struct JobRow { + id: Uuid, + team_id: i32, + created_at: DateTime, + updated_at: DateTime, + status_message: Option, + state: Option, + import_config: serde_json::Value, + secrets: String, + previous_lease_id: Option, +} + +impl TryFrom<(JobRow, &[String])> for JobModel { + type Error = Error; + + fn try_from(input: (JobRow, &[String])) -> Result { + let (row, keys) = input; + let state = match row.state { + Some(s) => serde_json::from_value(s).context("Parsing state")?, + None => JobState { parts: vec![] }, + }; + + let import_config = serde_json::from_value(row.import_config).context("Parsing config")?; + + let secrets = JobSecrets::decrypt(&row.secrets, keys).context("Parsing keys")?; + + Ok(JobModel { + id: row.id, + team_id: row.team_id, + created_at: row.created_at, + updated_at: row.updated_at, + lease_id: None, + leased_until: None, + status: JobStatus::Running, + status_message: row.status_message, + state: Some(state), + import_config, + secrets, + was_leased: row.previous_lease_id.is_some(), + }) + } +} diff --git a/rust/batch-import-worker/src/lib.rs b/rust/batch-import-worker/src/lib.rs new file mode 100644 index 0000000000000..bd96a60742665 --- /dev/null +++ b/rust/batch-import-worker/src/lib.rs @@ -0,0 +1,6 @@ +pub mod config; +pub mod context; +pub mod emit; +pub mod job; +pub mod parse; +pub mod source; diff --git a/rust/batch-import-worker/src/main.rs b/rust/batch-import-worker/src/main.rs new file mode 100644 index 0000000000000..a1a6eb4f593ea --- /dev/null +++ b/rust/batch-import-worker/src/main.rs @@ -0,0 +1,100 @@ +use std::{future::ready, sync::Arc, time::Duration}; + +use anyhow::Error; +use axum::{routing::get, Router}; +use batch_import_worker::{ + config::Config, + context::AppContext, + job::{model::JobModel, Job}, +}; +use common_metrics::{serve, setup_metrics_routes}; +use envconfig::Envconfig; +use tokio::task::JoinHandle; +use tracing::{error, info}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; + +common_alloc::used!(); + +fn setup_tracing() { + let log_layer: tracing_subscriber::filter::Filtered< + tracing_subscriber::fmt::Layer, + EnvFilter, + tracing_subscriber::Registry, + > = tracing_subscriber::fmt::layer().with_filter(EnvFilter::from_default_env()); + tracing_subscriber::registry().with(log_layer).init(); +} + +pub async fn index() -> &'static str { + "batch import worker" +} + +fn start_health_liveness_server(config: &Config, context: Arc) -> JoinHandle<()> { + let config = config.clone(); + let router = Router::new() + .route("/", get(index)) + .route("/_readiness", get(index)) + .route( + "/_liveness", + get(move || ready(context.health_registry.get_status())), + ); + let router = setup_metrics_routes(router); + let bind = format!("{}:{}", config.host, config.port); + tokio::task::spawn(async move { + serve(router, &bind) + .await + .expect("failed to start serving metrics"); + }) +} + +#[tokio::main] +pub async fn main() -> Result<(), Error> { + setup_tracing(); + info!("Starting up..."); + + let config = Config::init_from_env().unwrap(); + let context = Arc::new(AppContext::new(&config).await.unwrap()); + + context.clone().spawn_shutdown_listener(); + + start_health_liveness_server(&config, context.clone()); + + while context.is_running() { + info!("Looking for next job"); + let Some(model) = JobModel::claim_next_job(context.clone()).await? else { + if !context.is_running() { + break; + } + info!("No available job found, sleeping"); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + }; + + info!("Claimed job: {:?}", model.id); + + let mut next_step = Some(Job::new(model, context.clone()).await?); + while let Some(job) = next_step { + if !context.is_running() { + info!("Shutting down, dropping job"); + // if we're shutting down, we just drop the job - it'll remain leased for a few minutes, then another + // worker will come along and pick it up + break; + } + next_step = match job.process().await { + Ok(next) => next, + Err(e) => { + // process_next_chunk is written such that if an error occurs that should + // prevent the job from being picked up by a subsequent worker (which generally + // means an error in chunk commits to the jobs sink), the job will be in a paused + // state. This is why, in the event of an error, we don't try to set the job model + // state in PG - the job itself handles all of that. + error!("Error processing job: {:?}, dropping", e); + None + } + }; + } + } + + info!("Shutting down"); + + Ok(()) +} diff --git a/rust/batch-import-worker/src/parse/content/captured.rs b/rust/batch-import-worker/src/parse/content/captured.rs new file mode 100644 index 0000000000000..52c3e0cd1cd85 --- /dev/null +++ b/rust/batch-import-worker/src/parse/content/captured.rs @@ -0,0 +1,43 @@ +use anyhow::Error; +use chrono::Utc; +use common_types::{CapturedEvent, InternallyCapturedEvent, RawEvent}; +use uuid::Uuid; + +use super::TransformContext; + +pub fn captured_parse_fn( + context: TransformContext, +) -> impl Fn(RawEvent) -> Result { + move |raw| { + let Some(distinct_id) = raw.extract_distinct_id() else { + return Err(Error::msg("No distinct_id found")); + }; + // We'll respect the events uuid if ones set + let uuid = raw.uuid.unwrap_or_else(Uuid::now_v7); + // Grab the events timestamp, or make one up + let timestamp = get_timestamp(&raw); + + let event = CapturedEvent { + uuid, + distinct_id, + ip: "127.0.0.1".to_string(), + data: serde_json::to_string(&raw)?, + now: timestamp, + sent_at: None, // We don't know when it was sent at, since it's a historical import + token: context.token.clone(), + }; + + Ok(InternallyCapturedEvent { + team_id: context.team_id, + inner: event, + }) + } +} + +// We use the events timestamp value, if it has one, otherwise we use the current time +fn get_timestamp(event: &RawEvent) -> String { + match &event.timestamp { + Some(timestamp) => timestamp.clone(), + None => Utc::now().to_rfc3339(), + } +} diff --git a/rust/batch-import-worker/src/parse/content/mixpanel.rs b/rust/batch-import-worker/src/parse/content/mixpanel.rs new file mode 100644 index 0000000000000..d68c4c8e54ee7 --- /dev/null +++ b/rust/batch-import-worker/src/parse/content/mixpanel.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; + +use anyhow::Error; +use chrono::{DateTime, Utc}; +use common_types::{CapturedEvent, InternallyCapturedEvent, RawEvent}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use super::TransformContext; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct MixpanelEvent { + event: String, + properties: MixpanelProperties, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct MixpanelProperties { + #[serde(rename = "time")] + timestamp_ms: i64, + distinct_id: Option, + #[serde(flatten)] + other: HashMap, +} + +// Based off sample data provided by customer. +impl MixpanelEvent { + pub fn parse_fn( + context: TransformContext, + ) -> impl Fn(Self) -> Result { + move |mx| { + let token = context.token.clone(); + let team_id = context.team_id; + + // Getting entropy is surprisingly expensive, so don't do it a lot unless we have to + let generated_id = Uuid::now_v7(); + + let distinct_id = mx + .properties + .distinct_id + .as_ref() + .cloned() + .unwrap_or(format!("mixpanel-generated-{}", generated_id)); + + // We don't support subsecond precision for historical imports + let timestamp = DateTime::::from_timestamp(mx.properties.timestamp_ms / 1000, 0) + .ok_or(Error::msg("Invalid timestamp"))?; + + let raw_event = RawEvent { + token: Some(token.clone()), + distinct_id: Some(Value::String(distinct_id.clone())), + uuid: Some(generated_id), + event: map_event_names(mx.event), + properties: mx.properties.other, + // We send timestamps in iso 1806 format + timestamp: Some(timestamp.to_rfc3339()), + set: None, + set_once: None, + offset: None, + }; + + let inner = CapturedEvent { + uuid: generated_id, + distinct_id, + ip: "127.0.0.1".to_string(), + data: serde_json::to_string(&raw_event)?, + now: Utc::now().to_rfc3339(), + sent_at: None, + token, + }; + + Ok(InternallyCapturedEvent { team_id, inner }) + } + } +} + +// Maps mixpanel event names to posthog event names +pub fn map_event_names(event: String) -> String { + // TODO - add more as you find them + match event.as_str() { + "$mp_web_page_view" => "$pageview".to_string(), + _ => event, + } +} diff --git a/rust/batch-import-worker/src/parse/content/mod.rs b/rust/batch-import-worker/src/parse/content/mod.rs new file mode 100644 index 0000000000000..bf17d78f9921a --- /dev/null +++ b/rust/batch-import-worker/src/parse/content/mod.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +pub mod captured; +pub mod mixpanel; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ContentType { + Mixpanel, // From a mixpanel export + Captured, // Each json object structured as if it was going to be sent to the capture endpoint +} + +// All /extra/ information needed to go from any input format to an InternallyCapturedEvent, +// e.g. team_id +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransformContext { + pub team_id: i32, + pub token: String, +} diff --git a/rust/batch-import-worker/src/parse/format.rs b/rust/batch-import-worker/src/parse/format.rs new file mode 100644 index 0000000000000..088dd1a7e185d --- /dev/null +++ b/rust/batch-import-worker/src/parse/format.rs @@ -0,0 +1,253 @@ +use std::sync::Arc; + +use anyhow::Error; +use common_types::{InternallyCapturedEvent, RawEvent}; +use rayon::iter::IntoParallelIterator; +use rayon::prelude::*; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +use crate::{context::AppContext, job::model::JobModel}; + +use super::{ + content::{ + captured::captured_parse_fn, mixpanel::MixpanelEvent, ContentType, TransformContext, + }, + Parsed, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum FormatConfig { + JsonLines { + skip_blanks: bool, + content: ContentType, + }, +} + +pub type ParserFn = + Box) -> Result>, Error> + Send + Sync>; + +impl FormatConfig { + pub async fn get_parser( + &self, + model: &JobModel, + context: Arc, + ) -> Result { + // Only support json-lines for now + let Self::JsonLines { + skip_blanks, + content, + } = self; + + let transform_context = TransformContext { + team_id: model.team_id, + token: context.get_token_for_team_id(model.team_id).await?, + }; + + match content { + ContentType::Mixpanel => { + let format_parse = json_nd(*skip_blanks); + let event_transform = MixpanelEvent::parse_fn(transform_context); + let parser = move |data| { + let parsed: Parsed> = format_parse(data)?; + let consumed = parsed.consumed; + let result: Result<_, Error> = + parsed.data.into_par_iter().map(&event_transform).collect(); + Ok(Parsed { + data: result?, + consumed, + }) + }; + + Ok(Box::new(parser)) + } + ContentType::Captured => { + let format_parse = json_nd(*skip_blanks); + let event_transform = captured_parse_fn(transform_context); + let parser = move |data| { + let parsed: Parsed> = format_parse(data)?; + let consumed = parsed.consumed; + let result: Result<_, Error> = + parsed.data.into_par_iter().map(&event_transform).collect(); + Ok(Parsed { + data: result?, + consumed, + }) + }; + + Ok(Box::new(parser)) + } + } + } +} + +const NEWLINE_DELIM: u8 = b'\n'; + +pub const fn newline_delim( + skip_blank_lines: bool, + inner: impl Fn(&str) -> Result + Sync, +) -> impl Fn(Vec) -> Result>, Error> { + move |data: Vec| { + let mut cursor = 0; + let mut last_consumed_byte = 0; + + let mut lines = Vec::new(); + + // TODO - I'm reasonably sure this is actually invalid in the face of utf-8 encoding... we should immediately parse the + // data as utf-8, and then consume character-by-character, marking how many bytes we consume as we go. I could redesign + // this to do that. + while cursor < data.len() { + // The cursor != 0 bit here is because the "this might be the end of the file" handling below this can sometimes + // cause the next chunk to start exactly on a newline. This does run the risk of accidentally skipping a blank line, + // but we generally don't consider newlines important anyway (skip_blank_lines is generally only set to false to ensure + // the presence of one in the input will cause the inner function to return an error, not because they're semantically + // relevant) + if data[cursor] == NEWLINE_DELIM && cursor != 0 { + let line = std::str::from_utf8(&data[last_consumed_byte..cursor])?; + if !skip_blank_lines || !line.trim().is_empty() { + lines.push((cursor, line.trim())); + } + last_consumed_byte = cursor; + } + + cursor += 1; + } + + let remainder = std::str::from_utf8(&data[last_consumed_byte..])?; + + let mut output = Vec::with_capacity(lines.len()); + let intermediate: Vec<_> = lines + .into_par_iter() + .map(|(end_byte_idx, line)| (end_byte_idx, inner(line))) + .collect(); + + let mut last_validly_consumed_byte = 0; + for (byte_idx, res) in intermediate.into_iter() { + match res { + Ok(parsed) => { + output.push(parsed); + last_validly_consumed_byte = byte_idx; + } + Err(e) => { + return Err(e.context(format!( + "Starting at byte {} of current chunk", + last_validly_consumed_byte + ))); + } + } + } + + let remainder = inner(remainder); + + // If we managed to parse the last line, add it too, but if we didn't, assume it's due to this chunk being partway through the file, + // and carry on. + if let Ok(parsed) = remainder { + output.push(parsed); + // -1 because at this point the cursor is pointing at the end of the data, + // and we want to point at the last byte we actually consumed + last_validly_consumed_byte = cursor - 1; + } + + let parsed = Parsed { + data: output, + consumed: last_validly_consumed_byte + 1, + }; + + Ok(parsed) + } +} + +pub const fn json_nd(skip_blank_lines: bool) -> impl Fn(Vec) -> Result>, Error> +where + T: DeserializeOwned + Send, +{ + newline_delim(skip_blank_lines, |line| { + let parsed = serde_json::from_str(line)?; + Ok(parsed) + }) +} + +#[cfg(test)] +mod tests { + use crate::source::{folder::FolderSource, DataSource}; + + use super::*; + use serde::Deserialize; + use std::fs; + use tempfile::TempDir; + + #[derive(Deserialize, Debug, PartialEq)] + struct TestData { + id: i32, + name: String, + } + + async fn setup_test_files() -> (TempDir, FolderSource) { + let temp_dir = TempDir::new().unwrap(); + fs::write( + temp_dir.path().join("data.jsonl"), + r#"{"id": 1, "name": "test1"} +{"id": 2, "name": "test2"} +{"id": 3, "name": "test3"}"#, + ) + .unwrap(); + + fs::write( + temp_dir.path().join("blank_lines.jsonl"), + r#"{"id": 1, "name": "test1"} + +{"id": 2, "name": "test2"} +"#, + ) + .unwrap(); + + let source = FolderSource::new(temp_dir.path().to_str().unwrap().to_string()) + .await + .unwrap(); + + (temp_dir, source) + } + + #[tokio::test] + async fn test_json_nd_parsing() { + let (_temp_dir, source) = setup_test_files().await; + let chunk = source.get_chunk("data.jsonl", 0, 100).await.unwrap(); + let chunk_len = chunk.len(); + let parsed = json_nd::(false)(chunk).unwrap(); + + assert_eq!(parsed.data.len(), 3); + assert_eq!( + parsed.data[0], + TestData { + id: 1, + name: "test1".to_string() + } + ); + assert_eq!(parsed.consumed, chunk_len); + } + + #[tokio::test] + async fn test_json_nd_with_blank_lines() { + let (_temp_dir, source) = setup_test_files().await; + let data = source.get_chunk("blank_lines.jsonl", 0, 100).await.unwrap(); + + let parsed_with_blanks = json_nd::(true)(data.clone()).unwrap(); + assert_eq!(parsed_with_blanks.data.len(), 2); + + // IF we're not skipping blank lines, an empty line will cause json parsing + // to fail, and we should get an error + let should_be_error = json_nd::(false)(data); + assert!(should_be_error.is_err()); + } + + #[tokio::test] + async fn test_partial_line() { + let (_temp_dir, source) = setup_test_files().await; + let data = source.get_chunk("data.jsonl", 0, 30).await.unwrap(); + let parsed = json_nd::(false)(data).unwrap(); + + assert_eq!(parsed.data.len(), 1); + // 26 "data" characters, plus the newline + assert_eq!(parsed.consumed, 27); + } +} diff --git a/rust/batch-import-worker/src/parse/mod.rs b/rust/batch-import-worker/src/parse/mod.rs new file mode 100644 index 0000000000000..a21907d084bef --- /dev/null +++ b/rust/batch-import-worker/src/parse/mod.rs @@ -0,0 +1,9 @@ +pub mod content; +pub mod format; + +pub struct Parsed { + pub data: T, + // How many "parts" of the chunk (bytes, rows) were consumed to create the data. This allows for offset + // storing etc in an input-format-aware-manner + pub consumed: usize, +} diff --git a/rust/batch-import-worker/src/source/folder.rs b/rust/batch-import-worker/src/source/folder.rs new file mode 100644 index 0000000000000..472c04f5f09e7 --- /dev/null +++ b/rust/batch-import-worker/src/source/folder.rs @@ -0,0 +1,116 @@ +use std::path::PathBuf; + +use anyhow::Error; +use async_trait::async_trait; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use super::DataSource; + +pub struct FolderSource { + pub path: PathBuf, +} + +impl FolderSource { + pub async fn new(path: String) -> Result { + let path = tokio::fs::canonicalize(path).await?; + Ok(Self { path }) + } + + pub async fn assert_valid_path(&self, key: &str) -> Result<(), Error> { + if !self.keys().await?.into_iter().any(|k| k == key) { + return Err(Error::msg(format!("Key not found: {}", key))); + } + Ok(()) + } +} + +#[async_trait] +impl DataSource for FolderSource { + async fn keys(&self) -> Result, Error> { + let mut keys = vec![]; + let mut entries = tokio::fs::read_dir(&self.path).await?; + while let Some(entry) = entries.next_entry().await? { + keys.push(entry.file_name().to_string_lossy().to_string()); + } + Ok(keys) + } + + async fn size(&self, key: &str) -> Result { + self.assert_valid_path(key).await?; + let path = self.path.join(key); + let metadata = tokio::fs::metadata(path).await?; + Ok(metadata.len() as usize) + } + + async fn get_chunk(&self, key: &str, offset: usize, size: usize) -> Result, Error> { + self.assert_valid_path(key).await?; + let path = self.path.join(key); + let mut file = tokio::fs::File::open(path).await?; + file.seek(std::io::SeekFrom::Start(offset as u64)).await?; + // Read until either we reach `size` bytes or the end of the file + let mut res = Vec::with_capacity(size); + let mut buffer = vec![0; 1024]; + while res.len() < size { + let bytes_read = file.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + res.extend_from_slice(&buffer[..bytes_read]); + } + + // The last call to read might have read more than `size` bytes, so truncate the result + if res.len() > size { + res.truncate(size); + } + Ok(res) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + async fn setup_test_folder() -> (TempDir, FolderSource) { + let temp_dir = TempDir::new().unwrap(); + fs::write(temp_dir.path().join("test1.txt"), b"hello world").unwrap(); + fs::write(temp_dir.path().join("test2.txt"), b"another file").unwrap(); + + let source = FolderSource::new(temp_dir.path().to_str().unwrap().to_string()) + .await + .unwrap(); + + (temp_dir, source) + } + + #[tokio::test] + async fn test_keys() { + let (_temp_dir, source) = setup_test_folder().await; + let keys = source.keys().await.unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.contains(&"test1.txt".to_string())); + assert!(keys.contains(&"test2.txt".to_string())); + } + + #[tokio::test] + async fn test_size() { + let (_temp_dir, source) = setup_test_folder().await; + let size = source.size("test1.txt").await.unwrap(); + assert_eq!(size, 11); + } + + #[tokio::test] + async fn test_get_chunk() { + let (_temp_dir, source) = setup_test_folder().await; + let chunk = source.get_chunk("test1.txt", 0, 5).await.unwrap(); + assert_eq!(chunk, b"hello"); + } + + #[tokio::test] + async fn test_invalid_path() { + let (_temp_dir, source) = setup_test_folder().await; + let result = source.assert_valid_path("nonexistent.txt").await; + assert!(result.is_err()); + } +} diff --git a/rust/batch-import-worker/src/source/mod.rs b/rust/batch-import-worker/src/source/mod.rs new file mode 100644 index 0000000000000..a3b7f4c7c9010 --- /dev/null +++ b/rust/batch-import-worker/src/source/mod.rs @@ -0,0 +1,12 @@ +use anyhow::Error; +use async_trait::async_trait; + +pub mod folder; +pub mod url_list; + +#[async_trait] +pub trait DataSource: Sync + Send { + async fn keys(&self) -> Result, Error>; + async fn size(&self, key: &str) -> Result; + async fn get_chunk(&self, key: &str, offset: usize, size: usize) -> Result, Error>; +} diff --git a/rust/batch-import-worker/src/source/url_list.rs b/rust/batch-import-worker/src/source/url_list.rs new file mode 100644 index 0000000000000..37a6f8c66b8e1 --- /dev/null +++ b/rust/batch-import-worker/src/source/url_list.rs @@ -0,0 +1,273 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::Error; +use async_trait::async_trait; +use reqwest::Client; + +use super::DataSource; + +pub struct UrlList { + pub urls: Vec, + pub client: Client, +} + +impl UrlList { + pub async fn new( + urls: Vec, + allow_internal_ips: bool, + timeout: Duration, + ) -> Result { + let resolver = Arc::new(common_dns::PublicIPv4Resolver {}); + + let mut client = reqwest::Client::builder().timeout(timeout); + + if !allow_internal_ips { + client = client.dns_resolver(resolver); + } + + let client = client.build()?; + + let source = Self { urls, client }; + + // Validate the passed urls, and assert they all support range requests + for url in &source.urls { + source.assert_valid_url(url).await?; + } + + Ok(source) + } + + // A url is valid to us if it supports range requests and returns a content-length header + async fn assert_valid_url(&self, url: &str) -> Result<(), Error> { + let response = self + .client + .head(url) + .send() + .await? + .error_for_status() + .map_err(|e| Error::msg(format!("Failed to get headers for {}: {}", url, e)))?; + + let accept_ranges = response + .headers() + .get("accept-ranges") + .ok_or(Error::msg("Missing Accept-Ranges header"))? + .to_str() + .map_err(|e| Error::msg(format!("Failed to parse Accept-Ranges header: {}", e)))?; + + if accept_ranges != "bytes" { + return Err(Error::msg(format!( + "Server does not support range requests for {}", + url + ))); + } + + let content_lenth = response + .headers() + .get("content-length") + .ok_or(Error::msg("Missing Content-Length header"))? + .to_str() + .map_err(|e| Error::msg(format!("Failed to parse Content-Length header: {}", e)))?; + + content_lenth + .parse::() + .map_err(|e| Error::msg(format!("Failed to parse Content-Length as usize: {}", e)))?; + + Ok(()) + } +} + +#[async_trait] +impl DataSource for UrlList { + async fn keys(&self) -> Result, Error> { + Ok(self.urls.clone()) + } + + async fn size(&self, key: &str) -> Result { + // Ensure the passed key is in our list of URLs + if !self.urls.contains(&key.to_string()) { + return Err(Error::msg("Key not found")); + } + + // For some reason calling `content_length()` doesn't work properly here, so we don't do that + self.client + .head(key) + .send() + .await? + .headers() + .get("content-length") + .ok_or(Error::msg(format!( + "Could not get content length for {}", + key + ))) + .and_then(|header| { + header + .to_str() + .map_err(|e| Error::msg(format!("Failed to parse content length: {}", e))) + }) + .and_then(|length| { + length.parse::().map_err(|e| { + Error::msg(format!("Failed to parse content length as usize: {}", e)) + }) + }) + } + + async fn get_chunk(&self, key: &str, offset: usize, size: usize) -> Result, Error> { + // Ensure the passed key is in our list of URLs + if !self.urls.contains(&key.to_string()) { + return Err(Error::msg("Key not found")); + } + + let response = self + .client + .get(key) + .header("Range", format!("bytes={}-{}", offset, offset + size - 1)) + .send() + .await? + .error_for_status(); + + match response { + Ok(response) => Ok(response.bytes().await.map(|bytes| bytes.to_vec())?), + Err(e) => Err(e.into()), + } + } +} + +#[cfg(test)] +mod test { + + use std::time::Duration; + + use httpmock::MockServer; + + use crate::source::{url_list::UrlList, DataSource}; + + const TEST_CONTENTS: &str = include_str!("../../tests/capture_request_dump.jsonl"); + + #[tokio::test] + async fn test_url_list_creation() { + let server = MockServer::start(); + let head = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("accept-ranges", "bytes") + .header("content-length", TEST_CONTENTS.len().to_string()); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let url_count = urls.len(); + let source = UrlList::new(urls, true, Duration::from_secs(10)) + .await + .unwrap(); + let keys = source.keys().await.unwrap(); + println!("{:?}", keys); + + assert_eq!(head.hits(), url_count); + } + + #[tokio::test] + async fn test_url_list_no_accept_ranges() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("accept-ranges", "none") + .header("content-length", TEST_CONTENTS.len().to_string()); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source_res = UrlList::new(urls, true, Duration::from_secs(10)).await; + + assert!(source_res.is_err()); + } + + #[tokio::test] + async fn test_url_list_missing_accept_ranges() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("content-length", TEST_CONTENTS.len().to_string()); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source_res = UrlList::new(urls, true, Duration::from_secs(10)).await; + + assert!(source_res.is_err()); + } + + #[tokio::test] + async fn test_url_list_missing_content_length() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200).header("accept-ranges", "bytes"); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source_res = UrlList::new(urls, true, Duration::from_secs(10)).await; + + assert!(source_res.is_err()); + } + + #[tokio::test] + async fn test_non_usize_content_length() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("accept-ranges", "bytes") + .header("content-length", "not a number"); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source_res = UrlList::new(urls, true, Duration::from_secs(10)).await; + + assert!(source_res.is_err()); + } + + #[tokio::test] + async fn test_size() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("accept-ranges", "bytes") + .header("content-length", TEST_CONTENTS.len().to_string()); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source = UrlList::new(urls.clone(), true, Duration::from_secs(10)) + .await + .unwrap(); + let size = source.size(&urls[0]).await.unwrap(); + + assert_eq!(size, TEST_CONTENTS.len()); + } + + #[tokio::test] + async fn test_get_first_100_bytes() { + let server = MockServer::start(); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::HEAD); + then.status(200) + .header("accept-ranges", "bytes") + .header("content-length", TEST_CONTENTS.len().to_string()); + }); + let _ = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200) + .header("accept-ranges", "bytes") + .header("content-length", 100.to_string()) + .body(&TEST_CONTENTS[0..100]); + }); + + let urls: Vec<_> = ["/1", "/2"].iter().map(|&path| server.url(path)).collect(); + let source = UrlList::new(urls.clone(), true, Duration::from_secs(10)) + .await + .unwrap(); + let chunk = source.get_chunk(&urls[0], 0, 100).await.unwrap(); + + assert_eq!(chunk.len(), 100); + assert_eq!(&chunk, &TEST_CONTENTS.as_bytes()[0..100]); + } +} diff --git a/rust/batch-import-worker/tests/capture_request_dump.jsonl b/rust/batch-import-worker/tests/capture_request_dump.jsonl new file mode 100644 index 0000000000000..4c4a0b3fdd3c1 --- /dev/null +++ b/rust/batch-import-worker/tests/capture_request_dump.jsonl @@ -0,0 +1,8 @@ +{"path":"/e/?ip=1&_=1694769302325&ver=1.78.5","method":"POST","content_encoding":"","content_type":"application/x-www-form-urlencoded","ip":"127.0.0.1","now":"2023-09-15T09:15:02.328551+00:00","body":"ZGF0YT1leUoxZFdsa0lqb2lNREU0WVRrNE1XWXROR0l6TlMwM1l6WTJMVGd5TldVdE9HSXdaV1ZoWlRZMU56RTBJaXdpWlhabGJuUWlPaUlrYVdSbGJuUnBabmtpTENKd2NtOXdaWEowYVdWeklqcDdJaVJ2Y3lJNklrMWhZeUJQVXlCWUlpd2lKRzl6WDNabGNuTnBiMjRpT2lJeE1DNHhOUzR3SWl3aUpHSnliM2R6WlhJaU9pSkdhWEpsWm05NElpd2lKR1JsZG1salpWOTBlWEJsSWpvaVJHVnphM1J2Y0NJc0lpUmpkWEp5Wlc1MFgzVnliQ0k2SW1oMGRIQTZMeTlzYjJOaGJHaHZjM1E2T0RBd01DOGlMQ0lrYUc5emRDSTZJbXh2WTJGc2FHOXpkRG80TURBd0lpd2lKSEJoZEdodVlXMWxJam9pTHlJc0lpUmljbTkzYzJWeVgzWmxjbk5wYjI0aU9qRXhOeXdpSkdKeWIzZHpaWEpmYkdGdVozVmhaMlVpT2lKbGJpMVZVeUlzSWlSelkzSmxaVzVmYUdWcFoyaDBJam94TURVeUxDSWtjMk55WldWdVgzZHBaSFJvSWpveE5qSXdMQ0lrZG1sbGQzQnZjblJmYUdWcFoyaDBJam81TVRFc0lpUjJhV1YzY0c5eWRGOTNhV1IwYUNJNk1UVTBPQ3dpSkd4cFlpSTZJbmRsWWlJc0lpUnNhV0pmZG1WeWMybHZiaUk2SWpFdU56Z3VOU0lzSWlScGJuTmxjblJmYVdRaU9pSTBNSEIwTVhWamNHczNORFpwYkdWd0lpd2lKSFJwYldVaU9qRTJPVFEzTmprek1ESXVNekkxTENKa2FYTjBhVzVqZEY5cFpDSTZJbkJYUVd0SlZIbFJNME5PTnpNek1sVnhVWGh1U0Rad00wWldPRlpLWkRkd1dUWTBOMFZrVG10NFYyTWlMQ0lrWkdWMmFXTmxYMmxrSWpvaU1ERTRZVGs0TVdZdE5HSXlaQzAzT0dGbExUazBOMkl0WW1Wa1ltRmhNREpoTUdZMElpd2lKSFZ6WlhKZmFXUWlPaUp3VjBGclNWUjVVVE5EVGpjek16SlZjVkY0YmtnMmNETkdWamhXU21RM2NGazJORGRGWkU1cmVGZGpJaXdpSkhKbFptVnljbVZ5SWpvaUpHUnBjbVZqZENJc0lpUnlaV1psY25KcGJtZGZaRzl0WVdsdUlqb2lKR1JwY21WamRDSXNJaVJoYm05dVgyUnBjM1JwYm1OMFgybGtJam9pTURFNFlUazRNV1l0TkdJeVpDMDNPR0ZsTFRrME4ySXRZbVZrWW1GaE1ESmhNR1kwSWl3aWRHOXJaVzRpT2lKd2FHTmZjV2RWUm5BMWQzb3lRbXBETkZKelJtcE5SM0JSTTFCSFJISnphVFpSTUVOSE1FTlFORTVCWVdNd1NTSXNJaVJ6WlhOemFXOXVYMmxrSWpvaU1ERTRZVGs0TVdZdE5HSXlaUzAzWW1RekxXSmlNekF0TmpZeE4ySm1ORGc0T0RZNUlpd2lKSGRwYm1SdmQxOXBaQ0k2SWpBeE9HRTVPREZtTFRSaU1tVXROMkprTXkxaVlqTXdMVFkyTVRneE1HWmxaRFkxWmlKOUxDSWtjMlYwSWpwN2ZTd2lKSE5sZEY5dmJtTmxJanA3ZlN3aWRHbHRaWE4wWVcxd0lqb2lNakF5TXkwd09TMHhOVlF3T1RveE5Ub3dNaTR6TWpWYUluMCUzRA==","output":[{"uuid":"018a981f-4b35-7c66-825e-8b0eeae65714","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b35-7c66-825e-8b0eeae65714\", \"event\": \"$identify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"40pt1ucpk746ilep\", \"$time\": 1694769302.325, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$anon_distinct_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"$set\": {}, \"$set_once\": {}, \"timestamp\": \"2023-09-15T09:15:02.325Z\"}","now":"2023-09-15T09:15:02.328551+00:00","sent_at":"2023-09-15T09:15:02.325000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?ip=1&_=1694769302319&ver=1.78.5","method":"POST","content_encoding":"","content_type":"application/x-www-form-urlencoded","ip":"127.0.0.1","now":"2023-09-15T09:15:02.322717+00:00","body":"ZGF0YT1leUoxZFdsa0lqb2lNREU0WVRrNE1XWXROR0l5WmkwM09URmhMVGxsWWpFdE5qZGhaVFZpT1dWalpqZzBJaXdpWlhabGJuUWlPaUlrY0dGblpYWnBaWGNpTENKd2NtOXdaWEowYVdWeklqcDdJaVJ2Y3lJNklrMWhZeUJQVXlCWUlpd2lKRzl6WDNabGNuTnBiMjRpT2lJeE1DNHhOUzR3SWl3aUpHSnliM2R6WlhJaU9pSkdhWEpsWm05NElpd2lKR1JsZG1salpWOTBlWEJsSWpvaVJHVnphM1J2Y0NJc0lpUmpkWEp5Wlc1MFgzVnliQ0k2SW1oMGRIQTZMeTlzYjJOaGJHaHZjM1E2T0RBd01DOGlMQ0lrYUc5emRDSTZJbXh2WTJGc2FHOXpkRG80TURBd0lpd2lKSEJoZEdodVlXMWxJam9pTHlJc0lpUmljbTkzYzJWeVgzWmxjbk5wYjI0aU9qRXhOeXdpSkdKeWIzZHpaWEpmYkdGdVozVmhaMlVpT2lKbGJpMVZVeUlzSWlSelkzSmxaVzVmYUdWcFoyaDBJam94TURVeUxDSWtjMk55WldWdVgzZHBaSFJvSWpveE5qSXdMQ0lrZG1sbGQzQnZjblJmYUdWcFoyaDBJam81TVRFc0lpUjJhV1YzY0c5eWRGOTNhV1IwYUNJNk1UVTBPQ3dpSkd4cFlpSTZJbmRsWWlJc0lpUnNhV0pmZG1WeWMybHZiaUk2SWpFdU56Z3VOU0lzSWlScGJuTmxjblJmYVdRaU9pSnpjSEozT0RVM2JHVnVOM0ZxTXpSMklpd2lKSFJwYldVaU9qRTJPVFEzTmprek1ESXVNekU1TENKa2FYTjBhVzVqZEY5cFpDSTZJakF4T0dFNU9ERm1MVFJpTW1RdE56aGhaUzA1TkRkaUxXSmxaR0poWVRBeVlUQm1OQ0lzSWlSa1pYWnBZMlZmYVdRaU9pSXdNVGhoT1RneFppMDBZakprTFRjNFlXVXRPVFEzWWkxaVpXUmlZV0V3TW1Fd1pqUWlMQ0lrY21WbVpYSnlaWElpT2lJa1pHbHlaV04wSWl3aUpISmxabVZ5Y21sdVoxOWtiMjFoYVc0aU9pSWtaR2x5WldOMElpd2lkR2wwYkdVaU9pSlFiM04wU0c5bklpd2lkRzlyWlc0aU9pSndhR05mY1dkVlJuQTFkM295UW1wRE5GSnpSbXBOUjNCUk0xQkhSSEp6YVRaUk1FTkhNRU5RTkU1QllXTXdTU0lzSWlSelpYTnphVzl1WDJsa0lqb2lNREU0WVRrNE1XWXROR0l5WlMwM1ltUXpMV0ppTXpBdE5qWXhOMkptTkRnNE9EWTVJaXdpSkhkcGJtUnZkMTlwWkNJNklqQXhPR0U1T0RGbUxUUmlNbVV0TjJKa015MWlZak13TFRZMk1UZ3hNR1psWkRZMVppSjlMQ0owYVcxbGMzUmhiWEFpT2lJeU1ESXpMVEE1TFRFMVZEQTVPakUxT2pBeUxqTXhPVm9pZlElM0QlM0Q=","output":[{"uuid":"018a981f-4b2f-791a-9eb1-67ae5b9ecf84","distinct_id":"018a981f-4b2d-78ae-947b-bedbaa02a0f4","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b2f-791a-9eb1-67ae5b9ecf84\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"sprw857len7qj34v\", \"$time\": 1694769302.319, \"distinct_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"title\": \"PostHog\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"timestamp\": \"2023-09-15T09:15:02.319Z\"}","now":"2023-09-15T09:15:02.322717+00:00","sent_at":"2023-09-15T09:15:02.319000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?ip=1&_=1694769302319&ver=1.78.5","method":"POST","content_encoding":"","content_type":"application/x-www-form-urlencoded","ip":"127.0.0.1","now":"2023-09-15T09:15:02.321230+00:00","body":"ZGF0YT1leUoxZFdsa0lqb2lNREU0WVRrNE1XWXROR0l5WlMwM1ltUXpMV0ppTXpBdE5qWXhOalkxTjJNek9HWmhJaXdpWlhabGJuUWlPaUlrYjNCMFgybHVJaXdpY0hKdmNHVnlkR2xsY3lJNmV5SWtiM01pT2lKTllXTWdUMU1nV0NJc0lpUnZjMTkyWlhKemFXOXVJam9pTVRBdU1UVXVNQ0lzSWlSaWNtOTNjMlZ5SWpvaVJtbHlaV1p2ZUNJc0lpUmtaWFpwWTJWZmRIbHdaU0k2SWtSbGMydDBiM0FpTENJa1kzVnljbVZ1ZEY5MWNtd2lPaUpvZEhSd09pOHZiRzlqWVd4b2IzTjBPamd3TURBdklpd2lKR2h2YzNRaU9pSnNiMk5oYkdodmMzUTZPREF3TUNJc0lpUndZWFJvYm1GdFpTSTZJaThpTENJa1luSnZkM05sY2w5MlpYSnphVzl1SWpveE1UY3NJaVJpY205M2MyVnlYMnhoYm1kMVlXZGxJam9pWlc0dFZWTWlMQ0lrYzJOeVpXVnVYMmhsYVdkb2RDSTZNVEExTWl3aUpITmpjbVZsYmw5M2FXUjBhQ0k2TVRZeU1Dd2lKSFpwWlhkd2IzSjBYMmhsYVdkb2RDSTZPVEV4TENJa2RtbGxkM0J2Y25SZmQybGtkR2dpT2pFMU5EZ3NJaVJzYVdJaU9pSjNaV0lpTENJa2JHbGlYM1psY25OcGIyNGlPaUl4TGpjNExqVWlMQ0lrYVc1elpYSjBYMmxrSWpvaU1XVTRaV1p5WkdSbE1HSTBNV2RtYkNJc0lpUjBhVzFsSWpveE5qazBOelk1TXpBeUxqTXhPQ3dpWkdsemRHbHVZM1JmYVdRaU9pSXdNVGhoT1RneFppMDBZakprTFRjNFlXVXRPVFEzWWkxaVpXUmlZV0V3TW1Fd1pqUWlMQ0lrWkdWMmFXTmxYMmxrSWpvaU1ERTRZVGs0TVdZdE5HSXlaQzAzT0dGbExUazBOMkl0WW1Wa1ltRmhNREpoTUdZMElpd2lKSEpsWm1WeWNtVnlJam9pSkdScGNtVmpkQ0lzSWlSeVpXWmxjbkpwYm1kZlpHOXRZV2x1SWpvaUpHUnBjbVZqZENJc0luUnZhMlZ1SWpvaWNHaGpYM0ZuVlVad05YZDZNa0pxUXpSU2MwWnFUVWR3VVROUVIwUnljMmsyVVRCRFJ6QkRVRFJPUVdGak1Fa2lMQ0lrYzJWemMybHZibDlwWkNJNklqQXhPR0U1T0RGbUxUUmlNbVV0TjJKa015MWlZak13TFRZMk1UZGlaalE0T0RnMk9TSXNJaVIzYVc1a2IzZGZhV1FpT2lJd01UaGhPVGd4WmkwMFlqSmxMVGRpWkRNdFltSXpNQzAyTmpFNE1UQm1aV1EyTldZaWZTd2lkR2x0WlhOMFlXMXdJam9pTWpBeU15MHdPUzB4TlZRd09Ub3hOVG93TWk0ek1UaGFJbjAlM0Q=","output":[{"uuid":"018a981f-4b2e-7bd3-bb30-6616657c38fa","distinct_id":"018a981f-4b2d-78ae-947b-bedbaa02a0f4","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b2e-7bd3-bb30-6616657c38fa\", \"event\": \"$opt_in\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"1e8efrdde0b41gfl\", \"$time\": 1694769302.318, \"distinct_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"timestamp\": \"2023-09-15T09:15:02.318Z\"}","now":"2023-09-15T09:15:02.321230+00:00","sent_at":"2023-09-15T09:15:02.319000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769305355&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:05.358129+00:00","body":"H4sIAAAAAAAAA+2bW2/bNhTHv0pg9LFMSVGUqDytS9fLgHbJ2rTbisKgyCObsSLJknwt+t13qDiO3dpt1g2d7ejJEG+HOvyfn0mKfP+xMxpZ0znpUCZVJFlC/JgLEuogINITQGRMkzgw1KeB6TzswBiyGos/qKDGx6LMCyhrC1Xn5GPnQY4/nZdKH/32+ugPzMaE7hjKyuYZZjB6zMQxdelxmU8qKDHxqS0hyacu0cDYaujWswIw4wlUgzovXIYelSWa7Y7KFDP6dV2cPHqU5lql/byqTySl9JEr556wwHqOyyhU3c/UlWu2Kbgwf9s3xsKV5FRlvZHqueKQkYvXrkqlS4Cs2wfb66MRRoV3mzqxpu5jYuBRTBxbmBR5WS8LR4ytJt+UFr7E5NTGaGcCsbOCD6sOOw7lsXDpNsN+1d1mpMZsMg/KNLyazKein4Uuv7bu5VgQ+WEQceodc0887Bhb1TbTi3rFu8eDF29m5/z0Vci5dzE8n2bPg4I/fSvf/mrC4s/AD38xrwbTd3plND4Xh2dIKBUQtBSTGEysFPUUTXxXZ+S89z3GUAOAY+wE8cCgIrQT1yLVZr2uya+UdS5ZyXUKRNEB5jhZTBU6uPypwHHv571jnV91Pl2X6uaZRu98xMc6H4Brpujr7rB38bQQk7n38+Wp/3v19PLls+Kcnz17gu4PzunpM3p65r96rDR9cW2ucsPypUOAhLHhJI45JUHAwjjxpZRB5CpNbGbyyTfrSEYTMIFIXJfzJGleDYcx+PRwY4AGJAwjSZSvgSRcxxRQjonhqwHaK/NRYQ0+2WTWhur/EqrzXmX6UVoPKBUssb0toRrsUajaqmvgKu+inC5dIJ4kKq0AG2z01mhrmXXTGRSpjD2CI0yJEWBIxCUFEEoZP26i9HvivzF4I8Ebm8v0Aczu2oFlnQVRnFdQBetx960mFpp9AokapfXR2bI/2H2oajDdRWQu/LXw4/LZyWKeZ66NizenTb2qVgiubq16mJi5vIMlmPY9IzhVTmEtwXaIYH0GLI3nMzHspUWEIdgSbFP052VPZXau6mtf3tYSQl/XUlIY4gnNBAghjDD/DffWDG+F31d6sQl+d625ULkLviodOUq50EKVKsc75fznUY8TGhEm3tDohPknPj3mQgSM/YVF1RjnbipOoZtgnVHpIvv9hwNnn5/g8ioxqGcvJiGTgmsOKuCwyr4CieBieKewd/1XtlDanfi3VmOvQWhZPyzLAa6Jk8DMi8aZX4JQoMkWhD8GhDtPAzepq1On5zOMj+d5b50PlG3mg+AEBy8gSquYCAPGSENjzdkqHxa87Cap6nUxAlNwQG5RsQuo8LQSGaRT4EWmxWjLBo3Ypw2aXUKFGtUopKKRPzrQTR8Mzl5KHJZuhcuF284pXdvxcm7RxMpigrEeP4Wapbky7jW+d1K22p7z7vUuFMEob4S+Zg6nOUWOerld++06yVa45UXRlnmNwG1jRjVBBQIxOMrKeFQatrZt3HJrd7nlX05HqR+MBtmYpzattnALX6jl1oFyCzLXLYI+uSrqRgAHRS6xhVwhVgefRCZQxAeqIw06AuFMtuTaA3LNSlxHc8nKS86VhiZ/A7millyHSy5VpjNcM2nEClnkHRy/+N34ZVhiQknDtR2lll+7y69hXQ2CcTaJtT+Xk+mg5de941c1wu7MDm/K5W1BlnRnjHyiRCwIU9SEPAAd83axuCfImqZggZfWCiamVX/LYrH9Lni4xDKqVmSiSujn6PL7Ci7gPOTATLs7vyfgupqPC0vzYT4Qlanslt35FlyHC64EnOP1gFQa8GDBfQFXhOCKYxJBiPpTeKbb8xL8CBm04NoPcA1tzEwUTwHm6WSa9reAqz2BcLjkyvIa4jwfHN4ycctZiACrc8BzojKQhBrfU9LTHmNOdS209gBaJdOsEkLW03qY4T/OZmhJ7F4LrQOFVp7FuSoNliZjjzSH3g+MXiHdTC/p9uVlQiQNGMEPispPDNWeWf2uuAz1Iwxk56SjCoO05deO8Gs4HA1nw3CWign3yqHcyC+fYtstvw6EX/vFHt/fzB6Fyz0Pz95HgdTEeJGMEo/jn7Hajxs295k5mZGXytqJikyPeoU7SLyBOX47Z/rnzLm9WrJNeT8ES3hDJr1yPUCr+GI6tXpws4ve3GdujLnRWd6QWZqsUty6+kr+v79TtHTRZ/eJNvtrWWhxgaiyNXwttPftIo8XMfrpw986qs3ZKUEAAA==","output":[{"uuid":"018a981f-4b35-7c66-825e-8b0fb6d0406d","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b35-7c66-825e-8b0fb6d0406d\", \"event\": \"$set\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"v1wz6rl7mwzx5hn7\", \"$time\": 1694769302.325, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$set\": {\"email\": \"xavier@posthog.com\"}, \"$set_once\": {}, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 3026}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b36-7798-a4ce-f3cb0e052fd3","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b36-7798-a4ce-f3cb0e052fd3\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"zgsdh9ltk0051fig\", \"$time\": 1694769302.326, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\"}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$group_type\": \"project\", \"$group_key\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"$group_set\": {\"id\": 1, \"uuid\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"name\": \"Default Project\", \"ingested_event\": false, \"is_demo\": false, \"timezone\": \"UTC\", \"instance_tag\": \"none\"}, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 3026}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b36-7798-a4ce-f3cc42d530ac","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b36-7798-a4ce-f3cc42d530ac\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/\", \"$host\": \"localhost:8000\", \"$pathname\": \"/\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"h1e1lbzy5qglp9in\", \"$time\": 1694769302.326, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$group_type\": \"organization\", \"$group_key\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"$group_set\": {\"id\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"name\": \"X\", \"slug\": \"x\", \"created_at\": \"2023-09-15T09:14:40.355611Z\", \"available_features\": [], \"instance_tag\": \"none\"}, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 3026}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b4f-7cfd-be2b-71853c3ea63e","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b4f-7cfd-be2b-71853c3ea63e\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"i1h7rrk825f6dzpx\", \"$time\": 1694769302.351, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"PostHog\"}, \"offset\": 3001}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b53-7336-acab-5dedd8d0bc31","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b53-7336-acab-5dedd8d0bc31\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"2ca5nelxe3pnc5u7\", \"$time\": 1694769302.355, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"posthog-3000\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2996}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b55-710c-b2de-daadad208d1d","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b55-710c-b2de-daadad208d1d\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"4jxul46uknv3lils\", \"$time\": 1694769302.357, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"enable-prompts\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2995}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b57-7be4-9d6a-4e0c9cec9e59","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b57-7be4-9d6a-4e0c9cec9e59\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"yr718381rj33ace5\", \"$time\": 1694769302.359, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"early-access-feature\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2993}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b57-7be4-9d6a-4e0d1fd7807e","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b57-7be4-9d6a-4e0d1fd7807e\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"qtsk6vnwbc4z8wxk\", \"$time\": 1694769302.359, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"surveys\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2992}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b58-7c64-a5b5-1a0d736ecb3d","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b58-7c64-a5b5-1a0d736ecb3d\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"xleie3rii515xshs\", \"$time\": 1694769302.36, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"data-warehouse\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2992}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b58-7c64-a5b5-1a0e3373e1d1","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b58-7c64-a5b5-1a0e3373e1d1\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"mzvpi0oqok5sdsi7\", \"$time\": 1694769302.36, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"feedback-scene\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2992}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b59-7cbb-9e7e-9ab6d22f0016","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b59-7cbb-9e7e-9ab6d22f0016\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"qib1d9bxeezlwxlh\", \"$time\": 1694769302.361, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"notebooks\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2991}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b6e-73e8-a868-0d42a82c2114","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b6e-73e8-a868-0d42a82c2114\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"r1c1s558txtqnd22\", \"$time\": 1694769302.382, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"onboarding-v2-demo\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2970}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4b87-7b8f-8061-c9ea4fd0c2d9","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4b87-7b8f-8061-c9ea4fd0c2d9\", \"event\": \"ingestion landing seen\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"qquqyq7yl5w32rq8\", \"$time\": 1694769302.408, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2944}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-4ba9-7223-968c-d2989f231c1a","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-4ba9-7223-968c-d2989f231c1a\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"nd8jaiiwa9dg02pf\", \"$time\": 1694769302.442, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$group_type\": \"instance\", \"$group_key\": \"http://localhost:8000\", \"$group_set\": {\"site_url\": \"http://localhost:8000\"}, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2910}","now":"2023-09-15T09:15:05.358129+00:00","sent_at":"2023-09-15T09:15:05.355000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769308363&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:08.365426+00:00","body":"H4sIAAAAAAAAA+0a2W7bRvBXCMGPWpk3KffJdmPHQePYcY4WQUAsuUtxLYpLLVeHEwTot/TT+iWdpQ6L1OH4AGq3fJI0B+cezgz05XtrNGKkddDSDR93fSNGTkhM5FkORqFnh8h1YsfyiedYLm61W3RMMwnke3gkeYRzORIUwLngORWS0aJ18L21x+Gj9RZH2rsr7XdAAyAYU1EwngHC0DuG09EVPBR8UlABwBMmaMynCkjomEU0kDc5BcSvtOhLnitENBICxAcjkQIikTI/2N9PQY004YU88HVd32dZjxZSCQIGBQbKKolC5FgmGR6o51c55grdamsY3go4xVlvhHuKj2bo45ViKSJBaRYklPUSkGbojnkLnTAiEwC6pg7AMaOTnAu5JO4axip4Qe3YPoBTFoKcCQ2VFPix6sKO53ccBWcZ6CWDMoaUSpfwadwLmesYuorXnmTKSsPt2p7btXS348CjCQODs2jOln8+7J99uLm0js89yzI/Di+n2Ws3t04++Z/eEC//w7W9V+S8P/0crYSnmjV2aBLk+ZgiEBSikJIQY93EemwrnpFy3gOEsSIgdMADyK9rGoHDYpwWFB7YE3yUl8m2RC2UoSj2QxNBpHVEHEpQ1/J1Sh2Mia1cyUUPZ+wbLkO+yuU40YwL+w5BphMZDnUchzhEaZIVEmeRivzGxGv9AK1WiiIAH+MwpSQA0yFwQcEIMC/0x5FkYxrEFJfEcYp7YM2Xr4BahQU5vkk5JspSECAoTgdKA5AKhkUpi/oJB+eqyhxglpbCVHTwGH4p+UuRRYqj/g78HtQfhfpSxbhHoBrBp0so1EhAOEhQHlvBlu1gUamlOmWd0pX6UVQpHQCdMvB7S4JV89JTRR2luChU2/jSinkmUcbFAKeAkHQq0bRogUuwlCIISkrgWiHTFkTtViaTIEpYCjkG5ad+8XiumCpHmgaKFtgPF5ZrMRfaG3DEVSRYLtvaYUYEZ6StsXdXbe09hRhp55AmY9rWzjmhnesCwKPwpq2d8raGM6INuKAdCH3FLMLGVcMGEqkqGIRIXzdHIbUSVTEC/FYxwqgJKXKsGtaKlN+gULKjkZQ8g6eDk1SnXhO3kepeksOSuYz+0qlbNQHEyi+EcsEGWNzUwVBbclRsw6ZYQMutAeNRmqJZw6xhElwgVW2IgX0zv9s7HaFtUrEKrCpYxZXqVUG3ylXhFdVU1FVezPSaF9HSu/eIyFrCxSlVb1L1gSKuqglEuRtKCQi0BZVSx60KtuuVtFHwYzQ9Eoz06AW8Unfk7Cai3RX/ALFoInAOQ8xPiV8SP531ZXPdKbukeCqBAD9bDD7HS58mZebuUkOrc2lznscFZJ6x2+SXifqUglZKI0GzWU2NBvDailM+QQkjBCB3FMyCU6vzPS5IKs4I0gtkLOJSB0GXSjfmS51Q28b5OBWvoIcdYSV3W82uUexsLPZPCgTg/Bv00i0xmhNodcLHWbySJfUUWtNgmReVdHmIArPnloOz4LzmROuuFzUn6lU6f70kMIOpXahcPHbWkPEDLJK8D8bCjJZEwbD38SR3Jt/Mo+tj+31xcv32NL+0Lk5/hRnPvdSPT/XjC/v8EEf6WbkQ0ULNfusbAkVeSCwUhpaOXNfwwtj2fd/tKqYJywif3MnjG3pMCSylat7mcVxQtXJ5XlfZvmGV7SIvCm3UjYmDYJqHjTbsGn65Ui1X2RzamlrBnuceuw/1KmFUHdx/oa2wvujNlqXxeCSH7nA8TByfq1Bt2mydZrVtVtunWW2fff8DFZlURt7OcdoEYi+I9veff2kX4MfXvFdrk+V7dq1NUs9BHtY9hL1ujAxKqWnrhukZ/ou6+DWdEqCWN3UNPBzKrNs3fVzia53S65gODGBNp2w65TM4Am46Jw14yECr53RUikB3KqjKAriTmM/knrTQSt1uzJ85JT3BvtyckppTUnNKak5JzSmpOSX9905JMLpv2ZHglBQbHky1BkZ+aDu6aXZ9S1eb2Es5JS3nqvuuR7eML3o54ngyFL3BiFtuNxqOe1uWIzCoWY6a5ag5I20+Ixl6ORrVW6SrU4yghnwUmm6EQgv+RBZjC+S+sDPS/71L6oY3zgx/0MsyogsYQzZ1Sd+HmaHpkk2XfKYnpNW/bzWHpCc6JM2gBEuM1FdAFRCTSKJYQCQmXPTR+1eHxx+C88MPZ59e7d4xrLsuQHctJWuL1clCiwuc0Q3LVA3/4CWqkDflG3OApzNfHmhQuvn0l+aC1VywmgtWc8FqLljNBevfumDBgL5lOwNudbuC+TVGvm4YGGPbw6Z63b+sA9Y+zK+wdGSL4e5he9raU1700mZOfOGIgho2hROXpxb7TUub2yxtzdLWnLa2nLZsz/rx9R91GxYGFDkAAA==","output":[{"uuid":"018a981f-5bd2-735a-b74b-65f538d7536a","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-5bd2-735a-b74b-65f538d7536a\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"eet6doxfgbi6510a\", \"$time\": 1694769306.58, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"p\", \"classes\": [\"font-normal\", \"text-xs\"], \"attr__class\": \"font-normal text-xs\", \"nth_child\": 2, \"nth_of_type\": 2, \"$el_text\": \"Available for JavaScript, Android, iOS, React Native, Node.js, Ruby, Go, and more.\"}, {\"tag_name\": \"div\", \"classes\": [\"mt-4\", \"mb-0\"], \"attr__class\": \"mt-4 mb-0\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"span\", \"classes\": [\"LemonButton__content\"], \"attr__class\": \"LemonButton__content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"button\", \"$el_text\": \"\", \"classes\": [\"LemonButton\", \"LemonButton--primary\", \"LemonButton--status-primary\", \"LemonButton--large\", \"LemonButton--full-width\", \"LemonButton--has-side-icon\", \"mb-4\"], \"attr__class\": \"LemonButton LemonButton--primary LemonButton--status-primary LemonButton--large LemonButton--full-width LemonButton--has-side-icon mb-4\", \"attr__type\": \"button\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"mb-6\"], \"attr__class\": \"flex flex-col mb-6\", \"nth_child\": 4, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1779}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-5bd9-7cb4-9fd5-5e574bb918eb","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-5bd9-7cb4-9fd5-5e574bb918eb\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/platform\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/platform\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"ilfvutq6qvqh58ow\", \"$time\": 1694769306.585, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Ingestion wizard \\u2022 PostHog\"}, \"offset\": 1774}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-5e75-7a07-a79f-1eee24012718","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-5e75-7a07-a79f-1eee24012718\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/platform\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/platform\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"37x61aqqtn9k28a5\", \"$time\": 1694769307.254, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"button\", \"$el_text\": \"mobile\", \"classes\": [\"LemonButton\", \"LemonButton--primary\", \"LemonButton--status-primary\", \"LemonButton--large\", \"LemonButton--full-width\", \"LemonButton--centered\", \"mb-2\"], \"attr__class\": \"LemonButton LemonButton--primary LemonButton--status-primary LemonButton--large LemonButton--full-width LemonButton--centered mb-2\", \"attr__type\": \"button\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"mb-6\"], \"attr__class\": \"flex flex-col mb-6\", \"nth_child\": 4, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1105}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-5e79-7f17-be1a-8b450229830f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-5e79-7f17-be1a-8b450229830f\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"oawqrgmuo369cqvg\", \"$time\": 1694769307.257, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Ingestion wizard \\u2022 PostHog\"}, \"offset\": 1102}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-60ea-7698-b26c-b3735fa37bd8","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-60ea-7698-b26c-b3735fa37bd8\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"017vn18mgnnd0rss\", \"$time\": 1694769307.883, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"button\", \"$el_text\": \"React Native\", \"classes\": [\"LemonButton\", \"LemonButton--primary\", \"LemonButton--status-primary\", \"LemonButton--large\", \"LemonButton--full-width\", \"LemonButton--centered\", \"mb-2\"], \"attr__class\": \"LemonButton LemonButton--primary LemonButton--status-primary LemonButton--large LemonButton--full-width LemonButton--centered mb-2\", \"attr__type\": \"button\", \"attr__data-attr\": \"select-framework-REACT_NATIVE\", \"nth_child\": 3, \"nth_of_type\": 3}, {\"tag_name\": \"div\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"FrameworkPanel\"], \"attr__class\": \"FrameworkPanel\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 476}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-60ee-7e1a-aadf-8011aaa47a22","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-60ee-7e1a-aadf-8011aaa47a22\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile/react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile/react_native\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"2w8r5rse14eqrg7f\", \"$time\": 1694769307.886, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Ingestion wizard \\u2022 PostHog\"}, \"offset\": 473}","now":"2023-09-15T09:15:08.365426+00:00","sent_at":"2023-09-15T09:15:08.363000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769311367&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:11.372150+00:00","body":"H4sIAAAAAAAAA+1XW1PbRhT+KxoNj1mQrKvpUyAlpdOk0DRJO5mMZqU9shYkrSKtbAjDf+858gXJNiYFXjLDk61z2fOd6579cmO2rRTmoWnZIR+Hdsr8cMRZYLmCjceew8C1wxAciF0rNV+ZMIVSo/geb7VKeKXbGpBc1aqCWktozMMbc0/hj/mOJ8afH4x/kI2EaAp1I1WJDNvat719i+hxrWYN1Eg8kTWk6oqIAqYygUhfV4CMN9BcalURI2nrGs1HbZ0jI9O6Ojw4yBFGnqlGH4aWZR3IcgKNRkMHhYplDgc18ERHJddySkj3SBS1h2rEqLjOSl6QzYdPWQC/88q2gx455+Wk5RM6C0r28QOpNEkNUEYZyEmGCGzLG91RZ1LoDIn+yELiVMKsUrVeCY9tu09eSntuiORcxmhnBjFZwY9+qPeDcN8juiwRl466XPtpkI7TYppdX6RZk02JryV5bvtjN/DHjjXeD5zxK1NIjEKJnnd61efXl6d/X587x+8Dxxl9/HZ+Vf7mV87Jp/DT7yKo/vXd4Ffx/vLqc9LL47C83HgkWBByYGgpZjGImHNrxK3UJZ2WovcIY7KJBBQqwkK8gAQjlvK8ATxwUqu26qpyxVqCAZaG8Yhh+i0mPMCCd0ILwONcuBRLVU94Kb9jzrtY3ml5XjLX4qEn2MhLbA88zxOeICRlo3mZUOq3Vqh5i6h63RNhjHmcg4jQdcxc1EiBykv8WHZYclEKvBNOcz5Bb758RVafFlX8OldckKdoAMs1LwgBWkXHklwml5nC4FILF1zmnTHKDp/iF9lfmWxynlzu4O9howI2InXtnsC2xZiuqNg4kVBogSLW43ZzY9nSHZyuoaHXQCSVQ4Fy5OCNqdGrRT82FS9RPsl509CI+WL+gckuj1qtVRlFiSo1jSUMCte6RgIJot5WqVdmqbMoyWSOZYZW6UulC2xzFJGGKyqU03IqNRjc0MALo4AihtrQysggr4yZ1JmhM9kYGOMK0zqAHHdWO9cfddx93iKj98WYprHL6+t1OhahbhtW1bLYws15jcNpjZi2ec7mo2WNk2DcoAYq7wE94w2jcmUSg4vMQjN/ZxaMrdiH1CHyIa/DPSTdoR7Sl5iH1AFio8O7hLsozlXielWCg3pQJaO1ZAtJM/R/K/QyjAUOOUuVQsybERxwd9fvQ8hwqA8U3K0Kc/ONvqa+Nwt+NY/woYETrLr65TEYes6e4oisWxxsqmzOyDUqnZiFm45vSBqd3NPMH9VSTOAMr+cdo2Ob0NNSvOVENqt5hYvTD5lfCT+f992c3mm7k3gug0g/XS5Wx6uYZl0L74JhrGsZC52nJSTNgfbN++wT+1kN0Q9LFJ2WsfneR1sG3oBprmYsk0IgZTuOpS4Cmmsa63pPSxLlmWF5oY1lXtZJjFX51npZFzTu03waxA84to842b2vZzckBgbdH5p8mwaRuPiH18c9OVoIGOuCT/O4VyXrJbSBYFUXg3J5DID5ud0OXuONMzzCeeCIWAlaNhY3aobrHL21ukfMzh6yb9EjrS7RWbzusiT6Nvl4Unmz76Oji2P3r+bk4t3b6tw5e/sG10X/3Dp+ax2fue9f88Q67R5X0NAaufnYABbEwmFx7FjM9+0gTt0wDP0xKc1kKdTsQZ3QtlIQvpfS6q7StAF6vvkjn3zf8nzG9w0PAsaTUDBuBUnMExGMfbq2ls9nOd8E5ytgY8x3DqPbi7st6+U9/TO9p4M6m1qz70kynUGbTyfb39Muwnt5T7+8p5/jPf2TTUrn9ut/K0nIYGoUAAA=","output":[{"uuid":"018a981f-682a-704d-9953-e4188e3eb40f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-682a-704d-9953-e4188e3eb40f\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile/react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile/react_native\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"6f7f9fmvhyjfhshv\", \"$time\": 1694769309.739, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"classes\": [\"LemonButton__content\"], \"attr__class\": \"LemonButton__content\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"Invite a team member to help with this step\"}, {\"tag_name\": \"button\", \"$el_text\": \"Invite a team member to help with this step\", \"classes\": [\"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-primary\", \"LemonButton--large\", \"LemonButton--full-width\", \"LemonButton--centered\", \"LemonButton--has-side-icon\", \"mt-6\"], \"attr__class\": \"LemonButton LemonButton--tertiary LemonButton--status-primary LemonButton--large LemonButton--full-width LemonButton--centered LemonButton--has-side-icon mt-6\", \"attr__type\": \"button\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"panel-footer\"], \"attr__class\": \"panel-footer\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"nth_child\": 9, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"InstructionsPanel\", \"mb-8\"], \"attr__class\": \"InstructionsPanel mb-8\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1626}","now":"2023-09-15T09:15:11.372150+00:00","sent_at":"2023-09-15T09:15:11.367000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-682d-7a77-ac8d-a07cbacd7968","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-682d-7a77-ac8d-a07cbacd7968\", \"event\": \"invite members button clicked\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile/react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile/react_native\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"7rhv0wzccvweulvg\", \"$time\": 1694769309.742, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1623}","now":"2023-09-15T09:15:11.372150+00:00","sent_at":"2023-09-15T09:15:11.367000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769314370&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:14.373644+00:00","body":"H4sIAAAAAAAAA+0a7W7bRvJVCMG4X7c2v0n5UBxq95L6cEmd5tIPBAWx5C4l2iSXIZeinKBAn6WP1ifp7FKiSIqS7EgFkpRAAEfzsTM7Xzsz0tsPk7KMyORyomounrpaiGwaBMhxLQ1h6oTIp5ZPNGIZxA4n/5zQBU05kJ/hkrMAZ7zMKYCznGU05xEtJpcfJmcM/kxe4ED57rXyE6AB4C1oXkQsBYSmnmvWuSrgfs6qguYAfBblNGRLASR0EQXU4w8ZBcQ3tLjnLBOIoMxzEO+VeQyIOefZ5cVFDGrEc1bwS1dV1YsondGCg6CLhPlRTC9yigPupZhHC6HpmSAF7i6bQGSYz1OcCJmHT1kpvrmVpjktcIzTWYln4iyaojevBUsR5JSm3pxGszlooKmWvoFWEeFzANq6CsBFRKuM5bwhnmpaG7ymtkwXwHHkg5yK+kIKfGib+txxzy0Bj1LQi3vS1yTHD0VcvlcL866a6cKvZzwSN9fsqenYU0PTzk0DLkQisEIKN5d82Y9f39/8/+GVcf3SMQz9zbtXy/RbOzOe/eD+8F/iZD/bpvMf8vJ++WPQ8mM3vExfJxBemCKQ5EN4ER9jVcdqaAqeUljvI4RFhUdowjwIxDsagMVCHBcUDpzlrMxkVDaotTIUha6vI3C/iohFCZoarkqphTExhS1ZPsNp9B58Lm254bKsoObCrkWQbgWaRS3LIhYRmqQFx2kgXD8YoZNfQatW9nhgY+zHlHhwdfCcV0QEmNf6Q9hByHkhxZI4jPEMbvP2F0C1YV6GH2KGibgpCIBwjROhAUiFiwVxFNzPGRhXpHCCo1gKE97BC/gk5DciixgH93vwZ5CoFBJRZO0ZgbQFmzZQSByPMJAgLNbCyrqxTmmpjkxo2kogQRXTBOjEBT9MONxqlY/FYgbkQYyLQlSYt5P/ga/TmwD4wBCY89zzJBZoN6g1ZpUtE40mDWydWB1gGMWisKQsFXaqYSLnrqAugf8VVdFN+NfglkmcCpErP1dVdV4Z5xA2F7ooRbXWq6NZUEo3A7m0Y4PBeYTRPCKECpPxvBSolM+9YB7FkAhgF/GJhSvr1XbyOF0K9SGaupbKsLh431RXJecs9bxot8k6JPs16Mn0Jaf08kavXToAovUJIS6eDZw/9OGQRLws4A9EMnivj00wuKoHTBkC3bl4oHqYOS7Qwasrg3p1oV2tejihUxe00agLb/RZq7NKjMaSrdCA5KMiKoOYyah5gmNItBjwwwtGcCwsIM4bNkiH5IQiwRZQGaBy7BfbkB0n+nvxZq/OvG4CYwCKEA7B3wh6mCY+JcVGz4I/yNyVteRSgUKeLf/VOGp9i4GzlQPy6sCopTXRgP0oJVQUHaQ10JxJDUiEY7YpLDJGEsn+qOrxFKN9B8U5xiI1B6A7jeZ5bMW35eWBYzr2GTi6ZZ/Nwae64i00U20vb+u5ougItHsCjX49ZESouMOoV4BFtdUauXwO755oSuWjtM9qLe6OTtBK9owAh3N2Lx+VbB5472ZvnmVW9V6/urs2vy+e3b14nr0ybp9/A8+v/Uq9fq5e35ovv8aBeiObVVqIZ3m7eaPI8YmBfN9QkW1rjh+aruvaU8FUQdSy6iCPq6khJbYVilaIhWFB4cXQoekUduyPI45GVOQYpoNcHVuI6ET3CTZpYIq2dhxHvoBxpExS3TLKZenPVVrNZL/YG0f0c12Fs8dxZBxHPoFxZKjhFU97lMrn93GNb5ZHye6+dwc2xjlkcQ8YlnGM6hzsYQLQnuZUxEHiI/0J7e9K/mD3O4iTmnVBG7268LVWitRpVwv85Bd+z2uoH2oJYGqiMQoZA8W2rdTBHqfZtMdgDjJ0e84EL2sztvrOY/qfGygJeQmJzNLiVlytDg93++JblMJl7pHir/KIzOgtPEd1qy9e8C3JQ0THuXjgRFTlOING4VHiG+LT3V7Wpb2yJcWpBAL8Zt1IbOahuczTfWoofS5lxXOcQ8IYJpzd8gX6pILEHxjHxWlzVPc54lWFih/GrFqvYIb1WPOCQjWn0uc7zknCzwjCq7XA6IOgJseD8dInVHZxHqfia+gDrrCQuytntyg6As1HVb5tgQBc/Q/2Jjt8tCJQ+oTH3bgVJf0Q2tKgiYtOuHyMAvW5sufM4cXpHmEcWsXVo+djpsovbX7UbHXH/GggR9VshPEU2A2baFgNTKP7dVYGdU7MM5/o8AgCo/Dh35DHPGR58lU9Bv4jzMHtFcvvvzpyrKzP/+wnyfd5kjiL2dQ28QNf1C369iSpgXrjJDlOkqeYJD/5EgkqRlyOEU0Xp1Tg+5wof/z2u3ILdvwW1smdSmrZg5V0GprIMXULYVFTIZem8CsBX4VY+7w2cWMxfVQx5TqJgtA3ZuldrJOcDRRT8xzWtmMxHYvpJ7GWO/Dd9865ZZDqCd/Bs1wRXGL7B6WVgwW5UtcAuGf/C/qh3eH+A070TfqOjeIjlocn+tJ8cG14aEf4mPWg9dSFQFOqUQyVi6bwGbZ7OZLhV2zf9wD9cVOmiIHVdbclt5EfPUv+VXvEcZE3LvLGRd64yBsXeeMi73SLPMfaMXzCr9JDaiMXGm9EMSF4alsO1ozPaI1XlKBSxiqQ+9eNny0hn/0MuiTJMkjM7J2uz5YLXY4DWzPoOIGOE+i4ztu1zrPdHfUUwtvWMJrq1EUhMWyqadbUUMUvMtb1tCkq++fTsdR+AaVWS+nSv4sSuLB7p7+T+8vtUquCyLHYjsX2b1Fs21XU+fWXPwH99tceLTkAAA==","output":[{"uuid":"018a981f-6ecc-7851-ae7f-be5bd1d53d6f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-6ecc-7851-ae7f-be5bd1d53d6f\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile/react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile/react_native\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"draysluz0s4jwg2f\", \"$time\": 1694769311.437, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"svg\", \"classes\": [\"LemonIcon\"], \"attr__class\": \"LemonIcon\", \"attr__width\": \"1em\", \"attr__height\": \"1em\", \"attr__fill\": \"none\", \"attr__viewBox\": \"0 0 24 24\", \"attr__xmlns\": \"http://www.w3.org/2000/svg\", \"attr__focusable\": \"false\", \"attr__aria-hidden\": \"true\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"\"}, {\"tag_name\": \"span\", \"classes\": [\"LemonButton__icon\"], \"attr__class\": \"LemonButton__icon\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"button\", \"$el_text\": \"\", \"classes\": [\"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-stealth\", \"LemonButton--small\", \"LemonButton--no-content\", \"LemonButton--has-icon\"], \"attr__class\": \"LemonButton LemonButton--tertiary LemonButton--status-stealth LemonButton--small LemonButton--no-content LemonButton--has-icon\", \"attr__type\": \"button\", \"attr__aria-label\": \"close\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonModal__close\"], \"attr__class\": \"LemonModal__close\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonModal__container\"], \"attr__class\": \"LemonModal__container\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"ReactModal__Content\", \"ReactModal__Content--after-open\", \"LemonModal\"], \"attr__style\": \"width: 800px;\", \"attr__class\": \"ReactModal__Content ReactModal__Content--after-open LemonModal\", \"attr__tabindex\": \"-1\", \"attr__role\": \"dialog\", \"attr__aria-modal\": \"true\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"ReactModal__Overlay\", \"ReactModal__Overlay--after-open\", \"LemonModal__overlay\"], \"attr__class\": \"ReactModal__Overlay ReactModal__Overlay--after-open LemonModal__overlay\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"ReactModalPortal\"], \"attr__class\": \"ReactModalPortal\", \"nth_child\": 6, \"nth_of_type\": 3}, {\"tag_name\": \"body\", \"classes\": [\"ReactModal__Body--open\"], \"attr__theme\": \"light\", \"attr__class\": \"ReactModal__Body--open\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2931}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-71d0-7347-82a5-d2d2bda4ec45","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-71d0-7347-82a5-d2d2bda4ec45\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/mobile/react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/mobile/react_native\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"umn253uxubh0ewgt\", \"$time\": 1694769312.208, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"button\", \"$el_text\": \"Continue\", \"classes\": [\"LemonButton\", \"LemonButton--primary\", \"LemonButton--status-primary\", \"LemonButton--large\", \"LemonButton--full-width\", \"LemonButton--centered\", \"mb-2\"], \"attr__class\": \"LemonButton LemonButton--primary LemonButton--status-primary LemonButton--large LemonButton--full-width LemonButton--centered mb-2\", \"attr__type\": \"button\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"panel-footer\"], \"attr__class\": \"panel-footer\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"nth_child\": 9, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"InstructionsPanel\", \"mb-8\"], \"attr__class\": \"InstructionsPanel mb-8\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"attr__class\": \"\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2160}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-71d3-7016-aa90-636d1a0c436f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-71d3-7016-aa90-636d1a0c436f\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/verify?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/verify\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"zrmm7vg964aytvon\", \"$time\": 1694769312.212, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Ingestion wizard \\u2022 PostHog\"}, \"offset\": 2156}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-79f4-7425-a1d3-9319851b0f8b","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-79f4-7425-a1d3-9319851b0f8b\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/verify?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/verify\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"t2dicfb3gnjl2dro\", \"$time\": 1694769314.293, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"classes\": [\"LemonButton__content\"], \"attr__class\": \"LemonButton__content\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"or continue without verifying\"}, {\"tag_name\": \"button\", \"$el_text\": \"or continue without verifying\", \"classes\": [\"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-primary\", \"LemonButton--full-width\", \"LemonButton--centered\"], \"attr__class\": \"LemonButton LemonButton--tertiary LemonButton--status-primary LemonButton--full-width LemonButton--centered\", \"attr__type\": \"button\", \"nth_child\": 5, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"ingestion-listening-for-events\"], \"attr__class\": \"ingestion-listening-for-events\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"text-center\"], \"attr__class\": \"text-center\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"attr__class\": \"\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 75}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-79fc-7fe6-8aad-eadda9657a13","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-79fc-7fe6-8aad-eadda9657a13\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/superpowers?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/superpowers\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"xdmxcm4pq22gxv2k\", \"$time\": 1694769314.3, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Ingestion wizard \\u2022 PostHog\"}, \"offset\": 68}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-79fd-761a-92e8-fd36e1159302","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-79fd-761a-92e8-fd36e1159302\", \"event\": \"ingestion continue without verifying\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/superpowers?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/superpowers\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"1nexbjim0008j2qy\", \"$time\": 1694769314.301, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 67}","now":"2023-09-15T09:15:14.373644+00:00","sent_at":"2023-09-15T09:15:14.370000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769317374&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:17.382070+00:00","body":"H4sIAAAAAAAAA+1X627bNhR+FUMI9qt0JOueoRiadMkyrFmyLO2GoBAokbIY06JCUZadoO++Q/kS+RKnSzJgW/3L1rl+50byXN8bVcWIcWCYVoDDwEpRYJIY+ZZtoSAkBKUBdpMU2z03MY03Bh3RXIH4Hq6USHChKkmBXEhRUKkYLY2De2NPwI/xASedXy87fwAbCNGIypKJHBiW2bXcrra2F0tRl1QC8ZhJmoqxJhI6YgmN1KSgwHhPy4EShWYklZTgPqokB0amVHGwv88BBs9EqQ4C0zT3Wd6npQJH+2UFkApRg98fCo5VKuTw7VDEjNPvUomHtBZy8FZSnKgox4qNdCB72hIYX7aqGQVWWQ5awNzspBXPQ7CW5bfIHOf9Cve1DZqjq0utUiaS0jzKKOtn4Nky3d4DtWZEZUD0eiYQR4zWhZBqIRxaVps8l3adAMicxeCnprH2Ah/tCnT9oOtqOssBl4qaFrDDO280roY9yUzlxDear5iO2PJCx/dC2/K6phu+MQiD6HPIW6NXfHo3OP19cmEfnfm23bu6vRjnP3mFffwx+Pgz8Ys/Pcf/kZwNxp+SVnmXu86JewT5AaYIPMUopiTG2OxhM3W0TqWz9wxnrIwIHYoI+vOGJpCxFPOSgsG+FFXRNOuCNQdDoefjHoKym4i4lKDQDkxKXYyJo3MpZB/n7A46psnlg5brJlMtHLgEwcBYLnVdl7hEI8lLhfNEl35j4xpfAFVrqCLIMY45JRGEDpWLSkZAeY4fmhYaNkopboRTjvsQzfVnYLVpUYEnXGCiIwUH0Ox8qBGAVwgs4SwZZAKSqyd7iBlvnOnq4BF8af8LlyXHyWALfw/ml8J86mHeIzDNkNMFFQYmIgI86Iy1uM1xMp/0Bk4z57Q1QFqK0yHI6QDvDQVRzeaQsBGIJxyXpT54ro1foNb5Zc1UkkVRhnMC+CAlWCkZRY0caG0SemPkKouSjHHoMZg//SXSGTA9j5RHio51l0ChlkDElVIAdFnmcVQz8QWqpqkhPpGjshFB+rTZgnjhbyojha6BMdVdUAlWGOm/wBKFQgys01JnFEHqhSRQkJm/7bFbK9FuSzkwWl8IJRlNBlQ3/xI5rThH06NqW206m2wtE1uWloKAnvl7QQwnKFgH01C3d8ZGw1MrpZo0lRni8RTjQQfmvBh//0Ksh5KRPj2HWwSQilzp+3gN+iahZ0Sy3S2qJS7g+vsq9wvh14u+OU62+m4kXssh0E/n9/7RIqdZ04XbYHRWtToznZcVJOVUv5Ye86/Zr+pI/6BEaGsZmj5P9GUIB3XKRY0yRghQNuOY6wKgqWZnVe+F8wt1RtBe4GNel1USQvAG3NQvq4KdxzRfBvESru9DrP0+NrNrEksOnRWHzlc6BOLsH3q0RjOBzqrgyyJudclqC60hWPTFUrs8B0DrVpVCrCTRfsJELMhkYUNl8OrQl3Pz1l5F/NS1CSEqMYDo4cmaJdFt/+q4cOu73uHNkfNbeXzz4aS4sM9P3sMzx7swj07Mo3Pn7B1OzNNmKZhe1uuPZIr8mNgojm0TeZ7lx6kTBIEXaqWa5UTUT+oElplS4rmpfnKKNC2pXjtsq0nG2jbo4AD5vTBGAbYwCggJ4YEdY+Knu23wf70N3kLeLdtzxze15RB/0zbod00b4O22wd02+C/YBl99EbP+mUWsVdZvfQXbcjXbu9Vqt1rtVqvdarVbrXar1X96tbLt8MvnvwD69vHlaRsAAA==","output":[{"uuid":"018a981f-80db-7131-89dd-f8a5cfa325c0","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-80db-7131-89dd-f8a5cfa325c0\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/superpowers?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/superpowers\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"39z6vxum2ri0t4bj\", \"$time\": 1694769316.059, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"div\", \"classes\": [\"LemonSwitch__handle\"], \"attr__class\": \"LemonSwitch__handle\", \"nth_child\": 2, \"nth_of_type\": 2, \"$el_text\": \"\"}, {\"tag_name\": \"button\", \"$el_text\": \"\", \"classes\": [\"LemonSwitch__button\"], \"attr__id\": \"lemon-switch-0\", \"attr__class\": \"LemonSwitch__button\", \"attr__role\": \"switch\", \"attr__data-attr\": \"opt-in-session-recording-switch\", \"nth_child\": 2, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonSwitch\", \"LemonSwitch--checked\", \"LemonSwitch--full-width\"], \"attr__class\": \"LemonSwitch LemonSwitch--checked LemonSwitch--full-width\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"my-8\"], \"attr__class\": \"my-8\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"attr__class\": \"\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1311}","now":"2023-09-15T09:15:17.382070+00:00","sent_at":"2023-09-15T09:15:17.374000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-84a8-729b-8a1a-8dd9647bad7f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-84a8-729b-8a1a-8dd9647bad7f\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/ingestion/superpowers?platform=mobile&framework=react_native\", \"$host\": \"localhost:8000\", \"$pathname\": \"/ingestion/superpowers\", \"$browser_version\": 117, \"$browser_language\": \"en-US\", \"$screen_height\": 1052, \"$screen_width\": 1620, \"$viewport_height\": 911, \"$viewport_width\": 1548, \"$lib\": \"web\", \"$lib_version\": \"1.78.5\", \"$insert_id\": \"qevi1365xjw14d7j\", \"$time\": 1694769317.032, \"distinct_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"$device_id\": \"018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$user_id\": \"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc\", \"is_demo_project\": false, \"$groups\": {\"project\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"organization\": \"018a981e-f55c-0000-a85d-25c15e555d5d\", \"instance\": \"http://localhost:8000\"}, \"$autocapture_disabled_server_side\": false, \"$active_feature_flags\": [], \"$feature_flag_payloads\": {}, \"realm\": \"hosted-clickhouse\", \"email_service_available\": false, \"slack_service_available\": false, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"button\", \"$el_text\": \"\", \"classes\": [\"LemonSwitch__button\"], \"attr__id\": \"lemon-switch-1\", \"attr__class\": \"LemonSwitch__button\", \"attr__role\": \"switch\", \"attr__data-attr\": \"opt-in-autocapture-switch\", \"nth_child\": 2, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonSwitch\", \"LemonSwitch--checked\", \"LemonSwitch--full-width\"], \"attr__class\": \"LemonSwitch LemonSwitch--checked LemonSwitch--full-width\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"nth_child\": 3, \"nth_of_type\": 3}, {\"tag_name\": \"div\", \"attr__style\": \"max-width: 800px;\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content\"], \"attr__class\": \"BridgePage__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__content-wrapper\"], \"attr__class\": \"BridgePage__content-wrapper\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage__main\"], \"attr__class\": \"BridgePage__main\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"BridgePage\", \"IngestionContent\", \"h-full\"], \"attr__class\": \"BridgePage IngestionContent h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"h-full\"], \"attr__class\": \"flex h-full\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"div\", \"classes\": [\"flex\", \"flex-col\", \"h-screen\", \"overflow-hidden\"], \"attr__class\": \"flex flex-col h-screen overflow-hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\", \"main-app-content--plain\"], \"attr__class\": \"main-app-content main-app-content--plain\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__content\"], \"attr__class\": \"SideBar__content\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\", \"SideBar--hidden\"], \"attr__class\": \"SideBar SideBar--hidden\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"h-screen\", \"flex\", \"flex-col\"], \"attr__class\": \"h-screen flex flex-col\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"attr__id\": \"root\", \"nth_child\": 3, \"nth_of_type\": 1}, {\"tag_name\": \"body\", \"attr__theme\": \"light\", \"attr__class\": \"\", \"nth_child\": 2, \"nth_of_type\": 1}], \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 339}","now":"2023-09-15T09:15:17.382070+00:00","sent_at":"2023-09-15T09:15:17.374000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} diff --git a/rust/capture/src/api.rs b/rust/capture/src/api.rs index b3c93c5462c6d..30914bee95f6f 100644 --- a/rust/capture/src/api.rs +++ b/rust/capture/src/api.rs @@ -29,8 +29,6 @@ pub enum CaptureError { EmptyBatch, #[error("event submitted with an empty event name")] MissingEventName, - #[error("event submitted with an empty distinct_id")] - EmptyDistinctId, #[error("event submitted without a distinct_id")] MissingDistinctId, #[error("replay event submitted without snapshot data")] @@ -70,7 +68,6 @@ impl IntoResponse for CaptureError { | CaptureError::RequestParsingError(_) | CaptureError::EmptyBatch | CaptureError::MissingEventName - | CaptureError::EmptyDistinctId | CaptureError::MissingDistinctId | CaptureError::EventTooBig | CaptureError::NonRetryableSinkError diff --git a/rust/capture/src/test_endpoint.rs b/rust/capture/src/test_endpoint.rs index 7f7bc74b335e3..f4b23d0c6160e 100644 --- a/rust/capture/src/test_endpoint.rs +++ b/rust/capture/src/test_endpoint.rs @@ -154,22 +154,12 @@ pub async fn test_black_hole( // Check every event has a distinct id. This is the last piece of data validation capture does. for event in events { match event.extract_distinct_id() { - Ok(_) => {} - Err(CaptureError::EmptyDistinctId) => { - metrics::counter!(REQUEST_OUTCOME, "outcome" => "failure", "reason" => "empty_distinct_id") - .increment(1); - return Err(CaptureError::EmptyDistinctId); - } - Err(CaptureError::MissingDistinctId) => { + Some(_) => {} + None => { metrics::counter!(REQUEST_OUTCOME, "outcome" => "failure", "reason" => "missing_distinct_id") .increment(1); return Err(CaptureError::MissingDistinctId); } - Err(e) => { - metrics::counter!(REQUEST_OUTCOME, "outcome" => "failure", "reason" => e.to_string()) - .increment(1); - return Err(e); - } } } diff --git a/rust/capture/src/v0_endpoint.rs b/rust/capture/src/v0_endpoint.rs index c30981c6239c2..4e8a00c0186da 100644 --- a/rust/capture/src/v0_endpoint.rs +++ b/rust/capture/src/v0_endpoint.rs @@ -8,7 +8,7 @@ use axum::extract::{MatchedPath, Query, State}; use axum::http::{HeaderMap, Method}; use axum_client_ip::InsecureClientIp; use base64::Engine; -use common_types::CapturedEvent; +use common_types::{CapturedEvent, RawEvent}; use metrics::counter; use serde_json::json; use serde_json::Value; @@ -23,7 +23,7 @@ use crate::{ api::{CaptureError, CaptureResponse, CaptureResponseCode}, router, sinks, utils::uuid_v7, - v0_request::{EventFormData, EventQuery, RawEvent}, + v0_request::{EventFormData, EventQuery}, }; /// Flexible endpoint that targets wide compatibility with the wide range of requests @@ -182,7 +182,6 @@ pub async fn event( .await { let cause = match err { - CaptureError::EmptyDistinctId => "empty_distinct_id", CaptureError::MissingDistinctId => "missing_distinct_id", CaptureError::MissingEventName => "missing_event_name", _ => "process_events_error", @@ -234,7 +233,6 @@ pub async fn recording( let count = events.len() as u64; if let Err(err) = process_replay_events(state.sink.clone(), events, &context).await { let cause = match err { - CaptureError::EmptyDistinctId => "empty_distinct_id", CaptureError::MissingDistinctId => "missing_distinct_id", CaptureError::MissingSessionId => "missing_session_id", CaptureError::MissingWindowId => "missing_window_id", @@ -295,7 +293,9 @@ pub fn process_single_event( let event = CapturedEvent { uuid: event.uuid.unwrap_or_else(uuid_v7), - distinct_id: event.extract_distinct_id()?, + distinct_id: event + .extract_distinct_id() + .ok_or(CaptureError::MissingDistinctId)?, ip: context.client_ip.clone(), data, now: context.now.clone(), @@ -352,7 +352,9 @@ pub async fn process_replay_events<'a>( .remove("$window_id") .unwrap_or(session_id.clone()); let uuid = events[0].uuid.unwrap_or_else(uuid_v7); - let distinct_id = events[0].extract_distinct_id()?; + let distinct_id = events[0] + .extract_distinct_id() + .ok_or(CaptureError::MissingDistinctId)?; let snapshot_source = events[0] .properties .remove("$snapshot_source") diff --git a/rust/capture/src/v0_request.rs b/rust/capture/src/v0_request.rs index e3ea92c7ae53e..0e3ae5feb8916 100644 --- a/rust/capture/src/v0_request.rs +++ b/rust/capture/src/v0_request.rs @@ -1,15 +1,13 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::io::prelude::*; use bytes::{Buf, Bytes}; -use common_types::CapturedEvent; +use common_types::{CapturedEvent, RawEvent}; use flate2::read::GzDecoder; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; +use serde::Deserialize; use time::format_description::well_known::Iso8601; use time::OffsetDateTime; use tracing::instrument; -use uuid::Uuid; use crate::api::CaptureError; use crate::prometheus::report_dropped_events; @@ -58,45 +56,6 @@ pub struct EventFormData { pub data: String, } -pub fn empty_string_is_none<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let opt = Option::::deserialize(deserializer)?; - match opt { - None => Ok(None), - Some(s) if s.is_empty() => Ok(None), - Some(s) => Uuid::parse_str(&s) - .map(Some) - .map_err(serde::de::Error::custom), - } -} - -#[derive(Default, Debug, Deserialize, Serialize)] -pub struct RawEvent { - #[serde( - alias = "$token", - alias = "api_key", - skip_serializing_if = "Option::is_none" - )] - pub token: Option, - #[serde(alias = "$distinct_id", skip_serializing_if = "Option::is_none")] - pub distinct_id: Option, // posthog-js accepts arbitrary values as distinct_id - #[serde(default, deserialize_with = "empty_string_is_none")] - pub uuid: Option, - pub event: String, - #[serde(default)] - pub properties: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub timestamp: Option, // Passed through if provided, parsed by ingestion - #[serde(skip_serializing_if = "Option::is_none")] - pub offset: Option, // Passed through if provided, parsed by ingestion - #[serde(rename = "$set", skip_serializing_if = "Option::is_none")] - pub set: Option>, - #[serde(rename = "$set_once", skip_serializing_if = "Option::is_none")] - pub set_once: Option>, -} - pub static GZIP_MAGIC_NUMBERS: [u8; 3] = [0x1f, 0x8b, 8]; #[derive(Deserialize)] @@ -236,44 +195,6 @@ pub fn extract_token(events: &[RawEvent]) -> Result { }; } -impl RawEvent { - pub fn extract_token(&self) -> Option { - match &self.token { - Some(value) => Some(value.clone()), - None => self - .properties - .get("token") - .and_then(Value::as_str) - .map(String::from), - } - } - - /// Extracts, stringifies and trims the distinct_id to a 200 chars String. - /// SDKs send the distinct_id either in the root field or as a property, - /// and can send string, number, array, or map values. We try to best-effort - /// stringify complex values, and make sure it's not longer than 200 chars. - pub fn extract_distinct_id(&self) -> Result { - // Breaking change compared to capture-py: None / Null is not allowed. - let value = match &self.distinct_id { - None | Some(Value::Null) => match self.properties.get("distinct_id") { - None | Some(Value::Null) => return Err(CaptureError::MissingDistinctId), - Some(id) => id, - }, - Some(id) => id, - }; - - let distinct_id = value - .as_str() - .map(|s| s.to_owned()) - .unwrap_or_else(|| value.to_string()); - match distinct_id.len() { - 0 => Err(CaptureError::EmptyDistinctId), - 1..=200 => Ok(distinct_id), - _ => Ok(distinct_id.chars().take(200).collect()), - } - } -} - #[derive(Debug)] pub struct ProcessingContext { pub lib_version: Option, @@ -309,9 +230,9 @@ pub struct ProcessedEventMetadata { #[cfg(test)] mod tests { use crate::token::InvalidTokenReason; - use crate::v0_request::empty_string_is_none; use base64::Engine as _; use bytes::Bytes; + use common_types::util::empty_string_is_none; use rand::distributions::Alphanumeric; use rand::Rng; use serde::Deserialize; @@ -384,7 +305,9 @@ mod tests { let parsed = RawRequest::from_bytes(input.into(), 2048) .expect("failed to parse") .events(); - parsed[0].extract_distinct_id() + parsed[0] + .extract_distinct_id() + .ok_or(CaptureError::MissingDistinctId) }; // Return MissingDistinctId if not found assert!(matches!( @@ -399,7 +322,7 @@ mod tests { // Return EmptyDistinctId if empty string assert!(matches!( parse_and_extract(r#"{"event": "e", "distinct_id": ""}"#), - Err(CaptureError::EmptyDistinctId) + Err(CaptureError::MissingDistinctId) )); let assert_extracted_id = |input: &'static str, expected: &str| { diff --git a/rust/common/kafka/src/config.rs b/rust/common/kafka/src/config.rs index 8096efce9d6f4..1519f358d4aa9 100644 --- a/rust/common/kafka/src/config.rs +++ b/rust/common/kafka/src/config.rs @@ -8,6 +8,9 @@ pub struct KafkaConfig { #[envconfig(default = "400")] pub kafka_producer_queue_mib: u32, // Size of the in-memory producer queue in mebibytes + #[envconfig(default = "10000000")] + pub kafka_producer_queue_messages: u32, // Maximum number of messages in the in-memory producer queue + #[envconfig(default = "20000")] pub kafka_message_timeout_ms: u32, // Time before we stop retrying producing a message: 20 seconds diff --git a/rust/common/kafka/src/kafka_producer.rs b/rust/common/kafka/src/kafka_producer.rs index 666b7e72eeb55..e43cdf2754d6f 100644 --- a/rust/common/kafka/src/kafka_producer.rs +++ b/rust/common/kafka/src/kafka_producer.rs @@ -4,7 +4,7 @@ use futures::future::join_all; use health::HealthHandle; use rdkafka::error::KafkaError; use rdkafka::producer::{FutureProducer, FutureRecord, Producer}; -use rdkafka::ClientConfig; +use rdkafka::{ClientConfig, ClientContext}; use serde::Serialize; use serde_json::error::Error as SerdeError; use thiserror::Error; @@ -43,6 +43,10 @@ pub async fn create_kafka_producer( .set( "queue.buffering.max.kbytes", (config.kafka_producer_queue_mib * 1024).to_string(), + ) + .set( + "queue.buffering.max.messages", + config.kafka_producer_queue_messages.to_string(), ); if config.kafka_tls { @@ -85,8 +89,8 @@ pub enum KafkaProduceError { KafkaProduceCanceled, } -pub async fn send_iter_to_kafka( - kafka_producer: &FutureProducer, +pub async fn send_iter_to_kafka( + kafka_producer: &FutureProducer, topic: &str, iter: impl IntoIterator, ) -> Result<(), KafkaProduceError> @@ -96,8 +100,8 @@ where send_keyed_iter_to_kafka(kafka_producer, topic, |_| None, iter).await } -pub async fn send_keyed_iter_to_kafka( - kafka_producer: &FutureProducer, +pub async fn send_keyed_iter_to_kafka( + kafka_producer: &FutureProducer, topic: &str, key_extractor: impl Fn(&T) -> Option, iter: impl IntoIterator, diff --git a/rust/common/kafka/src/lib.rs b/rust/common/kafka/src/lib.rs index 0f39a9504d116..ee56e670b7dee 100644 --- a/rust/common/kafka/src/lib.rs +++ b/rust/common/kafka/src/lib.rs @@ -3,6 +3,7 @@ pub mod kafka_consumer; pub mod kafka_messages; pub mod kafka_producer; pub mod test; +pub mod transaction; pub const APP_METRICS_TOPIC: &str = "clickhouse_app_metrics"; pub const APP_METRICS2_TOPIC: &str = "clickhouse_app_metrics2"; diff --git a/rust/common/kafka/src/test.rs b/rust/common/kafka/src/test.rs index 33bb2b5d4e213..6b7e76a259eb4 100644 --- a/rust/common/kafka/src/test.rs +++ b/rust/common/kafka/src/test.rs @@ -22,6 +22,7 @@ pub async fn create_mock_kafka() -> ( kafka_compression_codec: "none".to_string(), kafka_hosts: cluster.bootstrap_servers(), kafka_tls: false, + kafka_producer_queue_messages: 1000, }; ( diff --git a/rust/common/kafka/src/transaction.rs b/rust/common/kafka/src/transaction.rs new file mode 100644 index 0000000000000..5f44f2542ee75 --- /dev/null +++ b/rust/common/kafka/src/transaction.rs @@ -0,0 +1,148 @@ +use std::time::Duration; + +use rdkafka::{ + error::KafkaError, + producer::{FutureProducer, Producer}, + ClientConfig, +}; +use serde::Serialize; +use tracing::{debug, error, info}; + +use crate::{ + config::KafkaConfig, + kafka_producer::{send_keyed_iter_to_kafka, KafkaProduceError}, +}; + +pub struct TransactionalProducer { + inner: FutureProducer, + timeout: Duration, +} + +// TODO - right now, these don't hook into the liveness reporting we use elsewhere, because +// I needed them to be droppable, and theres no good way to make our liveness reporting be able +// to handle that. +impl TransactionalProducer { + pub fn from_config( + config: &KafkaConfig, + transactional_id: &str, + timeout: Duration, + ) -> Result { + let mut client_config = ClientConfig::new(); + client_config + .set("bootstrap.servers", &config.kafka_hosts) + .set("statistics.interval.ms", "10000") + .set("linger.ms", config.kafka_producer_linger_ms.to_string()) + .set( + "message.timeout.ms", + config.kafka_message_timeout_ms.to_string(), + ) + .set( + "compression.codec", + config.kafka_compression_codec.to_owned(), + ) + .set( + "queue.buffering.max.kbytes", + (config.kafka_producer_queue_mib * 1024).to_string(), + ) + .set( + "queue.buffering.max.messages", + config.kafka_producer_queue_messages.to_string(), + ) + .set("transactional.id", transactional_id); + + if config.kafka_tls { + client_config + .set("security.protocol", "ssl") + .set("enable.ssl.certificate.verification", "false"); + }; + + debug!("rdkafka configuration: {:?}", client_config); + let api: FutureProducer = client_config.create()?; + + // "Ping" the Kafka brokers by requesting metadata + match api + .client() + .fetch_metadata(None, std::time::Duration::from_secs(15)) + { + Ok(metadata) => { + info!( + "Successfully connected to Kafka brokers. Found {} topics.", + metadata.topics().len() + ); + } + Err(error) => { + error!("Failed to fetch metadata from Kafka brokers: {:?}", error); + return Err(error); + } + } + + api.init_transactions(timeout)?; + + Ok(TransactionalProducer { + inner: api, + timeout, + }) + } + + pub fn begin(self) -> Result { + self.inner.begin_transaction()?; + Ok(KafkaTransaction { producer: self }) + } + + pub fn set_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + // Expose the inner at the producer level, but not at the transaction level - + // during a transaction, we want strong control over the operations done, but outside + // of the transaction, we want to be able to do things like fetch metadata + pub fn inner(&self) -> &FutureProducer { + &self.inner + } +} + +// Transactions are either read-write or write-only +pub struct KafkaTransaction { + producer: TransactionalProducer, +} + +// TODO - support for read offset commit association, which turns out to be a little tricky +impl KafkaTransaction { + pub async fn send_keyed_iter_to_kafka( + &self, + topic: &str, + key_extractor: impl Fn(&D) -> Option, + iter: impl IntoIterator, + ) -> Result<(), KafkaProduceError> + where + D: Serialize, + { + send_keyed_iter_to_kafka(&self.producer.inner, topic, key_extractor, iter).await + } + + pub async fn send_iter_to_kafka( + &self, + topic: &str, + iter: impl IntoIterator, + ) -> Result<(), KafkaProduceError> + where + D: Serialize, + { + send_keyed_iter_to_kafka(&self.producer.inner, topic, |_| None, iter).await + } + + pub fn commit(self) -> Result { + self.producer + .inner + .commit_transaction(self.producer.timeout)?; + Ok(self.producer) + } + + pub fn abort(self) -> Result { + self.producer + .inner + .abort_transaction(self.producer.timeout)?; + Ok(self.producer) + } +} diff --git a/rust/common/types/src/event.rs b/rust/common/types/src/event.rs index 309f86c35543c..f3c9fb2c9b897 100644 --- a/rust/common/types/src/event.rs +++ b/rust/common/types/src/event.rs @@ -5,13 +5,40 @@ use serde_json::Value; use time::OffsetDateTime; use uuid::Uuid; +use crate::util::empty_string_is_none; + +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct RawEvent { + #[serde( + alias = "$token", + alias = "api_key", + skip_serializing_if = "Option::is_none" + )] + pub token: Option, + #[serde(alias = "$distinct_id", skip_serializing_if = "Option::is_none")] + pub distinct_id: Option, // posthog-js accepts arbitrary values as distinct_id + #[serde(default, deserialize_with = "empty_string_is_none")] + pub uuid: Option, + pub event: String, + #[serde(default)] + pub properties: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, // Passed through if provided, parsed by ingestion + #[serde(skip_serializing_if = "Option::is_none")] + pub offset: Option, // Passed through if provided, parsed by ingestion + #[serde(rename = "$set", skip_serializing_if = "Option::is_none")] + pub set: Option>, + #[serde(rename = "$set_once", skip_serializing_if = "Option::is_none")] + pub set_once: Option>, +} + // The event type that capture produces #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] pub struct CapturedEvent { pub uuid: Uuid, pub distinct_id: String, pub ip: String, - pub data: String, + pub data: String, // This should be a `RawEvent`, but we serialise twice. pub now: String, #[serde( with = "time::serde::rfc3339::option", @@ -21,6 +48,15 @@ pub struct CapturedEvent { pub token: String, } +// Used when we want to bypass token checks when emitting events from rust +// services, by just setting the team_id instead. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InternallyCapturedEvent { + #[serde(flatten)] + pub inner: CapturedEvent, + pub team_id: i32, +} + impl CapturedEvent { pub fn key(&self) -> String { format!("{}:{}", self.token, self.distinct_id) @@ -102,3 +138,41 @@ impl ClickHouseEvent { Ok(()) } } + +impl RawEvent { + pub fn extract_token(&self) -> Option { + match &self.token { + Some(value) => Some(value.clone()), + None => self + .properties + .get("token") + .and_then(Value::as_str) + .map(String::from), + } + } + + /// Extracts, stringifies and trims the distinct_id to a 200 chars String. + /// SDKs send the distinct_id either in the root field or as a property, + /// and can send string, number, array, or map values. We try to best-effort + /// stringify complex values, and make sure it's not longer than 200 chars. + pub fn extract_distinct_id(&self) -> Option { + // Breaking change compared to capture-py: None / Null is not allowed. + let value = match &self.distinct_id { + None | Some(Value::Null) => match self.properties.get("distinct_id") { + None | Some(Value::Null) => return None, + Some(id) => id, + }, + Some(id) => id, + }; + + let distinct_id = value + .as_str() + .map(|s| s.to_owned()) + .unwrap_or_else(|| value.to_string()); + match distinct_id.len() { + 0 => None, + 1..=200 => Some(distinct_id), + _ => Some(distinct_id.chars().take(200).collect()), + } + } +} diff --git a/rust/common/types/src/lib.rs b/rust/common/types/src/lib.rs index 61cad2345f0ed..e98b7e2acd051 100644 --- a/rust/common/types/src/lib.rs +++ b/rust/common/types/src/lib.rs @@ -4,6 +4,11 @@ mod team; // Events pub use event::CapturedEvent; pub use event::ClickHouseEvent; +pub use event::InternallyCapturedEvent; +pub use event::RawEvent; // Teams pub use team::Team; + +// Utils +pub mod util; diff --git a/rust/common/types/src/util.rs b/rust/common/types/src/util.rs new file mode 100644 index 0000000000000..1dbc3f0690dec --- /dev/null +++ b/rust/common/types/src/util.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Deserializer}; +use uuid::Uuid; + +pub fn empty_string_is_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt { + None => Ok(None), + Some(s) if s.is_empty() => Ok(None), + Some(s) => Uuid::parse_str(&s) + .map(Some) + .map_err(serde::de::Error::custom), + } +} diff --git a/rust/cymbal/src/config.rs b/rust/cymbal/src/config.rs index 870c4c2c9be2d..44bdff90ddc81 100644 --- a/rust/cymbal/src/config.rs +++ b/rust/cymbal/src/config.rs @@ -33,13 +33,6 @@ pub struct Config { #[envconfig(default = "4")] pub max_pg_connections: u32, - // These are unused for now, but useful while iterating in prod - #[envconfig(default = "true")] - pub skip_writes: bool, - - #[envconfig(default = "true")] - pub skip_reads: bool, - // cymbal makes HTTP get requests to auto-resolve sourcemaps - and follows redirects. To protect against SSRF, we only allow requests to public URLs by default #[envconfig(default = "false")] pub allow_internal_ips: bool,