diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml index 3e491cd25dcc5..3ca21341d11aa 100644 --- a/.github/workflows/build-and-deploy-prod.yml +++ b/.github/workflows/build-and-deploy-prod.yml @@ -7,6 +7,8 @@ on: push: branches: - master + paths-ignore: + - 'rust/**' jobs: slack: diff --git a/.github/workflows/ci-hog.yml b/.github/workflows/ci-hog.yml index 7e6a5caf94005..db26722eb7e84 100644 --- a/.github/workflows/ci-hog.yml +++ b/.github/workflows/ci-hog.yml @@ -51,7 +51,7 @@ jobs: # If this run wasn't initiated by the bot (meaning: snapshot update) and we've determined # there are backend changes, cancel previous runs - uses: n1hility/cancel-previous-runs@v3 - if: github.actor != 'posthog-bot' && needs.changes.outputs.backend == 'true' + if: github.actor != 'posthog-bot' && needs.changes.outputs.hog == 'true' with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index e1ef69937452d..ec353909ae486 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -13,6 +13,8 @@ on: push: branches: - master + paths-ignore: + - 'rust/**' workflow_dispatch: jobs: diff --git a/.github/workflows/rust-docker-build.yml b/.github/workflows/rust-docker-build.yml new file mode 100644 index 0000000000000..d44e5d847e601 --- /dev/null +++ b/.github/workflows/rust-docker-build.yml @@ -0,0 +1,92 @@ +name: Build container images + +on: + workflow_dispatch: + push: + paths: + - 'rust/**' + branches: + - 'master' + +jobs: + build: + name: Build and publish container image + strategy: + matrix: + image: + - capture + - hook-api + - hook-janitor + - hook-worker + runs-on: depot-ubuntu-22.04-4 + permissions: + id-token: write # allow issuing OIDC tokens for this workflow run + contents: read # allow reading the repo contents + packages: write # allow push to ghcr.io + + defaults: + run: + working-directory: rust + + steps: + - name: Check Out Repo + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + uses: actions/checkout@v3 + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: posthog + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to ghcr.io + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ghcr.io/posthog/posthog/${{ matrix.image }} + tags: | + type=ref,event=pr + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push image + id: docker_build + uses: depot/build-push-action@v1 + with: + context: ./rust/ + file: ./rust/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: BIN=${{ matrix.image }} + + - name: Container image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/rust-hook-migrator-docker.yml b/.github/workflows/rust-hook-migrator-docker.yml new file mode 100644 index 0000000000000..2dd7c01d015dc --- /dev/null +++ b/.github/workflows/rust-hook-migrator-docker.yml @@ -0,0 +1,86 @@ +name: Build hook-migrator docker image + +on: + workflow_dispatch: + push: + paths: + - 'rust/**' + branches: + - 'master' + +permissions: + packages: write + +jobs: + build: + name: build and publish hook-migrator image + runs-on: depot-ubuntu-22.04-4 + permissions: + id-token: write # allow issuing OIDC tokens for this workflow run + contents: read # allow reading the repo contents + packages: write # allow push to ghcr.io + + defaults: + run: + working-directory: rust + + steps: + - name: Check Out Repo + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + uses: actions/checkout@v3 + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: posthog + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to ghcr.io + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ghcr.io/posthog/hog-rs/hook-migrator + tags: | + type=ref,event=pr + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Build and push migrator + id: docker_build_hook_migrator + uses: depot/build-push-action@v1 + with: + context: ./rust/ + file: ./rust/Dockerfile.migrate + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Hook-migrator image digest + run: echo ${{ steps.docker_build_hook_migrator.outputs.digest }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000000000..c2c379334980e --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,192 @@ +name: Rust + +on: + workflow_dispatch: + push: + branches: [master, main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + # Job to decide if we should run rust ci + # See https://github.com/dorny/paths-filter#conditional-execution for more details + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.repository == 'PostHog/posthog' + name: Determine need to run rust checks + # Set job outputs to values from filter step + outputs: + rust: ${{ steps.filter.outputs.rust }} + steps: + # For pull requests it's not necessary to checkout the code, but we + # also want this to run on master so we need to checkout + - uses: actions/checkout@v3 + - uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + rust: + # Avoid running rust tests for irrelevant changes + - 'rust/**' + + build: + needs: changes + runs-on: depot-ubuntu-22.04-4 + + defaults: + run: + working-directory: rust + + steps: + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + - uses: actions/checkout@v3 + if: needs.changes.outputs.rust == 'true' + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Install rust + if: needs.changes.outputs.rust == 'true' + uses: dtolnay/rust-toolchain@1.77 + + - uses: actions/cache@v3 + if: needs.changes.outputs.rust == 'true' + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} + + - name: Run cargo build + if: needs.changes.outputs.rust == 'true' + run: cargo build --all --locked --release && find target/release/ -maxdepth 1 -executable -type f | xargs strip + + test: + needs: changes + runs-on: depot-ubuntu-22.04-4 + timeout-minutes: 10 + + defaults: + run: + working-directory: rust + + steps: + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + - uses: actions/checkout@v3 + if: needs.changes.outputs.rust == 'true' + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Login to DockerHub + if: needs.changes.outputs.rust == 'true' + uses: docker/login-action@v2 + with: + username: posthog + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Setup dependencies + if: needs.changes.outputs.rust == 'true' + run: | + docker compose up kafka redis db echo_server -d --wait + docker compose up setup_test_db + echo "127.0.0.1 kafka" | sudo tee -a /etc/hosts + + - name: Install rust + if: needs.changes.outputs.rust == 'true' + uses: dtolnay/rust-toolchain@1.77 + + - uses: actions/cache@v3 + if: needs.changes.outputs.rust == 'true' + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${ runner.os }-cargo-debug-${{ hashFiles('**/Cargo.lock') }} + + - name: Run cargo test + if: needs.changes.outputs.rust == 'true' + run: cargo test --all-features + + linting: + needs: changes + runs-on: depot-ubuntu-22.04-4 + + defaults: + run: + working-directory: rust + + steps: + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + - uses: actions/checkout@v3 + if: needs.changes.outputs.rust == 'true' + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Install rust + if: needs.changes.outputs.rust == 'true' + uses: dtolnay/rust-toolchain@1.77 + with: + components: clippy,rustfmt + + - uses: actions/cache@v3 + if: needs.changes.outputs.rust == 'true' + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/target + key: ${{ runner.os }}-cargo-debug-${{ hashFiles('**/Cargo.lock') }} + + - name: Check format + if: needs.changes.outputs.rust == 'true' + run: cargo fmt -- --check + + - name: Run clippy + if: needs.changes.outputs.rust == 'true' + run: cargo clippy -- -D warnings + + - name: Run cargo check + if: needs.changes.outputs.rust == 'true' + run: cargo check --all-features + + shear: + needs: changes + runs-on: depot-ubuntu-22.04-4 + + defaults: + run: + working-directory: rust + + steps: + # Checkout project code + # Use sparse checkout to only select files in rust directory + # Turning off cone mode ensures that files in the project root are not included during checkout + - uses: actions/checkout@v3 + if: needs.changes.outputs.rust == 'true' + with: + sparse-checkout: 'rust/' + sparse-checkout-cone-mode: false + + - name: Install cargo-binstall + if: needs.changes.outputs.rust == 'true' + uses: cargo-bins/cargo-binstall@main + + - name: Install cargo-shear + if: needs.changes.outputs.rust == 'true' + run: cargo binstall --no-confirm cargo-shear + + - run: cargo shear + if: needs.changes.outputs.rust == 'true' diff --git a/rust/.dockerignore b/rust/.dockerignore new file mode 100644 index 0000000000000..fb898f33fd8c6 --- /dev/null +++ b/rust/.dockerignore @@ -0,0 +1,5 @@ +.env +.git +.github +docker +target diff --git a/rust/.env b/rust/.env new file mode 100644 index 0000000000000..43eda2a13040b --- /dev/null +++ b/rust/.env @@ -0,0 +1 @@ +DATABASE_URL=postgres://posthog:posthog@localhost:15432/test_database diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000000000..ea8c4bf7f35f6 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000000000..804ab47416080 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,3822 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "async-trait" +version = "0.1.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-write-file" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436" +dependencies = [ + "nix", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core 0.4.3", + "axum-macros", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-client-ip" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72188bed20deb981f3a4a9fe674e5980fd9e9c2bd880baa94715ad5d60d64c67" +dependencies = [ + "axum 0.7.5", + "forwarded-header-value", + "serde", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "axum-test-helper" +version = "0.4.0" +source = "git+https://github.com/posthog/axum-test-helper.git#002d45d8bbbac04e6a474e9a850b7f023a87d32f" +dependencies = [ + "axum 0.7.5", + "bytes", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "reqwest 0.11.24", + "serde", + "tokio", + "tower", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "capture" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert-json-diff", + "async-trait", + "axum 0.7.5", + "axum-client-ip", + "axum-test-helper", + "base64 0.22.0", + "bytes", + "envconfig", + "flate2", + "futures", + "governor", + "health", + "metrics", + "metrics-exporter-prometheus", + "once_cell", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "rand", + "rdkafka", + "redis", + "reqwest 0.12.3", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "time", + "tokio", + "tower-http", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f13690e35a5e4ace198e7beea2895d29f3a9cc55015fcebe6336bd2010af9eb" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.0", +] + +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176dc175b78f56c0f321911d9c8eb2b77a78a4860b9c19db83835fea1a46649b" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "envconfig" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea81cc7e21f55a9d9b1efb6816904978d0bfbe31a50347cb24b2e75564bcac9b" +dependencies = [ + "envconfig_derive", +] + +[[package]] +name = "envconfig_derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfca278e5f84b45519acaaff758ebfa01f18e96998bc24b8f1b722dd804b9bf" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" + +[[package]] +name = "feature-flags" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert-json-diff", + "async-trait", + "axum 0.7.5", + "axum-client-ip", + "bytes", + "envconfig", + "once_cell", + "rand", + "redis", + "regex", + "reqwest 0.12.3", + "serde", + "serde-pickle", + "serde_json", + "sha1", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror", +] + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-timer" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "governor" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c390a940a5d157878dd057c78680a33ce3415bcd05b4799509ea44210914b4d5" +dependencies = [ + "cfg-if", + "dashmap", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "quanta 0.9.3", + "rand", + "smallvec", +] + +[[package]] +name = "h2" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.11", + "indexmap 2.2.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 1.1.0", + "indexmap 2.2.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ff8ae62cd3a9102e5637afc8452c55acf3844001bd5374e0b0bd7b6616c038" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.3", +] + +[[package]] +name = "health" +version = "0.1.0" +dependencies = [ + "axum 0.7.5", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "hook-api" +version = "0.1.0" +dependencies = [ + "axum 0.7.5", + "envconfig", + "eyre", + "hook-common", + "http-body-util", + "metrics", + "serde", + "serde_derive", + "serde_json", + "sqlx", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "hook-common" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum 0.7.5", + "chrono", + "http 1.1.0", + "metrics", + "metrics-exporter-prometheus", + "reqwest 0.12.3", + "serde", + "serde_json", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "hook-janitor" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum 0.7.5", + "envconfig", + "eyre", + "futures", + "health", + "hook-common", + "metrics", + "rdkafka", + "serde_json", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "hook-worker" +version = "0.1.0" +dependencies = [ + "axum 0.7.5", + "chrono", + "envconfig", + "futures", + "health", + "hook-common", + "http 1.1.0", + "metrics", + "reqwest 0.12.3", + "sqlx", + "thiserror", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.11", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.5", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2 0.4.2", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.11", + "hyper 0.14.28", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.28", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "pin-project-lite", + "socket2 0.5.5", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indenter" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "iter-read" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c397ca3ea05ad509c4ec451fea28b4771236a376ca1c69fd5143aae0cf8f93c4" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037731f5d3aaa87a5675e895b63ddff1a87624bc29f77004ea829809654e48f6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "mach" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "metrics" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77b9e10a211c839210fd7f99954bda26e5f8e26ec686ad68da6a32df7c80e782" +dependencies = [ + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d58e362dc7206e9456ddbcdbd53c71ba441020e62104703075a69151e38d85f" +dependencies = [ + "base64 0.22.0", + "http-body-util", + "hyper 1.3.1", + "hyper-tls", + "hyper-util", + "indexmap 2.2.2", + "ipnet", + "metrics", + "metrics-util", + "quanta 0.12.2", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2670b8badcc285d486261e2e9f1615b506baff91427b61bd336a472b65bbf5ed" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.13.1", + "metrics", + "num_cpus", + "quanta 0.12.2", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "native-tls" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "libc", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c165a9ab64cf766f73521c0dd2cfdff64f488b8f0b3e621face3462d3db536d7" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" +dependencies = [ + "bitflags 2.4.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d57987be3f2aeb70d385fff9b27fb74c5723cc9a52d904d4f9c807a0667bf" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "once_cell", + "pin-project-lite", + "thiserror", + "urlencoding", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a016b8d9495c639af2145ac22387dcb88e44118e45320d9238fbf4e7889abcb" +dependencies = [ + "async-trait", + "futures-core", + "http 0.2.11", + "opentelemetry", + "opentelemetry-proto", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "prost", + "thiserror", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8fddc9b68f5b80dae9d6f510b88e02396f006ad48cac349411fbecc80caae4" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9ab5bd6c42fb9349dcf28af2ba9a0667f697f9bdcca045d39f2cec5543e2910" + +[[package]] +name = "opentelemetry_sdk" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e90c7113be649e31e9a0f8b5ee24ed7a16923b322c3c5ab6367469c049d6b7e" +dependencies = [ + "async-trait", + "crossbeam-channel", + "futures-channel", + "futures-executor", + "futures-util", + "glob", + "once_cell", + "opentelemetry", + "ordered-float", + "percent-encoding", + "rand", + "thiserror", + "tokio", + "tokio-stream", +] + +[[package]] +name = "ordered-float" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76df7075c7d4d01fdcb46c912dd17fba5b60c78ea480b475f2b6ab6f666584e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb" + +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f5d036824e4761737860779c906171497f6d55681139d8312388f8fe398922" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "quanta" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20afe714292d5e879d8b12740aa223c6a88f118af41870e8b6196e39a02238a8" +dependencies = [ + "crossbeam-utils", + "libc", + "mach", + "once_cell", + "raw-cpuid 10.7.0", + "wasi 0.10.2+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quanta" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca0b7bac0b97248c40bb77288fc52029cf1459c0461ea1b05ee32ccf011de2c" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid 11.0.1", + "wasi 0.11.0+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "raw-cpuid" +version = "10.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c297679cb867470fa8c9f67dbba74a78d78e3e98d7cf2b08d6d71540f797332" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "raw-cpuid" +version = "11.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d86a7c4638d42c44551f4791a20e687dbb4c3de1f33c43dd71e355cd429def1" +dependencies = [ + "bitflags 2.4.2", +] + +[[package]] +name = "rdkafka" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1beea247b9a7600a81d4cc33f659ce1a77e1988323d7d2809c7ed1c21f4c316d" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "log", + "rdkafka-sys", + "serde", + "serde_derive", + "serde_json", + "slab", + "tokio", + "tracing", +] + +[[package]] +name = "rdkafka-sys" +version = "4.7.0+2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55e0d2f9ba6253f6ec72385e453294f8618e9e15c2c6aba2a5c01ccf9622d615" +dependencies = [ + "cmake", + "libc", + "libz-sys", + "num_enum", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "redis" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f49cdc0bb3f412bf8e7d1bd90fe1d9eb10bc5c399ba90973c14662a27b3f8ba" +dependencies = [ + "async-trait", + "bytes", + "combine", + "crc16", + "futures", + "futures-util", + "itoa", + "log", + "percent-encoding", + "pin-project-lite", + "rand", + "ryu", + "sha1_smol", + "socket2 0.4.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "reqwest" +version = "0.11.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", + "winreg 0.50.0", +] + +[[package]] +name = "reqwest" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6cc1e89e689536eb5aeede61520e874df5a4707df811cd5da4aa5fbb2aae19" +dependencies = [ + "base64 0.22.0", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.2", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.1.2", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg 0.52.0", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustix" +version = "0.38.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949" +dependencies = [ + "bitflags 2.4.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.0", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-pickle" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c762ad136a26407c6a80825813600ceeab5e613660d93d79a41f0ec877171e71" +dependencies = [ + "byteorder", + "iter-read", + "num-bigint", + "num-traits", + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba03c279da73694ef99763320dea58b51095dfe87d001b1d4b5fe78ba8763cf" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d84b0a3c3739e220d94b3239fd69fb1f74bc36e16643423bd99de3b43c21bfbd" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.2.2", + "log", + "memchr", + "native-tls", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89961c00dc4d7dffb7aee214964b065072bff69e36ddb9e2c107541f75e4f2a5" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0bd4519486723648186a08785143599760f7cc81c52334a55d6a83ea1e20841" +dependencies = [ + "atomic-write-file", + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.4.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.4.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210976b7d948c7ba9fced8ca835b11cbb2d677c59c79de41ac0d397e14547490" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a365e8cd18e44762ef95d87f284f4b5cd04107fec2ff3052bd6a3e6069669e67" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.5.5", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.2", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tonic" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.6.20", + "base64 0.21.7", + "bytes", + "h2 0.3.24", + "http 0.2.11", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.4.2", + "bytes", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9be14ba1bbe4ab79e9229f7f89fab8d120b865859f10527f31c033e599d2284" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "uuid" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +dependencies = [ + "atomic", + "getrandom", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.48", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde2032aeb86bdfaecc8b261eef3cba735cc426c1f3a3416d1e0791be95fc461" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" + +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58cd2333b6e0be7a39605f0e255892fd7418a682d8da8fe042fe25128794d2ed" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winnow" +version = "0.5.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cad8365489051ae9f054164e459304af2e7e9bb407c958076c8bf4aef52da5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000000000..ea5d041027ad8 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,81 @@ +[workspace] +resolver = "2" + +members = [ + "capture", + "common/health", + "feature-flags", + "hook-api", + "hook-common", + "hook-janitor", + "hook-worker", +] + +[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" +non_ascii_idents = "deny" +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unit_bindings = "deny" + +[workspace.lints.clippy] +# See https://rust-lang.github.io/rust-clippy/, we might want to add more +enum_glob_use = "deny" + +[workspace.dependencies] +anyhow = "1.0" +assert-json-diff = "2.0.2" +async-trait = "0.1.74" +axum = { version = "0.7.5", features = ["http2", "macros", "matched-path"] } +axum-client-ip = "0.6.0" +base64 = "0.22.0" +bytes = "1" +chrono = { version = "0.4" } +envconfig = "0.10.0" +eyre = "0.6.9" +flate2 = "1.0" +futures = { version = "0.3.29" } +governor = { version = "0.5.1", features = ["dashmap"] } +http = { version = "1.1.0" } +http-body-util = "0.1.0" +metrics = "0.22.0" +metrics-exporter-prometheus = "0.14.0" +once_cell = "1.18.0" +opentelemetry = { version = "0.22.0", features = ["trace"]} +opentelemetry-otlp = "0.15.0" +opentelemetry_sdk = { version = "0.22.1", features = ["trace", "rt-tokio"] } +rand = "0.8.5" +rdkafka = { version = "0.36.0", features = ["cmake-build", "ssl", "tracing"] } +reqwest = { version = "0.12.3", features = ["json", "stream"] } +serde = { version = "1.0", features = ["derive"] } +serde_derive = { version = "1.0" } +serde_json = { version = "1.0" } +serde_urlencoded = "0.7.1" +sqlx = { version = "0.7", features = [ + "chrono", + "json", + "migrate", + "postgres", + "runtime-tokio", + "tls-native-tls", + "uuid", +] } +time = { version = "0.3.20", features = [ + "formatting", + "macros", + "parsing", + "serde", +] } +thiserror = { version = "1.0" } +tokio = { version = "1.34.0", features = ["full"] } +tower = "0.4.13" +tower-http = { version = "0.5.2", features = ["cors", "limit", "trace"] } +tracing = "0.1.40" +tracing-opentelemetry = "0.23.0" +tracing-subscriber = { version="0.3.18", features = ["env-filter"] } +url = { version = "2.5.0 " } +uuid = { version = "1.6.1", features = ["v7", "serde"] } diff --git a/rust/Dockerfile b/rust/Dockerfile new file mode 100644 index 0000000000000..a6c59b11a0e33 --- /dev/null +++ b/rust/Dockerfile @@ -0,0 +1,38 @@ +FROM docker.io/lukemathwalker/cargo-chef:latest-rust-1.77-bookworm AS chef +ARG BIN +WORKDIR /app + +FROM chef AS planner +ARG BIN + +COPY . . +RUN cargo chef prepare --recipe-path recipe.json --bin $BIN + +FROM chef AS builder +ARG BIN + +# Ensure working C compile setup (not installed by default in arm64 images) +RUN apt update && apt install build-essential cmake -y + +COPY --from=planner /app/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json + +COPY . . +RUN cargo build --release --bin $BIN + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + "ca-certificates" \ + && \ + rm -rf /var/lib/apt/lists/* + +ARG BIN +ENV BIN=$BIN +WORKDIR /app + +USER nobody + +COPY --from=builder /app/target/release/$BIN /usr/local/bin +ENTRYPOINT ["/bin/sh", "-c", "/usr/local/bin/$BIN"] diff --git a/rust/Dockerfile.migrate b/rust/Dockerfile.migrate new file mode 100644 index 0000000000000..e7fc120360b79 --- /dev/null +++ b/rust/Dockerfile.migrate @@ -0,0 +1,16 @@ +FROM docker.io/library/rust:1.74.0-buster as builder + +RUN apt update && apt install build-essential cmake -y +RUN cargo install sqlx-cli@0.7.3 --no-default-features --features native-tls,postgres --root /app/target/release/ + +FROM debian:bullseye-20230320-slim AS runtime +WORKDIR /sqlx + +COPY bin /sqlx/bin/ +COPY migrations /sqlx/migrations/ + +COPY --from=builder /app/target/release/bin/sqlx /usr/local/bin + +RUN chmod +x ./bin/migrate + +CMD ["./bin/migrate"] diff --git a/rust/LICENSE b/rust/LICENSE new file mode 100644 index 0000000000000..d1e439cba370e --- /dev/null +++ b/rust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 PostHog + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000000000..9e8957756562c --- /dev/null +++ b/rust/README.md @@ -0,0 +1,40 @@ +# hog-rs + +PostHog Rust service monorepo. This is *not* the Rust client library for PostHog. + +## capture + +This is a rewrite of [capture.py](https://github.com/PostHog/posthog/blob/master/posthog/api/capture.py), in Rust. + +### Why? + +Capture is very simple. It takes some JSON, checks a key in Redis, and then pushes onto Kafka. It's mostly IO bound. + +We currently use far too much compute to run this service, and it could be more efficient. This effort should not take too long to complete, but should massively reduce our CPU usage - and therefore spend. + +### How? + +I'm trying to ensure the rewrite at least vaguely resembles the Python version. This will both minimize accidental regressions, but also serve as a "rosetta stone" for engineers at PostHog who have not written Rust before. + +## rusty-hook +A reliable and performant webhook system for PostHog + +### Requirements + +1. [Rust](https://www.rust-lang.org/tools/install). +2. [Docker](https://docs.docker.com/engine/install/), or [podman](https://podman.io/docs/installation) and [podman-compose](https://github.com/containers/podman-compose#installation): To setup development stack. + +### Testing + +1. Start development stack: +```bash +docker compose -f docker-compose.yml up -d --wait +``` + +2. Test: +```bash +# Note that tests require a DATABASE_URL environment variable to be set, e.g.: +# export DATABASE_URL=postgres://posthog:posthog@localhost:15432/test_database +# But there is an .env file in the project root that should be used automatically. +cargo test +``` diff --git a/rust/bin/migrate b/rust/bin/migrate new file mode 100755 index 0000000000000..6e36fc40f8f9a --- /dev/null +++ b/rust/bin/migrate @@ -0,0 +1,4 @@ +#!/bin/sh + +sqlx database create +sqlx migrate run diff --git a/rust/capture/Cargo.toml b/rust/capture/Cargo.toml new file mode 100644 index 0000000000000..97d310f03d662 --- /dev/null +++ b/rust/capture/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "capture" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +axum-client-ip = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +envconfig = { workspace = true } +flate2 = { workspace = true } +governor = { workspace = true } +health = { path = "../common/health" } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +opentelemetry = { workspace = true } +opentelemetry-otlp = { workspace = true } +opentelemetry_sdk = { workspace = true } +rand = { workspace = true } +rdkafka = { workspace = true } +redis = { version = "0.23.3", features = [ + "tokio-comp", + "cluster", + "cluster-async", +] } +serde = { workspace = true } +serde_json = { workspace = true } +serde_urlencoded = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-opentelemetry = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +assert-json-diff = { workspace = true } +axum-test-helper = { git = "https://github.com/posthog/axum-test-helper.git" } # TODO: remove, directly use reqwest like capture-server tests +anyhow = { workspace = true } +futures = { workspace = true } +once_cell = { workspace = true } +rand = { workspace = true } +rdkafka = { workspace = true } +reqwest = { workspace = true } +serde_json = { workspace = true } diff --git a/rust/capture/src/api.rs b/rust/capture/src/api.rs new file mode 100644 index 0000000000000..97d84857075e4 --- /dev/null +++ b/rust/capture/src/api.rs @@ -0,0 +1,109 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::token::InvalidTokenReason; + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum CaptureResponseCode { + Ok = 1, +} + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct CaptureResponse { + pub status: CaptureResponseCode, +} + +#[derive(Error, Debug)] +pub enum CaptureError { + #[error("failed to decode request: {0}")] + RequestDecodingError(String), + #[error("failed to parse request: {0}")] + RequestParsingError(#[from] serde_json::Error), + + #[error("request holds no event")] + 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("event submitted without an api_key")] + NoTokenError, + #[error("batch submitted with inconsistent api_key values")] + MultipleTokensError, + #[error("API key is not valid: {0}")] + TokenValidationError(#[from] InvalidTokenReason), + + #[error("transient error, please retry")] + RetryableSinkError, + #[error("maximum event size exceeded")] + EventTooBig, + #[error("invalid event could not be processed")] + NonRetryableSinkError, + + #[error("billing limit reached")] + BillingLimit, + + #[error("rate limited")] + RateLimited, +} + +impl IntoResponse for CaptureError { + fn into_response(self) -> Response { + match self { + CaptureError::RequestDecodingError(_) + | CaptureError::RequestParsingError(_) + | CaptureError::EmptyBatch + | CaptureError::MissingEventName + | CaptureError::EmptyDistinctId + | CaptureError::MissingDistinctId + | CaptureError::EventTooBig + | CaptureError::NonRetryableSinkError => (StatusCode::BAD_REQUEST, self.to_string()), + + CaptureError::NoTokenError + | CaptureError::MultipleTokensError + | CaptureError::TokenValidationError(_) => (StatusCode::UNAUTHORIZED, self.to_string()), + + CaptureError::RetryableSinkError => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), + + CaptureError::BillingLimit | CaptureError::RateLimited => { + (StatusCode::TOO_MANY_REQUESTS, self.to_string()) + } + } + .into_response() + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum DataType { + AnalyticsMain, + AnalyticsHistorical, +} +#[derive(Clone, Debug, Serialize, Eq, PartialEq)] +pub struct ProcessedEvent { + #[serde(skip_serializing)] + pub data_type: DataType, + pub uuid: Uuid, + pub distinct_id: String, + pub ip: String, + pub data: String, + pub now: String, + #[serde( + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub sent_at: Option, + pub token: String, +} + +impl ProcessedEvent { + pub fn key(&self) -> String { + format!("{}:{}", self.token, self.distinct_id) + } +} diff --git a/rust/capture/src/config.rs b/rust/capture/src/config.rs new file mode 100644 index 0000000000000..d91e7b7241337 --- /dev/null +++ b/rust/capture/src/config.rs @@ -0,0 +1,57 @@ +use std::{net::SocketAddr, num::NonZeroU32}; + +use envconfig::Envconfig; + +#[derive(Envconfig, Clone)] +pub struct Config { + #[envconfig(default = "false")] + pub print_sink: bool, + + #[envconfig(default = "127.0.0.1:3000")] + pub address: SocketAddr, + + pub redis_url: String, + pub otel_url: Option, + + #[envconfig(default = "false")] + pub overflow_enabled: bool, + + #[envconfig(default = "100")] + pub overflow_per_second_limit: NonZeroU32, + + #[envconfig(default = "1000")] + pub overflow_burst_limit: NonZeroU32, + + pub overflow_forced_keys: Option, // Coma-delimited keys + + #[envconfig(nested = true)] + pub kafka: KafkaConfig, + + #[envconfig(default = "1.0")] + pub otel_sampling_rate: f64, + + #[envconfig(default = "capture")] + pub otel_service_name: String, + + #[envconfig(default = "true")] + pub export_prometheus: bool, +} + +#[derive(Envconfig, Clone)] +pub struct KafkaConfig { + #[envconfig(default = "20")] + pub kafka_producer_linger_ms: u32, // Maximum time between producer batches during low traffic + #[envconfig(default = "400")] + pub kafka_producer_queue_mib: u32, // Size of the in-memory producer queue in mebibytes + #[envconfig(default = "20000")] + pub kafka_message_timeout_ms: u32, // Time before we stop retrying producing a message: 20 seconds + #[envconfig(default = "none")] + pub kafka_compression_codec: String, // none, gzip, snappy, lz4, zstd + pub kafka_hosts: String, + #[envconfig(default = "events_plugin_ingestion")] + pub kafka_topic: String, + #[envconfig(default = "events_plugin_ingestion_historical")] + pub kafka_historical_topic: String, + #[envconfig(default = "false")] + pub kafka_tls: bool, +} diff --git a/rust/capture/src/lib.rs b/rust/capture/src/lib.rs new file mode 100644 index 0000000000000..d5d47dd9ea421 --- /dev/null +++ b/rust/capture/src/lib.rs @@ -0,0 +1,13 @@ +pub mod api; +pub mod config; +pub mod limiters; +pub mod prometheus; +pub mod redis; +pub mod router; +pub mod server; +pub mod sinks; +pub mod time; +pub mod token; +pub mod utils; +pub mod v0_endpoint; +pub mod v0_request; diff --git a/rust/capture/src/limiters/billing.rs b/rust/capture/src/limiters/billing.rs new file mode 100644 index 0000000000000..b908519dda265 --- /dev/null +++ b/rust/capture/src/limiters/billing.rs @@ -0,0 +1,196 @@ +use std::{collections::HashSet, ops::Sub, sync::Arc}; + +use crate::redis::Client; + +/// Limit accounts by team ID if they hit a billing limit +/// +/// We have an async celery worker that regularly checks on accounts + assesses if they are beyond +/// a billing limit. If this is the case, a key is set in redis. +/// +/// Requirements +/// +/// 1. Updates from the celery worker should be reflected in capture within a short period of time +/// 2. Capture should cope with redis being _totally down_, and fail open +/// 3. We should not hit redis for every single request +/// +/// The solution here is to read from the cache until a time interval is hit, and then fetch new +/// data. The write requires taking a lock that stalls all readers, though so long as redis reads +/// stay fast we're ok. +/// +/// Some small delay between an account being limited and the limit taking effect is acceptable. +/// However, ideally we should not allow requests from some pods but 429 from others. +use thiserror::Error; +use time::{Duration, OffsetDateTime}; +use tokio::sync::RwLock; +use tracing::instrument; + +// todo: fetch from env +const QUOTA_LIMITER_CACHE_KEY: &str = "@posthog/quota-limits/"; + +#[derive(Debug)] +pub enum QuotaResource { + Events, + Recordings, +} + +impl QuotaResource { + fn as_str(&self) -> &'static str { + match self { + Self::Events => "events", + Self::Recordings => "recordings", + } + } +} + +#[derive(Error, Debug)] +pub enum LimiterError { + #[error("updater already running - there can only be one")] + UpdaterRunning, +} + +#[derive(Clone)] +pub struct BillingLimiter { + limited: Arc>>, + redis: Arc, + interval: Duration, + updated: Arc>, +} + +impl BillingLimiter { + /// Create a new BillingLimiter. + /// + /// This connects to a redis cluster - pass in a vec of addresses for the initial nodes. + /// + /// You can also initialize the limiter with a set of tokens to limit from the very beginning. + /// This may be overridden by Redis, if the sets differ, + /// + /// Pass an empty redis node list to only use this initial set. + pub fn new( + interval: Duration, + redis: Arc, + ) -> anyhow::Result { + let limited = Arc::new(RwLock::new(HashSet::new())); + + // Force an update immediately if we have any reasonable interval + let updated = OffsetDateTime::from_unix_timestamp(0)?; + let updated = Arc::new(RwLock::new(updated)); + + Ok(BillingLimiter { + interval, + limited, + updated, + redis, + }) + } + + #[instrument(skip_all)] + async fn fetch_limited( + client: &Arc, + resource: QuotaResource, + ) -> anyhow::Result> { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + + client + .zrangebyscore( + format!("{QUOTA_LIMITER_CACHE_KEY}{}", resource.as_str()), + now.to_string(), + String::from("+Inf"), + ) + .await + } + + #[instrument(skip_all, fields(key = key))] + pub async fn is_limited(&self, key: &str, resource: QuotaResource) -> bool { + // hold the read lock to clone it, very briefly. clone is ok because it's very small 🤏 + // rwlock can have many readers, but one writer. the writer will wait in a queue with all + // the readers, so we want to hold read locks for the smallest time possible to avoid + // writers waiting for too long. and vice versa. + let updated = { + let updated = self.updated.read().await; + *updated + }; + + let now = OffsetDateTime::now_utc(); + let since_update = now.sub(updated); + + // If an update is due, fetch the set from redis + cache it until the next update is due. + // Otherwise, return a value from the cache + // + // This update will block readers! Keep it fast. + if since_update > self.interval { + // open the update lock to change the update, and prevent anyone else from doing so + let mut updated = self.updated.write().await; + *updated = OffsetDateTime::now_utc(); + + let span = tracing::debug_span!("updating billing cache from redis"); + let _span = span.enter(); + + // a few requests might end up in here concurrently, but I don't think a few extra will + // be a big problem. If it is, we can rework the concurrency a bit. + // On prod atm we call this around 15 times per second at peak times, and it usually + // completes in <1ms. + + let set = Self::fetch_limited(&self.redis, resource).await; + + tracing::debug!("fetched set from redis, caching"); + + if let Ok(set) = set { + let set = HashSet::from_iter(set.iter().cloned()); + + let mut limited = self.limited.write().await; + *limited = set; + + tracing::debug!("updated cache from redis"); + + limited.contains(key) + } else { + tracing::error!("failed to fetch from redis in time, failing open"); + // If we fail to fetch the set, something really wrong is happening. To avoid + // dropping events that we don't mean to drop, fail open and accept data. Better + // than angry customers :) + // + // TODO: Consider backing off our redis checks + false + } + } else { + let l = self.limited.read().await; + + l.contains(key) + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use time::Duration; + + use crate::{ + limiters::billing::{BillingLimiter, QuotaResource}, + redis::MockRedisClient, + }; + + #[tokio::test] + async fn test_dynamic_limited() { + let client = MockRedisClient::new().zrangebyscore_ret(vec![String::from("banana")]); + let client = Arc::new(client); + + let limiter = BillingLimiter::new(Duration::microseconds(1), client) + .expect("Failed to create billing limiter"); + + assert_eq!( + limiter + .is_limited("idk it doesn't matter", QuotaResource::Events) + .await, + false + ); + + assert_eq!( + limiter + .is_limited("some_org_hit_limits", QuotaResource::Events) + .await, + false + ); + assert!(limiter.is_limited("banana", QuotaResource::Events).await); + } +} diff --git a/rust/capture/src/limiters/mod.rs b/rust/capture/src/limiters/mod.rs new file mode 100644 index 0000000000000..58b2dcc1a5c8c --- /dev/null +++ b/rust/capture/src/limiters/mod.rs @@ -0,0 +1,2 @@ +pub mod billing; +pub mod overflow; diff --git a/rust/capture/src/limiters/overflow.rs b/rust/capture/src/limiters/overflow.rs new file mode 100644 index 0000000000000..65bf14a5adba4 --- /dev/null +++ b/rust/capture/src/limiters/overflow.rs @@ -0,0 +1,127 @@ +/// The analytics ingestion pipeline provides ordering guarantees for events of the same +/// token and distinct_id. We currently achieve this through a locality constraint on the +/// Kafka partition (consistent partition hashing through a computed key). +/// +/// Volume spikes to a given key can create lag on the destination partition and induce +/// ingestion lag. To protect the downstream systems, capture can relax this locality +/// constraint when bursts are detected. When that happens, the excess traffic will be +/// spread across all partitions and be processed by the overflow consumer, without +/// strict ordering guarantees. +use std::collections::HashSet; +use std::num::NonZeroU32; +use std::sync::Arc; + +use governor::{clock, state::keyed::DefaultKeyedStateStore, Quota, RateLimiter}; +use metrics::gauge; +use rand::Rng; + +// See: https://docs.rs/governor/latest/governor/_guide/index.html#usage-in-multiple-threads +#[derive(Clone)] +pub struct OverflowLimiter { + limiter: Arc, clock::DefaultClock>>, + forced_keys: HashSet, +} + +impl OverflowLimiter { + pub fn new(per_second: NonZeroU32, burst: NonZeroU32, forced_keys: Option) -> Self { + let quota = Quota::per_second(per_second).allow_burst(burst); + let limiter = Arc::new(governor::RateLimiter::dashmap(quota)); + + let forced_keys: HashSet = match forced_keys { + None => HashSet::new(), + Some(values) => values.split(',').map(String::from).collect(), + }; + + OverflowLimiter { + limiter, + forced_keys, + } + } + + pub fn is_limited(&self, key: &String) -> bool { + self.forced_keys.contains(key) || self.limiter.check_key(key).is_err() + } + + /// Reports the number of tracked keys to prometheus every 10 seconds, + /// needs to be spawned in a separate task. + pub async fn report_metrics(&self) { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(10)); + loop { + interval.tick().await; + gauge!("partition_limits_key_count").set(self.limiter.len() as f64); + } + } + + /// Clean up the rate limiter state, once per minute. Ensure we don't use more memory than + /// necessary. + pub async fn clean_state(&self) { + // Give a small amount of randomness to the interval to ensure we don't have all replicas + // locking at the same time. The lock isn't going to be held for long, but this will reduce + // impact regardless. + let interval_secs = rand::thread_rng().gen_range(60..70); + + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(interval_secs)); + loop { + interval.tick().await; + + self.limiter.retain_recent(); + self.limiter.shrink_to_fit(); + } + } +} + +#[cfg(test)] +mod tests { + use crate::limiters::overflow::OverflowLimiter; + use std::num::NonZeroU32; + + #[tokio::test] + async fn low_limits() { + let limiter = OverflowLimiter::new( + NonZeroU32::new(1).unwrap(), + NonZeroU32::new(1).unwrap(), + None, + ); + let token = String::from("test"); + + assert!(!limiter.is_limited(&token)); + assert!(limiter.is_limited(&token)); + } + + #[tokio::test] + async fn bursting() { + let limiter = OverflowLimiter::new( + NonZeroU32::new(1).unwrap(), + NonZeroU32::new(3).unwrap(), + None, + ); + let token = String::from("test"); + + assert!(!limiter.is_limited(&token)); + assert!(!limiter.is_limited(&token)); + assert!(!limiter.is_limited(&token)); + assert!(limiter.is_limited(&token)); + } + + #[tokio::test] + async fn forced_key() { + let key_one = String::from("one"); + let key_two = String::from("two"); + let key_three = String::from("three"); + let forced_keys = Some(String::from("one,three")); + + let limiter = OverflowLimiter::new( + NonZeroU32::new(1).unwrap(), + NonZeroU32::new(1).unwrap(), + forced_keys, + ); + + // One and three are limited from the start, two is not + assert!(limiter.is_limited(&key_one)); + assert!(!limiter.is_limited(&key_two)); + assert!(limiter.is_limited(&key_three)); + + // Two is limited on the second event + assert!(limiter.is_limited(&key_two)); + } +} diff --git a/rust/capture/src/main.rs b/rust/capture/src/main.rs new file mode 100644 index 0000000000000..12b91941c7f6c --- /dev/null +++ b/rust/capture/src/main.rs @@ -0,0 +1,88 @@ +use std::time::Duration; + +use envconfig::Envconfig; +use opentelemetry::{KeyValue, Value}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::trace::{BatchConfig, RandomIdGenerator, Sampler, Tracer}; +use opentelemetry_sdk::{runtime, Resource}; +use tokio::signal; +use tracing::level_filters::LevelFilter; +use tracing::Level; +use tracing_opentelemetry::OpenTelemetryLayer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +use capture::config::Config; +use capture::server::serve; + +async fn shutdown() { + let mut term = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + + let mut interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to register SIGINT handler"); + + tokio::select! { + _ = term.recv() => {}, + _ = interrupt.recv() => {}, + }; + + tracing::info!("Shutting down gracefully..."); +} + +fn init_tracer(sink_url: &str, sampling_rate: f64, service_name: &str) -> Tracer { + opentelemetry_otlp::new_pipeline() + .tracing() + .with_trace_config( + opentelemetry_sdk::trace::Config::default() + .with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased( + sampling_rate, + )))) + .with_id_generator(RandomIdGenerator::default()) + .with_resource(Resource::new(vec![KeyValue::new( + "service.name", + Value::from(service_name.to_string()), + )])), + ) + .with_batch_config(BatchConfig::default()) + .with_exporter( + opentelemetry_otlp::new_exporter() + .tonic() + .with_endpoint(sink_url) + .with_timeout(Duration::from_secs(3)), + ) + .install_batch(runtime::Tokio) + .unwrap() +} + +#[tokio::main] +async fn main() { + let config = Config::init_from_env().expect("Invalid configuration:"); + + // Instantiate tracing outputs: + // - stdout with a level configured by the RUST_LOG envvar (default=ERROR) + // - OpenTelemetry if enabled, for levels INFO and higher + let log_layer = tracing_subscriber::fmt::layer().with_filter(EnvFilter::from_default_env()); + let otel_layer = config + .otel_url + .clone() + .map(|url| { + OpenTelemetryLayer::new(init_tracer( + &url, + config.otel_sampling_rate, + &config.otel_service_name, + )) + }) + .with_filter(LevelFilter::from_level(Level::INFO)); + tracing_subscriber::registry() + .with(log_layer) + .with(otel_layer) + .init(); + + // Open the TCP port and start the server + let listener = tokio::net::TcpListener::bind(config.address) + .await + .expect("could not bind port"); + serve(config, listener, shutdown()).await +} diff --git a/rust/capture/src/prometheus.rs b/rust/capture/src/prometheus.rs new file mode 100644 index 0000000000000..b4e19974ab51f --- /dev/null +++ b/rust/capture/src/prometheus.rs @@ -0,0 +1,70 @@ +// Middleware + prometheus exporter setup + +use std::time::Instant; + +use axum::body::Body; +use axum::{extract::MatchedPath, http::Request, middleware::Next, response::IntoResponse}; +use metrics::counter; +use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle}; + +pub fn report_dropped_events(cause: &'static str, quantity: u64) { + counter!("capture_events_dropped_total", "cause" => cause).increment(quantity); +} + +pub fn report_overflow_partition(quantity: u64) { + counter!("capture_partition_key_capacity_exceeded_total").increment(quantity); +} + +pub fn setup_metrics_recorder() -> PrometheusHandle { + // Ok I broke it at the end, but the limit on our ingress is 60 and that's a nicer way of reaching it + const EXPONENTIAL_SECONDS: &[f64] = &[ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, + ]; + const BATCH_SIZES: &[f64] = &[ + 1.0, 10.0, 25.0, 50.0, 75.0, 100.0, 250.0, 500.0, 750.0, 1000.0, + ]; + + PrometheusBuilder::new() + .set_buckets_for_metric( + Matcher::Full("http_requests_duration_seconds".to_string()), + EXPONENTIAL_SECONDS, + ) + .unwrap() + .set_buckets_for_metric(Matcher::Suffix("_batch_size".to_string()), BATCH_SIZES) + .unwrap() + .install_recorder() + .unwrap() +} + +/// Middleware to record some common HTTP metrics +/// Generic over B to allow for arbitrary body types (eg Vec, Streams, a deserialized thing, etc) +/// Someday tower-http might provide a metrics middleware: https://github.com/tower-rs/tower-http/issues/57 +pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse { + let start = Instant::now(); + + let path = if let Some(matched_path) = req.extensions().get::() { + matched_path.as_str().to_owned() + } else { + req.uri().path().to_owned() + }; + + let method = req.method().clone(); + + // Run the rest of the request handling first, so we can measure it and get response + // codes. + let response = next.run(req).await; + + let latency = start.elapsed().as_secs_f64(); + let status = response.status().as_u16().to_string(); + + let labels = [ + ("method", method.to_string()), + ("path", path), + ("status", status), + ]; + + metrics::counter!("http_requests_total", &labels).increment(1); + metrics::histogram!("http_requests_duration_seconds", &labels).record(latency); + + response +} diff --git a/rust/capture/src/redis.rs b/rust/capture/src/redis.rs new file mode 100644 index 0000000000000..c83c0ad89a8ac --- /dev/null +++ b/rust/capture/src/redis.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use redis::AsyncCommands; +use tokio::time::timeout; + +// average for all commands is <10ms, check grafana +const REDIS_TIMEOUT_MILLISECS: u64 = 10; + +/// A simple redis wrapper +/// I'm currently just exposing the commands we use, for ease of implementation +/// Allows for testing + injecting failures +/// We can also swap it out for alternative implementations in the future +/// I tried using redis-rs Connection/ConnectionLike traits but honestly things just got really +/// awkward to work with. + +#[async_trait] +pub trait Client { + // A very simplified wrapper, but works for our usage + async fn zrangebyscore(&self, k: String, min: String, max: String) -> Result>; +} + +pub struct RedisClient { + client: redis::Client, +} + +impl RedisClient { + pub fn new(addr: String) -> Result { + let client = redis::Client::open(addr)?; + + Ok(RedisClient { client }) + } +} + +#[async_trait] +impl Client for RedisClient { + async fn zrangebyscore(&self, k: String, min: String, max: String) -> Result> { + let mut conn = self.client.get_async_connection().await?; + + let results = conn.zrangebyscore(k, min, max); + let fut = timeout(Duration::from_secs(REDIS_TIMEOUT_MILLISECS), results).await?; + + Ok(fut?) + } +} + +// mockall got really annoying with async and results so I'm just gonna do my own +#[derive(Clone)] +pub struct MockRedisClient { + zrangebyscore_ret: Vec, +} + +impl MockRedisClient { + pub fn new() -> MockRedisClient { + MockRedisClient { + zrangebyscore_ret: Vec::new(), + } + } + + pub fn zrangebyscore_ret(&mut self, ret: Vec) -> Self { + self.zrangebyscore_ret = ret; + + self.clone() + } +} + +impl Default for MockRedisClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Client for MockRedisClient { + // A very simplified wrapper, but works for our usage + async fn zrangebyscore(&self, _k: String, _min: String, _max: String) -> Result> { + Ok(self.zrangebyscore_ret.clone()) + } +} diff --git a/rust/capture/src/router.rs b/rust/capture/src/router.rs new file mode 100644 index 0000000000000..0710302549def --- /dev/null +++ b/rust/capture/src/router.rs @@ -0,0 +1,113 @@ +use std::future::ready; +use std::sync::Arc; + +use axum::http::Method; +use axum::{ + routing::{get, post}, + Router, +}; +use health::HealthRegistry; +use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer}; +use tower_http::trace::TraceLayer; + +use crate::{ + limiters::billing::BillingLimiter, redis::Client, sinks, time::TimeSource, v0_endpoint, +}; + +use crate::prometheus::{setup_metrics_recorder, track_metrics}; + +#[derive(Clone)] +pub struct State { + pub sink: Arc, + pub timesource: Arc, + pub redis: Arc, + pub billing: BillingLimiter, +} + +async fn index() -> &'static str { + "capture" +} + +pub fn router< + TZ: TimeSource + Send + Sync + 'static, + S: sinks::Event + Send + Sync + 'static, + R: Client + Send + Sync + 'static, +>( + timesource: TZ, + liveness: HealthRegistry, + sink: S, + redis: Arc, + billing: BillingLimiter, + metrics: bool, +) -> Router { + let state = State { + sink: Arc::new(sink), + timesource: Arc::new(timesource), + redis, + billing, + }; + + // Very permissive CORS policy, as old SDK versions + // and reverse proxies might send funky headers. + let cors = CorsLayer::new() + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers(AllowHeaders::mirror_request()) + .allow_credentials(true) + .allow_origin(AllowOrigin::mirror_request()); + + let router = Router::new() + // TODO: use NormalizePathLayer::trim_trailing_slash + .route("/", get(index)) + .route("/_readiness", get(index)) + .route("/_liveness", get(move || ready(liveness.get_status()))) + .route( + "/e", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .route( + "/e/", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .route( + "/batch", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .route( + "/batch/", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .route( + "/i/v0/e", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .route( + "/i/v0/e/", + post(v0_endpoint::event) + .get(v0_endpoint::event) + .options(v0_endpoint::options), + ) + .layer(TraceLayer::new_for_http()) + .layer(cors) + .layer(axum::middleware::from_fn(track_metrics)) + .with_state(state); + + // Don't install metrics unless asked to + // Installing a global recorder when capture is used as a library (during tests etc) + // does not work well. + if metrics { + let recorder_handle = setup_metrics_recorder(); + router.route("/metrics", get(move || ready(recorder_handle.render()))) + } else { + router + } +} diff --git a/rust/capture/src/server.rs b/rust/capture/src/server.rs new file mode 100644 index 0000000000000..85850363e762c --- /dev/null +++ b/rust/capture/src/server.rs @@ -0,0 +1,98 @@ +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use health::{ComponentStatus, HealthRegistry}; +use time::Duration; +use tokio::net::TcpListener; + +use crate::config::Config; + +use crate::limiters::billing::BillingLimiter; +use crate::limiters::overflow::OverflowLimiter; +use crate::redis::RedisClient; +use crate::router; +use crate::sinks::kafka::KafkaSink; +use crate::sinks::print::PrintSink; + +pub async fn serve(config: Config, listener: TcpListener, shutdown: F) +where + F: Future + Send + 'static, +{ + let liveness = HealthRegistry::new("liveness"); + + let redis_client = + Arc::new(RedisClient::new(config.redis_url).expect("failed to create redis client")); + + let billing = BillingLimiter::new(Duration::seconds(5), redis_client.clone()) + .expect("failed to create billing limiter"); + + let app = if config.print_sink { + // Print sink is only used for local debug, don't allow a container with it to run on prod + liveness + .register("print_sink".to_string(), Duration::seconds(30)) + .await + .report_status(ComponentStatus::Unhealthy) + .await; + + router::router( + crate::time::SystemTime {}, + liveness, + PrintSink {}, + redis_client, + billing, + config.export_prometheus, + ) + } else { + let sink_liveness = liveness + .register("rdkafka".to_string(), Duration::seconds(30)) + .await; + + let partition = match config.overflow_enabled { + false => None, + true => { + let partition = OverflowLimiter::new( + config.overflow_per_second_limit, + config.overflow_burst_limit, + config.overflow_forced_keys, + ); + if config.export_prometheus { + let partition = partition.clone(); + tokio::spawn(async move { + partition.report_metrics().await; + }); + } + { + // Ensure that the rate limiter state does not grow unbounded + let partition = partition.clone(); + tokio::spawn(async move { + partition.clean_state().await; + }); + } + Some(partition) + } + }; + let sink = KafkaSink::new(config.kafka, sink_liveness, partition) + .expect("failed to start Kafka sink"); + + router::router( + crate::time::SystemTime {}, + liveness, + sink, + redis_client, + billing, + config.export_prometheus, + ) + }; + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + tracing::info!("listening on {:?}", listener.local_addr().unwrap()); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await + .unwrap() +} diff --git a/rust/capture/src/sinks/kafka.rs b/rust/capture/src/sinks/kafka.rs new file mode 100644 index 0000000000000..b82d3c342a115 --- /dev/null +++ b/rust/capture/src/sinks/kafka.rs @@ -0,0 +1,435 @@ +use std::time::Duration; + +use async_trait::async_trait; +use health::HealthHandle; +use metrics::{counter, gauge, histogram}; +use rdkafka::error::{KafkaError, RDKafkaErrorCode}; +use rdkafka::producer::{DeliveryFuture, FutureProducer, FutureRecord, Producer}; +use rdkafka::util::Timeout; +use rdkafka::ClientConfig; +use tokio::task::JoinSet; +use tracing::log::{debug, error, info}; +use tracing::{info_span, instrument, Instrument}; + +use crate::api::{CaptureError, DataType, ProcessedEvent}; +use crate::config::KafkaConfig; +use crate::limiters::overflow::OverflowLimiter; +use crate::prometheus::report_dropped_events; +use crate::sinks::Event; + +struct KafkaContext { + liveness: HealthHandle, +} + +impl rdkafka::ClientContext for KafkaContext { + fn stats(&self, stats: rdkafka::Statistics) { + // Signal liveness, as the main rdkafka loop is running and calling us + self.liveness.report_healthy_blocking(); + + // Update exported metrics + gauge!("capture_kafka_callback_queue_depth",).set(stats.replyq as f64); + gauge!("capture_kafka_producer_queue_depth",).set(stats.msg_cnt as f64); + gauge!("capture_kafka_producer_queue_depth_limit",).set(stats.msg_max as f64); + gauge!("capture_kafka_producer_queue_bytes",).set(stats.msg_max as f64); + gauge!("capture_kafka_producer_queue_bytes_limit",).set(stats.msg_size_max as f64); + + for (topic, stats) in stats.topics { + gauge!( + "capture_kafka_produce_avg_batch_size_bytes", + "topic" => topic.clone() + ) + .set(stats.batchsize.avg as f64); + gauge!( + "capture_kafka_produce_avg_batch_size_events", + "topic" => topic + ) + .set(stats.batchcnt.avg as f64); + } + + for (_, stats) in stats.brokers { + let id_string = format!("{}", stats.nodeid); + if let Some(rtt) = stats.rtt { + gauge!( + "capture_kafka_produce_rtt_latency_us", + "quantile" => "p50", + "broker" => id_string.clone() + ) + .set(rtt.p50 as f64); + gauge!( + "capture_kafka_produce_rtt_latency_us", + "quantile" => "p90", + "broker" => id_string.clone() + ) + .set(rtt.p90 as f64); + gauge!( + "capture_kafka_produce_rtt_latency_us", + "quantile" => "p95", + "broker" => id_string.clone() + ) + .set(rtt.p95 as f64); + gauge!( + "capture_kafka_produce_rtt_latency_us", + "quantile" => "p99", + "broker" => id_string.clone() + ) + .set(rtt.p99 as f64); + } + + gauge!( + "capture_kafka_broker_requests_pending", + "broker" => id_string.clone() + ) + .set(stats.outbuf_cnt as f64); + gauge!( + "capture_kafka_broker_responses_awaiting", + "broker" => id_string.clone() + ) + .set(stats.waitresp_cnt as f64); + counter!( + "capture_kafka_broker_tx_errors_total", + "broker" => id_string.clone() + ) + .absolute(stats.txerrs); + counter!( + "capture_kafka_broker_rx_errors_total", + "broker" => id_string.clone() + ) + .absolute(stats.rxerrs); + counter!( + "capture_kafka_broker_request_timeouts", + "broker" => id_string + ) + .absolute(stats.req_timeouts); + } + } +} + +#[derive(Clone)] +pub struct KafkaSink { + producer: FutureProducer, + partition: Option, + main_topic: String, + historical_topic: String, +} + +impl KafkaSink { + pub fn new( + config: KafkaConfig, + liveness: HealthHandle, + partition: Option, + ) -> anyhow::Result { + info!("connecting to Kafka brokers at {}...", config.kafka_hosts); + + let mut client_config = ClientConfig::new(); + client_config + .set("bootstrap.servers", &config.kafka_hosts) + .set("statistics.interval.ms", "10000") + .set("partitioner", "murmur2_random") // Compatibility with python-kafka + .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) + .set( + "queue.buffering.max.kbytes", + (config.kafka_producer_queue_mib * 1024).to_string(), + ); + + if config.kafka_tls { + client_config + .set("security.protocol", "ssl") + .set("enable.ssl.certificate.verification", "false"); + }; + + debug!("rdkafka configuration: {:?}", client_config); + let producer: FutureProducer = + client_config.create_with_context(KafkaContext { liveness })?; + + // Ping the cluster to make sure we can reach brokers, fail after 10 seconds + drop(producer.client().fetch_metadata( + Some("__consumer_offsets"), + Timeout::After(Duration::new(10, 0)), + )?); + info!("connected to Kafka brokers"); + + Ok(KafkaSink { + producer, + partition, + main_topic: config.kafka_topic, + historical_topic: config.kafka_historical_topic, + }) + } + + pub fn flush(&self) -> Result<(), KafkaError> { + // TODO: hook it up on shutdown + self.producer.flush(Duration::new(30, 0)) + } + + async fn kafka_send(&self, event: ProcessedEvent) -> Result { + let payload = serde_json::to_string(&event).map_err(|e| { + error!("failed to serialize event: {}", e); + CaptureError::NonRetryableSinkError + })?; + + let event_key = event.key(); + let (topic, partition_key): (&str, Option<&str>) = match &event.data_type { + DataType::AnalyticsHistorical => (&self.historical_topic, Some(event_key.as_str())), // We never trigger overflow on historical events + DataType::AnalyticsMain => { + // TODO: deprecate capture-led overflow or move logic in handler + let is_limited = match &self.partition { + None => false, + Some(partition) => partition.is_limited(&event_key), + }; + if is_limited { + (&self.main_topic, None) // Analytics overflow goes to the main topic without locality + } else { + (&self.main_topic, Some(event_key.as_str())) + } + } + }; + + match self.producer.send_result(FutureRecord { + topic, + payload: Some(&payload), + partition: None, + key: partition_key, + timestamp: None, + headers: None, + }) { + Ok(ack) => Ok(ack), + Err((e, _)) => match e.rdkafka_error_code() { + Some(RDKafkaErrorCode::MessageSizeTooLarge) => { + report_dropped_events("kafka_message_size", 1); + Err(CaptureError::EventTooBig) + } + _ => { + // TODO(maybe someday): Don't drop them but write them somewhere and try again + report_dropped_events("kafka_write_error", 1); + error!("failed to produce event: {}", e); + Err(CaptureError::RetryableSinkError) + } + }, + } + } + + async fn process_ack(delivery: DeliveryFuture) -> Result<(), CaptureError> { + match delivery.await { + Err(_) => { + // Cancelled due to timeout while retrying + counter!("capture_kafka_produce_errors_total").increment(1); + error!("failed to produce to Kafka before write timeout"); + Err(CaptureError::RetryableSinkError) + } + Ok(Err((KafkaError::MessageProduction(RDKafkaErrorCode::MessageSizeTooLarge), _))) => { + // Rejected by broker due to message size + report_dropped_events("kafka_message_size", 1); + Err(CaptureError::EventTooBig) + } + Ok(Err((err, _))) => { + // Unretriable produce error + counter!("capture_kafka_produce_errors_total").increment(1); + error!("failed to produce to Kafka: {}", err); + Err(CaptureError::RetryableSinkError) + } + Ok(Ok(_)) => { + counter!("capture_events_ingested_total").increment(1); + Ok(()) + } + } + } +} + +#[async_trait] +impl Event for KafkaSink { + #[instrument(skip_all)] + async fn send(&self, event: ProcessedEvent) -> Result<(), CaptureError> { + let ack = self.kafka_send(event).await?; + histogram!("capture_event_batch_size").record(1.0); + Self::process_ack(ack) + .instrument(info_span!("ack_wait_one")) + .await + } + + #[instrument(skip_all)] + async fn send_batch(&self, events: Vec) -> Result<(), CaptureError> { + let mut set = JoinSet::new(); + let batch_size = events.len(); + for event in events { + // We await kafka_send to get events in the producer queue sequentially + let ack = self.kafka_send(event).await?; + + // Then stash the returned DeliveryFuture, waiting concurrently for the write ACKs from brokers. + set.spawn(Self::process_ack(ack)); + } + + // Await on all the produce promises, fail batch on first failure + async move { + while let Some(res) = set.join_next().await { + match res { + Ok(Ok(_)) => {} + Ok(Err(err)) => { + set.abort_all(); + return Err(err); + } + Err(err) => { + set.abort_all(); + error!("join error while waiting on Kafka ACK: {:?}", err); + return Err(CaptureError::RetryableSinkError); + } + } + } + Ok(()) + } + .instrument(info_span!("ack_wait_many")) + .await?; + + histogram!("capture_event_batch_size").record(batch_size as f64); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::api::{CaptureError, DataType, ProcessedEvent}; + use crate::config; + use crate::limiters::overflow::OverflowLimiter; + use crate::sinks::kafka::KafkaSink; + use crate::sinks::Event; + use crate::utils::uuid_v7; + use health::HealthRegistry; + use rand::distributions::Alphanumeric; + use rand::Rng; + use rdkafka::mocking::MockCluster; + use rdkafka::producer::DefaultProducerContext; + use rdkafka::types::{RDKafkaApiKey, RDKafkaRespErr}; + use std::num::NonZeroU32; + use time::Duration; + + async fn start_on_mocked_sink() -> (MockCluster<'static, DefaultProducerContext>, KafkaSink) { + let registry = HealthRegistry::new("liveness"); + let handle = registry + .register("one".to_string(), Duration::seconds(30)) + .await; + let limiter = Some(OverflowLimiter::new( + NonZeroU32::new(10).unwrap(), + NonZeroU32::new(10).unwrap(), + None, + )); + let cluster = MockCluster::new(1).expect("failed to create mock brokers"); + let config = config::KafkaConfig { + kafka_producer_linger_ms: 0, + kafka_producer_queue_mib: 50, + kafka_message_timeout_ms: 500, + kafka_compression_codec: "none".to_string(), + kafka_hosts: cluster.bootstrap_servers(), + kafka_topic: "events_plugin_ingestion".to_string(), + kafka_historical_topic: "events_plugin_ingestion_historical".to_string(), + kafka_tls: false, + }; + let sink = KafkaSink::new(config, handle, limiter).expect("failed to create sink"); + (cluster, sink) + } + + #[tokio::test] + async fn kafka_sink_error_handling() { + // Uses a mocked Kafka broker that allows injecting write errors, to check error handling. + // We test different cases in a single test to amortize the startup cost of the producer. + + let (cluster, sink) = start_on_mocked_sink().await; + let event: ProcessedEvent = ProcessedEvent { + data_type: DataType::AnalyticsMain, + uuid: uuid_v7(), + distinct_id: "id1".to_string(), + ip: "".to_string(), + data: "".to_string(), + now: "".to_string(), + sent_at: None, + token: "token1".to_string(), + }; + + // Wait for producer to be healthy, to keep kafka_message_timeout_ms short and tests faster + for _ in 0..20 { + if sink.send(event.clone()).await.is_ok() { + break; + } + } + + // Send events to confirm happy path + sink.send(event.clone()) + .await + .expect("failed to send one initial event"); + sink.send_batch(vec![event.clone(), event.clone()]) + .await + .expect("failed to send initial event batch"); + + // Producer should reject a 2MB message, twice the default `message.max.bytes` + let big_data = rand::thread_rng() + .sample_iter(Alphanumeric) + .take(2_000_000) + .map(char::from) + .collect(); + let big_event: ProcessedEvent = ProcessedEvent { + data_type: DataType::AnalyticsMain, + uuid: uuid_v7(), + distinct_id: "id1".to_string(), + ip: "".to_string(), + data: big_data, + now: "".to_string(), + sent_at: None, + token: "token1".to_string(), + }; + match sink.send(big_event).await { + Err(CaptureError::EventTooBig) => {} // Expected + Err(err) => panic!("wrong error code {}", err), + Ok(()) => panic!("should have errored"), + }; + + // Simulate unretriable errors + cluster.clear_request_errors(RDKafkaApiKey::Produce); + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_MSG_SIZE_TOO_LARGE; 1]; + cluster.request_errors(RDKafkaApiKey::Produce, &err); + match sink.send(event.clone()).await { + Err(CaptureError::EventTooBig) => {} // Expected + Err(err) => panic!("wrong error code {}", err), + Ok(()) => panic!("should have errored"), + }; + cluster.clear_request_errors(RDKafkaApiKey::Produce); + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_INVALID_PARTITIONS; 1]; + cluster.request_errors(RDKafkaApiKey::Produce, &err); + match sink.send_batch(vec![event.clone(), event.clone()]).await { + Err(CaptureError::RetryableSinkError) => {} // Expected + Err(err) => panic!("wrong error code {}", err), + Ok(()) => panic!("should have errored"), + }; + + // Simulate transient errors, messages should go through OK + cluster.clear_request_errors(RDKafkaApiKey::Produce); + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_BROKER_NOT_AVAILABLE; 2]; + cluster.request_errors(RDKafkaApiKey::Produce, &err); + sink.send(event.clone()) + .await + .expect("failed to send one event after recovery"); + cluster.clear_request_errors(RDKafkaApiKey::Produce); + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_BROKER_NOT_AVAILABLE; 2]; + cluster.request_errors(RDKafkaApiKey::Produce, &err); + sink.send_batch(vec![event.clone(), event.clone()]) + .await + .expect("failed to send event batch after recovery"); + + // Timeout on a sustained transient error + cluster.clear_request_errors(RDKafkaApiKey::Produce); + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_BROKER_NOT_AVAILABLE; 50]; + cluster.request_errors(RDKafkaApiKey::Produce, &err); + match sink.send(event.clone()).await { + Err(CaptureError::RetryableSinkError) => {} // Expected + Err(err) => panic!("wrong error code {}", err), + Ok(()) => panic!("should have errored"), + }; + match sink.send_batch(vec![event.clone(), event.clone()]).await { + Err(CaptureError::RetryableSinkError) => {} // Expected + Err(err) => panic!("wrong error code {}", err), + Ok(()) => panic!("should have errored"), + }; + } +} diff --git a/rust/capture/src/sinks/mod.rs b/rust/capture/src/sinks/mod.rs new file mode 100644 index 0000000000000..bedbcbc8df69d --- /dev/null +++ b/rust/capture/src/sinks/mod.rs @@ -0,0 +1,12 @@ +use async_trait::async_trait; + +use crate::api::{CaptureError, ProcessedEvent}; + +pub mod kafka; +pub mod print; + +#[async_trait] +pub trait Event { + async fn send(&self, event: ProcessedEvent) -> Result<(), CaptureError>; + async fn send_batch(&self, events: Vec) -> Result<(), CaptureError>; +} diff --git a/rust/capture/src/sinks/print.rs b/rust/capture/src/sinks/print.rs new file mode 100644 index 0000000000000..7845a3d039b56 --- /dev/null +++ b/rust/capture/src/sinks/print.rs @@ -0,0 +1,30 @@ +use async_trait::async_trait; +use metrics::{counter, histogram}; +use tracing::log::info; + +use crate::api::{CaptureError, ProcessedEvent}; +use crate::sinks::Event; + +pub struct PrintSink {} + +#[async_trait] +impl Event for PrintSink { + async fn send(&self, event: ProcessedEvent) -> Result<(), CaptureError> { + info!("single event: {:?}", event); + counter!("capture_events_ingested_total").increment(1); + + Ok(()) + } + async fn send_batch(&self, events: Vec) -> Result<(), CaptureError> { + let span = tracing::span!(tracing::Level::INFO, "batch of events"); + let _enter = span.enter(); + + histogram!("capture_event_batch_size").record(events.len() as f64); + counter!("capture_events_ingested_total").increment(events.len() as u64); + for event in events { + info!("event: {:?}", event); + } + + Ok(()) + } +} diff --git a/rust/capture/src/time.rs b/rust/capture/src/time.rs new file mode 100644 index 0000000000000..3cfed322d5338 --- /dev/null +++ b/rust/capture/src/time.rs @@ -0,0 +1,16 @@ +pub trait TimeSource { + // Return an ISO timestamp + fn current_time(&self) -> String; +} + +#[derive(Clone)] +pub struct SystemTime {} + +impl TimeSource for SystemTime { + fn current_time(&self) -> String { + let time = time::OffsetDateTime::now_utc(); + + time.format(&time::format_description::well_known::Rfc3339) + .expect("failed to format timestamp") + } +} diff --git a/rust/capture/src/token.rs b/rust/capture/src/token.rs new file mode 100644 index 0000000000000..7924cc9511485 --- /dev/null +++ b/rust/capture/src/token.rs @@ -0,0 +1,99 @@ +use std::error::Error; +use std::fmt::Display; + +/// Validate that a token is the correct shape + +#[derive(Debug, PartialEq)] +pub enum InvalidTokenReason { + Empty, + + // ignoring for now, as serde and the type system save us but we need to error properly + // IsNotString, + TooLong, + NotAscii, + PersonalApiKey, +} + +impl InvalidTokenReason { + pub fn reason(&self) -> &str { + match *self { + Self::Empty => "empty", + Self::NotAscii => "not_ascii", + // Self::IsNotString => "not_string", + Self::TooLong => "too_long", + Self::PersonalApiKey => "personal_api_key", + } + } +} + +impl Display for InvalidTokenReason { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.reason()) + } +} + +impl Error for InvalidTokenReason { + fn description(&self) -> &str { + self.reason() + } +} + +/// Check if a token is the right shape. It may not actually be a valid token! We don't validate +/// these at the edge yet. +pub fn validate_token(token: &str) -> Result<(), InvalidTokenReason> { + if token.is_empty() { + return Err(InvalidTokenReason::Empty); + } + + if token.len() > 64 { + return Err(InvalidTokenReason::TooLong); + } + + if !token.is_ascii() { + return Err(InvalidTokenReason::NotAscii); + } + + if token.starts_with("phx_") { + return Err(InvalidTokenReason::PersonalApiKey); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::token::{validate_token, InvalidTokenReason}; + + #[test] + fn blocks_empty_tokens() { + let valid = validate_token(""); + + assert!(valid.is_err()); + assert_eq!(valid.unwrap_err(), InvalidTokenReason::Empty); + } + + #[test] + fn blocks_too_long_tokens() { + let valid = + validate_token("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); + + assert!(valid.is_err()); + assert_eq!(valid.unwrap_err(), InvalidTokenReason::TooLong); + } + + #[test] + fn blocks_invalid_ascii() { + let valid = validate_token("🦀"); + + assert!(valid.is_err()); + assert_eq!(valid.unwrap_err(), InvalidTokenReason::NotAscii); + } + + #[test] + fn blocks_personal_api_key() { + let valid = validate_token("phx_hellothere"); + + assert!(valid.is_err()); + assert_eq!(valid.unwrap_err(), InvalidTokenReason::PersonalApiKey); + } +} diff --git a/rust/capture/src/utils.rs b/rust/capture/src/utils.rs new file mode 100644 index 0000000000000..c5a95fbe2481f --- /dev/null +++ b/rust/capture/src/utils.rs @@ -0,0 +1,38 @@ +use rand::RngCore; +use uuid::Uuid; + +pub fn random_bytes() -> [u8; N] { + let mut ret = [0u8; N]; + rand::thread_rng().fill_bytes(&mut ret); + ret +} + +// basically just ripped from the uuid crate. they have it as unstable, but we can use it fine. +const fn encode_unix_timestamp_millis(millis: u64, random_bytes: &[u8; 10]) -> Uuid { + let millis_high = ((millis >> 16) & 0xFFFF_FFFF) as u32; + let millis_low = (millis & 0xFFFF) as u16; + + let random_and_version = + (random_bytes[0] as u16 | ((random_bytes[1] as u16) << 8) & 0x0FFF) | (0x7 << 12); + + let mut d4 = [0; 8]; + + d4[0] = (random_bytes[2] & 0x3F) | 0x80; + d4[1] = random_bytes[3]; + d4[2] = random_bytes[4]; + d4[3] = random_bytes[5]; + d4[4] = random_bytes[6]; + d4[5] = random_bytes[7]; + d4[6] = random_bytes[8]; + d4[7] = random_bytes[9]; + + Uuid::from_fields(millis_high, millis_low, random_and_version, &d4) +} + +pub fn uuid_v7() -> Uuid { + let bytes = random_bytes(); + let now = time::OffsetDateTime::now_utc(); + let now_millis: u64 = now.unix_timestamp() as u64 * 1_000 + now.millisecond() as u64; + + encode_unix_timestamp_millis(now_millis, &bytes) +} diff --git a/rust/capture/src/v0_endpoint.rs b/rust/capture/src/v0_endpoint.rs new file mode 100644 index 0000000000000..ff4b90f2662e2 --- /dev/null +++ b/rust/capture/src/v0_endpoint.rs @@ -0,0 +1,225 @@ +use std::ops::Deref; +use std::sync::Arc; + +use axum::{debug_handler, Json}; +use bytes::Bytes; +// TODO: stream this instead +use axum::extract::{MatchedPath, Query, State}; +use axum::http::{HeaderMap, Method}; +use axum_client_ip::InsecureClientIp; +use base64::Engine; +use metrics::counter; +use tracing::instrument; + +use crate::limiters::billing::QuotaResource; +use crate::prometheus::report_dropped_events; +use crate::v0_request::{Compression, ProcessingContext, RawRequest}; +use crate::{ + api::{CaptureError, CaptureResponse, CaptureResponseCode, DataType, ProcessedEvent}, + router, sinks, + utils::uuid_v7, + v0_request::{EventFormData, EventQuery, RawEvent}, +}; + +/// Flexible endpoint that targets wide compatibility with the wide range of requests +/// currently processed by posthog-events (analytics events capture). Replay is out +/// of scope and should be processed on a separate endpoint. +/// +/// Because it must accommodate several shapes, it is inefficient in places. A v1 +/// endpoint should be created, that only accepts the BatchedRequest payload shape. + +#[instrument( + skip_all, + fields( + path, + token, + batch_size, + user_agent, + content_encoding, + content_type, + version, + compression, + historical_migration + ) +)] +#[debug_handler] +pub async fn event( + state: State, + InsecureClientIp(ip): InsecureClientIp, + meta: Query, + headers: HeaderMap, + method: Method, + path: MatchedPath, + body: Bytes, +) -> Result, CaptureError> { + let user_agent = headers + .get("user-agent") + .map_or("unknown", |v| v.to_str().unwrap_or("unknown")); + let content_encoding = headers + .get("content-encoding") + .map_or("unknown", |v| v.to_str().unwrap_or("unknown")); + + let comp = match meta.compression { + None => String::from("unknown"), + Some(Compression::Gzip) => String::from("gzip"), + Some(Compression::Unsupported) => String::from("unsupported"), + }; + + tracing::Span::current().record("user_agent", user_agent); + tracing::Span::current().record("content_encoding", content_encoding); + tracing::Span::current().record("version", meta.lib_version.clone()); + tracing::Span::current().record("compression", comp.as_str()); + tracing::Span::current().record("method", method.as_str()); + tracing::Span::current().record("path", path.as_str().trim_end_matches('/')); + + let request = match headers + .get("content-type") + .map_or("", |v| v.to_str().unwrap_or("")) + { + "application/x-www-form-urlencoded" => { + tracing::Span::current().record("content_type", "application/x-www-form-urlencoded"); + + let input: EventFormData = serde_urlencoded::from_bytes(body.deref()).map_err(|e| { + tracing::error!("failed to decode body: {}", e); + CaptureError::RequestDecodingError(String::from("invalid form data")) + })?; + let payload = base64::engine::general_purpose::STANDARD + .decode(input.data) + .map_err(|e| { + tracing::error!("failed to decode form data: {}", e); + CaptureError::RequestDecodingError(String::from("missing data field")) + })?; + RawRequest::from_bytes(payload.into()) + } + ct => { + tracing::Span::current().record("content_type", ct); + + RawRequest::from_bytes(body) + } + }?; + + let sent_at = request.sent_at().or(meta.sent_at()); + let token = match request.extract_and_verify_token() { + Ok(token) => token, + Err(err) => { + report_dropped_events("token_shape_invalid", request.events().len() as u64); + return Err(err); + } + }; + let historical_migration = request.historical_migration(); + let events = request.events(); // Takes ownership of request + + tracing::Span::current().record("token", &token); + tracing::Span::current().record("historical_migration", historical_migration); + tracing::Span::current().record("batch_size", events.len()); + + if events.is_empty() { + return Err(CaptureError::EmptyBatch); + } + + counter!("capture_events_received_total").increment(events.len() as u64); + + let context = ProcessingContext { + lib_version: meta.lib_version.clone(), + sent_at, + token, + now: state.timesource.current_time(), + client_ip: ip.to_string(), + historical_migration, + }; + + let billing_limited = state + .billing + .is_limited(context.token.as_str(), QuotaResource::Events) + .await; + + if billing_limited { + report_dropped_events("over_quota", events.len() as u64); + + // for v0 we want to just return ok 🙃 + // this is because the clients are pretty dumb and will just retry over and over and + // over... + // + // for v1, we'll return a meaningful error code and error, so that the clients can do + // something meaningful with that error + return Ok(Json(CaptureResponse { + status: CaptureResponseCode::Ok, + })); + } + + tracing::debug!(context=?context, events=?events, "decoded request"); + + if let Err(err) = process_events(state.sink.clone(), &events, &context).await { + let cause = match err { + // TODO: automate this with a macro + CaptureError::EmptyDistinctId => "empty_distinct_id", + CaptureError::MissingDistinctId => "missing_distinct_id", + CaptureError::MissingEventName => "missing_event_name", + _ => "process_events_error", + }; + report_dropped_events(cause, events.len() as u64); + tracing::log::warn!("rejected invalid payload: {}", err); + return Err(err); + } + + Ok(Json(CaptureResponse { + status: CaptureResponseCode::Ok, + })) +} + +pub async fn options() -> Result, CaptureError> { + Ok(Json(CaptureResponse { + status: CaptureResponseCode::Ok, + })) +} + +#[instrument(skip_all)] +pub fn process_single_event( + event: &RawEvent, + context: &ProcessingContext, +) -> Result { + if event.event.is_empty() { + return Err(CaptureError::MissingEventName); + } + + let data_type = match context.historical_migration { + true => DataType::AnalyticsHistorical, + false => DataType::AnalyticsMain, + }; + + let data = serde_json::to_string(&event).map_err(|e| { + tracing::error!("failed to encode data field: {}", e); + CaptureError::NonRetryableSinkError + })?; + + Ok(ProcessedEvent { + data_type, + uuid: event.uuid.unwrap_or_else(uuid_v7), + distinct_id: event.extract_distinct_id()?, + ip: context.client_ip.clone(), + data, + now: context.now.clone(), + sent_at: context.sent_at, + token: context.token.clone(), + }) +} + +#[instrument(skip_all, fields(events = events.len()))] +pub async fn process_events<'a>( + sink: Arc, + events: &'a [RawEvent], + context: &'a ProcessingContext, +) -> Result<(), CaptureError> { + let events: Vec = events + .iter() + .map(|e| process_single_event(e, context)) + .collect::, CaptureError>>()?; + + tracing::debug!(events=?events, "processed {} events", events.len()); + + if events.len() == 1 { + sink.send(events[0].clone()).await + } else { + sink.send_batch(events).await + } +} diff --git a/rust/capture/src/v0_request.rs b/rust/capture/src/v0_request.rs new file mode 100644 index 0000000000000..c0d5f36d3577f --- /dev/null +++ b/rust/capture/src/v0_request.rs @@ -0,0 +1,434 @@ +use std::collections::{HashMap, HashSet}; +use std::io::prelude::*; + +use bytes::{Buf, Bytes}; +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::format_description::well_known::Iso8601; +use time::OffsetDateTime; +use tracing::instrument; +use uuid::Uuid; + +use crate::api::CaptureError; +use crate::token::validate_token; + +#[derive(Deserialize, Default)] +pub enum Compression { + #[default] + Unsupported, + + #[serde(rename = "gzip", alias = "gzip-js")] + Gzip, +} + +#[derive(Deserialize, Default)] +pub struct EventQuery { + pub compression: Option, + + #[serde(alias = "ver")] + pub lib_version: Option, + + #[serde(alias = "_")] + sent_at: Option, +} + +impl EventQuery { + /// Returns the parsed value of the sent_at timestamp if present in the query params. + /// We only support the format sent by recent posthog-js versions, in milliseconds integer. + /// Values in seconds integer (older SDKs will be ignored). + pub fn sent_at(&self) -> Option { + if let Some(value) = self.sent_at { + let value_nanos: i128 = i128::from(value) * 1_000_000; // Assuming the value is in milliseconds, latest posthog-js releases + if let Ok(sent_at) = OffsetDateTime::from_unix_timestamp_nanos(value_nanos) { + if sent_at.year() > 2020 { + // Could be lower if the input is in seconds + return Some(sent_at); + } + } + } + None + } +} + +#[derive(Debug, Deserialize)] +pub struct EventFormData { + pub data: String, +} + +#[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 + 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>, +} + +static GZIP_MAGIC_NUMBERS: [u8; 3] = [0x1f, 0x8b, 8]; + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum RawRequest { + /// Array of events (posthog-js) + Array(Vec), + /// Batched events (/batch) + Batch(BatchedRequest), + /// Single event (/capture) + One(Box), +} + +#[derive(Deserialize)] +pub struct BatchedRequest { + #[serde(alias = "api_key")] + pub token: String, + pub historical_migration: Option, + pub sent_at: Option, + pub batch: Vec, +} + +impl RawRequest { + /// Takes a request payload and tries to decompress and unmarshall it. + /// While posthog-js sends a compression query param, a sizable portion of requests + /// fail due to it being missing when the body is compressed. + /// Instead of trusting the parameter, we peek at the payload's first three bytes to + /// detect gzip, fallback to uncompressed utf8 otherwise. + #[instrument(skip_all)] + pub fn from_bytes(bytes: Bytes) -> Result { + tracing::debug!(len = bytes.len(), "decoding new event"); + + let payload = if bytes.starts_with(&GZIP_MAGIC_NUMBERS) { + let mut d = GzDecoder::new(bytes.reader()); + let mut s = String::new(); + d.read_to_string(&mut s).map_err(|e| { + tracing::error!("failed to decode gzip: {}", e); + CaptureError::RequestDecodingError(String::from("invalid gzip data")) + })?; + s + } else { + String::from_utf8(bytes.into()).map_err(|e| { + tracing::error!("failed to decode body: {}", e); + CaptureError::RequestDecodingError(String::from("invalid body encoding")) + })? + }; + + tracing::debug!(json = payload, "decoded event data"); + Ok(serde_json::from_str::(&payload)?) + } + + pub fn events(self) -> Vec { + match self { + RawRequest::Array(events) => events, + RawRequest::One(event) => vec![*event], + RawRequest::Batch(req) => req.batch, + } + } + + pub fn extract_and_verify_token(&self) -> Result { + let token = match self { + RawRequest::Batch(req) => req.token.to_string(), + RawRequest::One(event) => event.extract_token().ok_or(CaptureError::NoTokenError)?, + RawRequest::Array(events) => extract_token(events)?, + }; + validate_token(&token)?; + Ok(token) + } + + pub fn historical_migration(&self) -> bool { + match self { + RawRequest::Batch(req) => req.historical_migration.unwrap_or_default(), + _ => false, + } + } + + pub fn sent_at(&self) -> Option { + if let RawRequest::Batch(req) = &self { + if let Some(value) = &req.sent_at { + if let Ok(parsed) = OffsetDateTime::parse(value, &Iso8601::DEFAULT) { + return Some(parsed); + } + } + } + None + } +} + +#[instrument(skip_all, fields(events = events.len()))] +pub fn extract_token(events: &[RawEvent]) -> Result { + let distinct_tokens: HashSet> = HashSet::from_iter( + events + .iter() + .map(RawEvent::extract_token) + .filter(Option::is_some), + ); + + return match distinct_tokens.len() { + 0 => Err(CaptureError::NoTokenError), + 1 => match distinct_tokens.iter().last() { + Some(Some(token)) => Ok(token.clone()), + _ => Err(CaptureError::NoTokenError), + }, + _ => Err(CaptureError::MultipleTokensError), + }; +} + +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, + pub sent_at: Option, + pub token: String, + pub now: String, + pub client_ip: String, + pub historical_migration: bool, +} + +#[cfg(test)] +mod tests { + use crate::token::InvalidTokenReason; + use base64::Engine as _; + use bytes::Bytes; + use rand::distributions::Alphanumeric; + use rand::Rng; + use serde_json::json; + + use super::CaptureError; + use super::RawRequest; + + #[test] + fn decode_uncompressed_raw_event() { + let base64_payload = "ewogICAgImRpc3RpbmN0X2lkIjogIm15X2lkMSIsCiAgICAiZXZlbnQiOiAibXlfZXZlbnQxIiwKICAgICJwcm9wZXJ0aWVzIjogewogICAgICAgICIkZGV2aWNlX3R5cGUiOiAiRGVza3RvcCIKICAgIH0sCiAgICAiYXBpX2tleSI6ICJteV90b2tlbjEiCn0K"; + let compressed_bytes = Bytes::from( + base64::engine::general_purpose::STANDARD + .decode(base64_payload) + .expect("payload is not base64"), + ); + + let events = RawRequest::from_bytes(compressed_bytes) + .expect("failed to parse") + .events(); + assert_eq!(1, events.len()); + assert_eq!(Some("my_token1".to_string()), events[0].extract_token()); + assert_eq!("my_event1".to_string(), events[0].event); + assert_eq!( + "my_id1".to_string(), + events[0] + .extract_distinct_id() + .expect("cannot find distinct_id") + ); + } + #[test] + fn decode_gzipped_raw_event() { + let base64_payload = "H4sIADQSbmUCAz2MsQqAMAxE936FBEcnR2f/o4i9IRTb0AahiP9urcVMx3t3ucxQjxxn5bCrZUfLQEepYabpkzgRtOOWfyMpCpIyctVXY42PDifvsFoE73BF9hqFWuPu403YepT+WKNHmMnc5gENoFu2kwAAAA=="; + let compressed_bytes = Bytes::from( + base64::engine::general_purpose::STANDARD + .decode(base64_payload) + .expect("payload is not base64"), + ); + + let events = RawRequest::from_bytes(compressed_bytes) + .expect("failed to parse") + .events(); + assert_eq!(1, events.len()); + assert_eq!(Some("my_token2".to_string()), events[0].extract_token()); + assert_eq!("my_event2".to_string(), events[0].event); + assert_eq!( + "my_id2".to_string(), + events[0] + .extract_distinct_id() + .expect("cannot find distinct_id") + ); + } + + #[test] + fn extract_distinct_id() { + let parse_and_extract = |input: &'static str| -> Result { + let parsed = RawRequest::from_bytes(input.into()) + .expect("failed to parse") + .events(); + parsed[0].extract_distinct_id() + }; + // Return MissingDistinctId if not found + assert!(matches!( + parse_and_extract(r#"{"event": "e"}"#), + Err(CaptureError::MissingDistinctId) + )); + // Return MissingDistinctId if null + assert!(matches!( + parse_and_extract(r#"{"event": "e", "distinct_id": null}"#), + Err(CaptureError::MissingDistinctId) + )); + // Return EmptyDistinctId if empty string + assert!(matches!( + parse_and_extract(r#"{"event": "e", "distinct_id": ""}"#), + Err(CaptureError::EmptyDistinctId) + )); + + let assert_extracted_id = |input: &'static str, expected: &str| { + let id = parse_and_extract(input).expect("failed to extract"); + assert_eq!(id, expected); + }; + // Happy path: toplevel field present + assert_extracted_id(r#"{"event": "e", "distinct_id": "myid"}"#, "myid"); + assert_extracted_id(r#"{"event": "e", "$distinct_id": "23"}"#, "23"); + + // Sourced from properties if not present in toplevel field, but toplevel wins if both present + assert_extracted_id( + r#"{"event": "e", "properties":{"distinct_id": "myid"}}"#, + "myid", + ); + assert_extracted_id( + r#"{"event": "e", "distinct_id": 23, "properties":{"distinct_id": "myid"}}"#, + "23", + ); + + // Numbers are stringified + assert_extracted_id(r#"{"event": "e", "distinct_id": 23}"#, "23"); + assert_extracted_id(r#"{"event": "e", "distinct_id": 23.4}"#, "23.4"); + + // Containers are stringified + assert_extracted_id( + r#"{"event": "e", "distinct_id": ["a", "b"]}"#, + r#"["a","b"]"#, + ); + assert_extracted_id( + r#"{"event": "e", "distinct_id": {"string": "a", "number": 3}}"#, + r#"{"number":3,"string":"a"}"#, + ); + } + + #[test] + fn extract_distinct_id_trims_to_200_chars() { + let distinct_id: String = rand::thread_rng() + .sample_iter(Alphanumeric) + .take(222) + .map(char::from) + .collect(); + let (expected_distinct_id, _) = distinct_id.split_at(200); // works because ascii chars only + let input = json!([{ + "token": "mytoken", + "event": "myevent", + "distinct_id": distinct_id + }]); + + let parsed = RawRequest::from_bytes(input.to_string().into()) + .expect("failed to parse") + .events(); + assert_eq!( + parsed[0].extract_distinct_id().expect("failed to extract"), + expected_distinct_id + ); + } + + #[test] + fn extract_and_verify_token() { + let parse_and_extract = |input: &'static str| -> Result { + RawRequest::from_bytes(input.into()) + .expect("failed to parse") + .extract_and_verify_token() + }; + + let assert_extracted_token = |input: &'static str, expected: &str| { + let id = parse_and_extract(input).expect("failed to extract"); + assert_eq!(id, expected); + }; + + // Return NoTokenError if not found + assert!(matches!( + parse_and_extract(r#"{"event": "e"}"#), + Err(CaptureError::NoTokenError) + )); + + // Return TokenValidationError if token empty + assert!(matches!( + parse_and_extract(r#"{"api_key": "", "batch":[{"event": "e"}]}"#), + Err(CaptureError::TokenValidationError( + InvalidTokenReason::Empty + )) + )); + + // Return TokenValidationError if personal apikey + assert!(matches!( + parse_and_extract(r#"[{"event": "e", "token": "phx_hellothere"}]"#), + Err(CaptureError::TokenValidationError( + InvalidTokenReason::PersonalApiKey + )) + )); + + // Return MultipleTokensError if tokens don't match in array + assert!(matches!( + parse_and_extract( + r#"[{"event": "e", "token": "token1"},{"event": "e", "token": "token2"}]"# + ), + Err(CaptureError::MultipleTokensError) + )); + + // Return token from array if consistent + assert_extracted_token( + r#"[{"event":"e","token":"token1"},{"event":"e","token":"token1"}]"#, + "token1", + ); + + // Return token from batch if present + assert_extracted_token( + r#"{"batch":[{"event":"e","token":"token1"}],"api_key":"batched"}"#, + "batched", + ); + + // Return token from single event if present + assert_extracted_token(r#"{"event":"e","$token":"single_token"}"#, "single_token"); + assert_extracted_token(r#"{"event":"e","api_key":"single_token"}"#, "single_token"); + } +} diff --git a/rust/capture/tests/common.rs b/rust/capture/tests/common.rs new file mode 100644 index 0000000000000..868b27c120a7f --- /dev/null +++ b/rust/capture/tests/common.rs @@ -0,0 +1,216 @@ +#![allow(dead_code)] + +use std::default::Default; +use std::net::SocketAddr; +use std::num::NonZeroU32; +use std::str::FromStr; +use std::string::ToString; +use std::sync::{Arc, Once}; +use std::time::Duration; + +use anyhow::bail; +use once_cell::sync::Lazy; +use rand::distributions::Alphanumeric; +use rand::Rng; +use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, TopicReplication}; +use rdkafka::config::{ClientConfig, FromClientConfig}; +use rdkafka::consumer::{BaseConsumer, Consumer}; +use rdkafka::util::Timeout; +use rdkafka::{Message, TopicPartitionList}; +use tokio::net::TcpListener; +use tokio::sync::Notify; +use tokio::time::timeout; +use tracing::{debug, warn}; + +use capture::config::{Config, KafkaConfig}; +use capture::server::serve; + +pub static DEFAULT_CONFIG: Lazy = Lazy::new(|| Config { + print_sink: false, + address: SocketAddr::from_str("127.0.0.1:0").unwrap(), + redis_url: "redis://localhost:6379/".to_string(), + overflow_enabled: false, + overflow_burst_limit: NonZeroU32::new(5).unwrap(), + overflow_per_second_limit: NonZeroU32::new(10).unwrap(), + overflow_forced_keys: None, + kafka: KafkaConfig { + kafka_producer_linger_ms: 0, // Send messages as soon as possible + kafka_producer_queue_mib: 10, + kafka_message_timeout_ms: 10000, // 10s, ACKs can be slow on low volumes, should be tuned + kafka_compression_codec: "none".to_string(), + kafka_hosts: "kafka:9092".to_string(), + kafka_topic: "events_plugin_ingestion".to_string(), + kafka_historical_topic: "events_plugin_ingestion_historical".to_string(), + kafka_tls: false, + }, + otel_url: None, + otel_sampling_rate: 0.0, + otel_service_name: "capture-testing".to_string(), + export_prometheus: false, +}); + +static TRACING_INIT: Once = Once::new(); +pub fn setup_tracing() { + TRACING_INIT.call_once(|| { + tracing_subscriber::fmt() + .with_writer(tracing_subscriber::fmt::TestWriter::new()) + .init() + }); +} +pub struct ServerHandle { + pub addr: SocketAddr, + shutdown: Arc, +} + +impl ServerHandle { + pub async fn for_topics(main: &EphemeralTopic, historical: &EphemeralTopic) -> Self { + let mut config = DEFAULT_CONFIG.clone(); + config.kafka.kafka_topic = main.topic_name().to_string(); + config.kafka.kafka_historical_topic = historical.topic_name().to_string(); + Self::for_config(config).await + } + pub async fn for_config(config: Config) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let notify = Arc::new(Notify::new()); + let shutdown = notify.clone(); + + tokio::spawn(async move { + serve(config, listener, async move { notify.notified().await }).await + }); + Self { addr, shutdown } + } + + pub async fn capture_events>(&self, body: T) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("http://{:?}/i/v0/e", self.addr)) + .body(body) + .send() + .await + .expect("failed to send request") + } +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.shutdown.notify_one() + } +} + +pub struct EphemeralTopic { + consumer: BaseConsumer, + read_timeout: Timeout, + topic_name: String, +} + +impl EphemeralTopic { + pub async fn new() -> Self { + let mut config = ClientConfig::new(); + config.set("group.id", "capture_integration_tests"); + config.set( + "bootstrap.servers", + DEFAULT_CONFIG.kafka.kafka_hosts.clone(), + ); + config.set("debug", "all"); + + // TODO: check for name collision? + let topic_name = random_string("events_", 16); + let admin = AdminClient::from_config(&config).expect("failed to create admin client"); + admin + .create_topics( + &[NewTopic { + name: &topic_name, + num_partitions: 1, + replication: TopicReplication::Fixed(1), + config: vec![], + }], + &AdminOptions::default(), + ) + .await + .expect("failed to create topic"); + + let consumer: BaseConsumer = config.create().expect("failed to create consumer"); + let mut assignment = TopicPartitionList::new(); + assignment.add_partition(&topic_name, 0); + consumer + .assign(&assignment) + .expect("failed to assign topic"); + + Self { + consumer, + read_timeout: Timeout::After(Duration::from_secs(5)), + topic_name, + } + } + + pub fn next_event(&self) -> anyhow::Result { + match self.consumer.poll(self.read_timeout) { + Some(Ok(message)) => { + let body = message.payload().expect("empty kafka message"); + let event = serde_json::from_slice(body)?; + Ok(event) + } + Some(Err(err)) => bail!("kafka read error: {}", err), + None => bail!("kafka read timeout"), + } + } + pub fn next_message_key(&self) -> anyhow::Result> { + match self.consumer.poll(self.read_timeout) { + Some(Ok(message)) => { + let key = message.key(); + + if let Some(key) = key { + let key = std::str::from_utf8(key)?; + let key = String::from_str(key)?; + + Ok(Some(key)) + } else { + Ok(None) + } + } + Some(Err(err)) => bail!("kafka read error: {}", err), + None => bail!("kafka read timeout"), + } + } + + pub fn topic_name(&self) -> &str { + &self.topic_name + } +} + +impl Drop for EphemeralTopic { + fn drop(&mut self) { + debug!("dropping EphemeralTopic {}...", self.topic_name); + self.consumer.unsubscribe(); + match futures::executor::block_on(timeout( + Duration::from_secs(10), + delete_topic(self.topic_name.clone()), + )) { + Ok(_) => debug!("dropped topic"), + Err(err) => warn!("failed to drop topic: {}", err), + } + } +} + +async fn delete_topic(topic: String) { + let mut config = ClientConfig::new(); + config.set( + "bootstrap.servers", + DEFAULT_CONFIG.kafka.kafka_hosts.clone(), + ); + let admin = AdminClient::from_config(&config).expect("failed to create admin client"); + admin + .delete_topics(&[&topic], &AdminOptions::default()) + .await + .expect("failed to delete topic"); +} + +pub fn random_string(prefix: &str, length: usize) -> String { + let suffix: String = rand::thread_rng() + .sample_iter(Alphanumeric) + .take(length) + .map(char::from) + .collect(); + format!("{}_{}", prefix, suffix) +} diff --git a/rust/capture/tests/django_compat.rs b/rust/capture/tests/django_compat.rs new file mode 100644 index 0000000000000..87b0a1b269256 --- /dev/null +++ b/rust/capture/tests/django_compat.rs @@ -0,0 +1,227 @@ +use assert_json_diff::assert_json_matches_no_panic; +use async_trait::async_trait; +use axum::http::StatusCode; +use axum_test_helper::TestClient; +use base64::engine::general_purpose; +use base64::Engine; +use capture::api::{CaptureError, CaptureResponse, CaptureResponseCode, DataType, ProcessedEvent}; +use capture::limiters::billing::BillingLimiter; +use capture::redis::MockRedisClient; +use capture::router::router; +use capture::sinks::Event; +use capture::time::TimeSource; +use health::HealthRegistry; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::sync::{Arc, Mutex}; +use time::format_description::well_known::{Iso8601, Rfc3339}; +use time::{Duration, OffsetDateTime}; + +#[derive(Debug, Deserialize)] +struct RequestDump { + path: String, + method: String, + content_encoding: String, + content_type: String, + ip: String, + now: String, + body: String, + output: Vec, + #[serde(default)] // default = false + historical_migration: bool, +} + +static REQUESTS_DUMP_FILE_NAME: &str = "tests/requests_dump.jsonl"; + +#[derive(Clone)] +pub struct FixedTime { + pub time: String, +} + +impl TimeSource for FixedTime { + fn current_time(&self) -> String { + self.time.to_string() + } +} + +#[derive(Clone, Default)] +struct MemorySink { + events: Arc>>, +} + +impl MemorySink { + fn len(&self) -> usize { + self.events.lock().unwrap().len() + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +#[async_trait] +impl Event for MemorySink { + async fn send(&self, event: ProcessedEvent) -> Result<(), CaptureError> { + self.events.lock().unwrap().push(event); + Ok(()) + } + + async fn send_batch(&self, events: Vec) -> Result<(), CaptureError> { + self.events.lock().unwrap().extend_from_slice(&events); + Ok(()) + } +} + +#[tokio::test] +async fn it_matches_django_capture_behaviour() -> anyhow::Result<()> { + let file = File::open(REQUESTS_DUMP_FILE_NAME)?; + let reader = BufReader::new(file); + let liveness = HealthRegistry::new("dummy"); + + let mut mismatches = 0; + + for (line_number, line_contents) in reader.lines().enumerate() { + let line_contents = line_contents?; + if line_contents.starts_with('#') { + // Skip comment lines + continue; + } + let case: RequestDump = serde_json::from_str(&line_contents)?; + let raw_body = general_purpose::STANDARD.decode(&case.body)?; + assert_eq!( + case.method, "POST", + "update code to handle method {}", + case.method + ); + + let sink = MemorySink::default(); + let timesource = FixedTime { time: case.now }; + + let redis = Arc::new(MockRedisClient::new()); + let billing = BillingLimiter::new(Duration::weeks(1), redis.clone()) + .expect("failed to create billing limiter"); + + let app = router( + timesource, + liveness.clone(), + sink.clone(), + redis, + billing, + false, + ); + + let client = TestClient::new(app); + let mut req = client.post(&case.path).body(raw_body); + if !case.content_encoding.is_empty() { + req = req.header("Content-encoding", case.content_encoding); + } + if !case.content_type.is_empty() { + req = req.header("Content-type", case.content_type); + } + if !case.ip.is_empty() { + req = req.header("X-Forwarded-For", case.ip); + } + + let res = req.send().await; + assert_eq!( + res.status(), + StatusCode::OK, + "line {} rejected: {}", + line_number, + res.text().await + ); + assert_eq!( + Some(CaptureResponse { + status: CaptureResponseCode::Ok + }), + res.json().await + ); + assert_eq!( + sink.len(), + case.output.len(), + "event count mismatch on line {}", + line_number + ); + + for (event_number, (message, expected)) in + sink.events().iter().zip(case.output.iter()).enumerate() + { + // Ensure the data type matches + if case.historical_migration { + assert_eq!(DataType::AnalyticsHistorical, message.data_type); + } else { + assert_eq!(DataType::AnalyticsMain, message.data_type); + } + + // Normalizing the expected event to align with known django->rust inconsistencies + let mut expected = expected.clone(); + if let Some(value) = expected.get_mut("sent_at") { + // Default ISO format is different between python and rust, both are valid + // Parse and re-print the value before comparison + let raw_value = value.as_str().expect("sent_at field is not a string"); + if raw_value.is_empty() { + *value = Value::Null + } else { + let sent_at = + OffsetDateTime::parse(value.as_str().expect("empty"), &Iso8601::DEFAULT) + .expect("failed to parse expected sent_at"); + *value = Value::String(sent_at.format(&Rfc3339)?) + } + } + if let Some(expected_data) = expected.get_mut("data") { + // Data is a serialized JSON map. Unmarshall both and compare them, + // instead of expecting the serialized bytes to be equal + let mut expected_props: Value = + serde_json::from_str(expected_data.as_str().expect("not str"))?; + if let Some(object) = expected_props.as_object_mut() { + // toplevel fields added by posthog-node that plugin-server will ignore anyway + object.remove("type"); + object.remove("library"); + object.remove("library_version"); + } + + let found_props: Value = serde_json::from_str(&message.data)?; + let match_config = + assert_json_diff::Config::new(assert_json_diff::CompareMode::Strict); + if let Err(e) = + assert_json_matches_no_panic(&expected_props, &found_props, match_config) + { + println!( + "data field mismatch at line {}, event {}: {}", + line_number, event_number, e + ); + mismatches += 1; + } else { + *expected_data = json!(&message.data) + } + } + + if let Some(object) = expected.as_object_mut() { + // site_url is unused in the pipeline now, let's drop it + object.remove("site_url"); + + // Remove sent_at field if empty: Rust will skip marshalling it + if let Some(None) = object.get("sent_at").map(|v| v.as_str()) { + object.remove("sent_at"); + } + } + + let match_config = assert_json_diff::Config::new(assert_json_diff::CompareMode::Strict); + if let Err(e) = + assert_json_matches_no_panic(&json!(expected), &json!(message), match_config) + { + println!( + "record mismatch at line {}, event {}: {}", + line_number + 1, + event_number, + e + ); + mismatches += 1; + } + } + } + assert_eq!(0, mismatches, "some events didn't match"); + Ok(()) +} diff --git a/rust/capture/tests/events.rs b/rust/capture/tests/events.rs new file mode 100644 index 0000000000000..7d2defcebd5ff --- /dev/null +++ b/rust/capture/tests/events.rs @@ -0,0 +1,351 @@ +use std::num::NonZeroU32; + +use anyhow::Result; +use assert_json_diff::assert_json_include; +use reqwest::StatusCode; +use serde_json::json; + +use crate::common::*; +mod common; + +#[tokio::test] +async fn it_captures_one_event() -> Result<()> { + setup_tracing(); + let token = random_string("token", 16); + let distinct_id = random_string("id", 16); + + let main_topic = EphemeralTopic::new().await; + let histo_topic = EphemeralTopic::new().await; + let server = ServerHandle::for_topics(&main_topic, &histo_topic).await; + + let event = json!({ + "token": token, + "event": "testing", + "distinct_id": distinct_id + }); + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + let event = main_topic.next_event()?; + assert_json_include!( + actual: event, + expected: json!({ + "token": token, + "distinct_id": distinct_id + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn it_captures_a_posthogjs_array() -> Result<()> { + setup_tracing(); + let token = random_string("token", 16); + let distinct_id1 = random_string("id", 16); + let distinct_id2 = random_string("id", 16); + + let main_topic = EphemeralTopic::new().await; + let histo_topic = EphemeralTopic::new().await; + let server = ServerHandle::for_topics(&main_topic, &histo_topic).await; + + let event = json!([{ + "token": token, + "event": "event1", + "distinct_id": distinct_id1 + },{ + "token": token, + "event": "event2", + "distinct_id": distinct_id2 + }]); + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id1 + }) + ); + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id2 + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn it_captures_a_batch() -> Result<()> { + setup_tracing(); + let token = random_string("token", 16); + let distinct_id1 = random_string("id", 16); + let distinct_id2 = random_string("id", 16); + + let main_topic = EphemeralTopic::new().await; + let histo_topic = EphemeralTopic::new().await; + let server = ServerHandle::for_topics(&main_topic, &histo_topic).await; + + let event = json!({ + "token": token, + "batch": [{ + "event": "event1", + "distinct_id": distinct_id1 + },{ + "event": "event2", + "distinct_id": distinct_id2 + }] + }); + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id1 + }) + ); + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id2 + }) + ); + + Ok(()) +} +#[tokio::test] +async fn it_captures_a_historical_batch() -> Result<()> { + setup_tracing(); + let token = random_string("token", 16); + let distinct_id1 = random_string("id", 16); + let distinct_id2 = random_string("id", 16); + + let main_topic = EphemeralTopic::new().await; + let histo_topic = EphemeralTopic::new().await; + let server = ServerHandle::for_topics(&main_topic, &histo_topic).await; + + let event = json!({ + "token": token, + "historical_migration": true, + "batch": [{ + "event": "event1", + "distinct_id": distinct_id1 + },{ + "event": "event2", + "distinct_id": distinct_id2 + }] + }); + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_json_include!( + actual: histo_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id1 + }) + ); + assert_json_include!( + actual: histo_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id2 + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn it_overflows_events_on_burst() -> Result<()> { + setup_tracing(); + + let token = random_string("token", 16); + let distinct_id = random_string("id", 16); + + let topic = EphemeralTopic::new().await; + + let mut config = DEFAULT_CONFIG.clone(); + config.kafka.kafka_topic = topic.topic_name().to_string(); + config.overflow_enabled = true; + config.overflow_burst_limit = NonZeroU32::new(2).unwrap(); + config.overflow_per_second_limit = NonZeroU32::new(1).unwrap(); + + let server = ServerHandle::for_config(config).await; + + let event = json!([{ + "token": token, + "event": "event1", + "distinct_id": distinct_id + },{ + "token": token, + "event": "event2", + "distinct_id": distinct_id + },{ + "token": token, + "event": "event3", + "distinct_id": distinct_id + }]); + + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + + assert_eq!(topic.next_message_key()?, None); + + Ok(()) +} + +#[tokio::test] +async fn it_does_not_overflow_team_with_different_ids() -> Result<()> { + setup_tracing(); + + let token = random_string("token", 16); + let distinct_id = random_string("id", 16); + let distinct_id2 = random_string("id", 16); + + let topic = EphemeralTopic::new().await; + + let mut config = DEFAULT_CONFIG.clone(); + config.kafka.kafka_topic = topic.topic_name().to_string(); + config.overflow_enabled = true; + config.overflow_burst_limit = NonZeroU32::new(1).unwrap(); + config.overflow_per_second_limit = NonZeroU32::new(1).unwrap(); + + let server = ServerHandle::for_config(config).await; + + let event = json!([{ + "token": token, + "event": "event1", + "distinct_id": distinct_id + },{ + "token": token, + "event": "event2", + "distinct_id": distinct_id2 + }]); + + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id2) + ); + + Ok(()) +} + +#[tokio::test] +async fn it_skips_overflows_when_disabled() -> Result<()> { + setup_tracing(); + + let token = random_string("token", 16); + let distinct_id = random_string("id", 16); + + let topic = EphemeralTopic::new().await; + + let mut config = DEFAULT_CONFIG.clone(); + config.kafka.kafka_topic = topic.topic_name().to_string(); + config.overflow_enabled = false; + config.overflow_burst_limit = NonZeroU32::new(2).unwrap(); + config.overflow_per_second_limit = NonZeroU32::new(1).unwrap(); + + let server = ServerHandle::for_config(config).await; + + let event = json!([{ + "token": token, + "event": "event1", + "distinct_id": distinct_id + },{ + "token": token, + "event": "event2", + "distinct_id": distinct_id + },{ + "token": token, + "event": "event3", + "distinct_id": distinct_id + }]); + + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + + // Should have triggered overflow, but has not + assert_eq!( + topic.next_message_key()?.unwrap(), + format!("{}:{}", token, distinct_id) + ); + Ok(()) +} + +#[tokio::test] +async fn it_trims_distinct_id() -> Result<()> { + setup_tracing(); + let token = random_string("token", 16); + let distinct_id1 = random_string("id", 200 - 3); + let distinct_id2 = random_string("id", 222); + let (trimmed_distinct_id2, _) = distinct_id2.split_at(200); // works because ascii chars + + let main_topic = EphemeralTopic::new().await; + let histo_topic = EphemeralTopic::new().await; + let server = ServerHandle::for_topics(&main_topic, &histo_topic).await; + + let event = json!([{ + "token": token, + "event": "event1", + "distinct_id": distinct_id1 + },{ + "token": token, + "event": "event2", + "distinct_id": distinct_id2 + }]); + let res = server.capture_events(event.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": distinct_id1 + }) + ); + assert_json_include!( + actual: main_topic.next_event()?, + expected: json!({ + "token": token, + "distinct_id": trimmed_distinct_id2 + }) + ); + + Ok(()) +} diff --git a/rust/capture/tests/requests_dump.jsonl b/rust/capture/tests/requests_dump.jsonl new file mode 100644 index 0000000000000..4b59c3bc971b3 --- /dev/null +++ b/rust/capture/tests/requests_dump.jsonl @@ -0,0 +1,21 @@ +### posthog-js +{"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"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769320381&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:20.384514+00:00","body":"H4sIAAAAAAAAA+2deZPbthXAvwpH48m0HWPNG8R2Mmm8PpJO49p1nLT1ZDQgCIpc8RIPHevJTD9LP1o/SR+gi5So9cYrJysF/9grnA/Aez8cJPHefxg0TRwMLge64VHiGSHyXMdDWDcY8jw/RCH2fd/FoYM9PHg84FOe1ZD8EW3qnNGibkoOwUWZF7ysY14NLj8MHuXw3+A7yrS/v9X+CdEQMJzysorzDCIM/cJwLnQR7pf5rOIlBL6ISx7mcxEY8GnM+LBeFBwinvFqXOeFiGBNWUL1w6ZMICKq6+LyyZMExEiivKovPV3Xn8TZiFc1VPSkakCkIp9BvV8VCa3DvEy/THM/TvgXYUlTPsvL8Zclp6weZrSOp6Ihj0RJUHi3VBFR0DrKIBdE9lfSas+2sYaBW8EJzUYNHYkyeIbevRVZKlZyng0jHo8iqNnQHXMbOouDOoJA19QhcBrzWZGX9SYxMYx28Dq1Y3sQnMQ+1DPjvqgFfrRH4AJ7F44IjzOQqx5KFZhlzEgWllXESUI8Uov4OhYtNlxiY5dYBr6wXfvxIIih9Rn0m8xX/Pj1+NvvF2+sq1fYssx3kzfz7Bu3sF784P3w1wAX/3Jt/Dx4NZ7/yFrD29U62zcDhD3KEdTkI58HPqW6SfXQFnka0XufUFlcDQOe5kPQz2vOoMdCmlQcChyVeVNIZd1ErYXhKPR8E8Gw6yhweICI5emcO5QGtujLvBzRLL4BjZF9uc3lOGyZi3pOgEyHGQ53HCdwAiFJVtU0Y2LoexV38DNI1TKqIfQx9RMeDKHpMHLDKg4g81p+UFpQ2GHIqUwcJnQErXn/E0S1w4YFXSQ5DURLoQJQ9iQVEkCt0DCWxGwc5dC5wrJTGieyMjE6dAq/RP2bKquEsvEt8Y/AfjnYpzDmRwFYM/TpJhQMZhjkUIPosVasxMna0qU40s55y4BEqoSnkE408MOghlat7NBv6hrSyBTDms/FIMIvltCqEiR6P/gbDH72dhbXLBoOV8mhj2hdl8OlPkHReYYqmQQZkHsZJ8uA6L4C1mnKXDR/sMy7CQ1oTZH4E6LyokZxhlrDuqoJUmd1NGRRnIAQYPDiVx6uesL4+XGnnUE8PdgsiGj9QihskgQtQbBpaE9jtEOZOoJB599BsFYGayeD1ZthKVVVL2T/pXS+rPtSA0Mo5n/+FBlanfO0jIMRfw2YhZbnWS0mrL2u6Et065iYn1AtmpW0gPnhTtVvEh+v9dLebq1bpjhWhRD+7XpivNr0aSS16zYxtN1c2irP/QYkTLhYThyqX0QftSLxH2K5KC1Cy/lbzBZAsjDJZyiKgwBC+uVY5wWBljm13Xz3GyQxzgjUC+pYj8tuEEKwSOrTl92E2qGc9xPxLcxvT6mo95DN7qXoVAjLkk6F9h0rhMDVX+jgGK0SaLsJ79filpbsqtCeBBu96KjLpwjQmvvKPN/pxF1+7xbh58FiU0YdwbQsplC5GN2V+GNTHDSxzsfQeljTRWw4Gb17UTizG/Pp9ZX9j+rF9XcvizfW65fPYB3gvtGvXupXr+1XX1OmfytXzbwS64P9VSRH2A8s5PuWjlzXwH5oe57nEpFpFmdBPvtoHs/QQx64TijWZHkYVhwWFiYxJAj2tkueD9k920LU5D4ivhV6geuZNhO7BrVdOt/tUhoaThHeXDdpHl8nROyOd7dL3oWOidouqe3Sg90uXeVpkfBatGh3f/F0nbz1C6b6Mk5pKSaBTjCMVt1Uh2ITWsrlYSews+noxDCQnpdc6EHqI/PARmaZermR2ZGuG9iVrRsnJesGbeXqhq+l0qRMm0lwORibvr3P7ukXrzwLmvEEhTCN920yOrH3k+xuiyu1r1P7OrWvU/s6ta9T+7qT2deZBPfv65gJ2U2MKDYd5ASUMcelRkhpa1+32dlosETLywB+VxosJzNYJkANaqt3Hlu9giYzYwHKYLqWPh0J++zZ6nkgntrqqa3eMbZ6aw5usDKEZ0lDkWhV6LozYM6s4DnUMMkPJgE7FBYu+ng3yYmRWs79e6Qmpo+wFbjIc3T4BxNCOHcZtoSBrEl9qEO1mtNUgwrAbEdaU8DjO7nxVNw+A25HUeT7syDHaUAyfy4e7+5z2/CgbMVtxe3jcFuC5BbgQKIpTZptNSfFYDCWAwxmCDObI2oR+IvYgWNbsF7CXovBh+csReEzpvA8n2NWFDPGJnF83Uhk9lBYPShRFD46hW9ZJp84h/tPLYhQdxc47FGTIMcwKASbhPlCw3Y5vL8xUBw+Yw6T3BxV03lUBCHnzBKP3RSHFYd/VQ73nEWcKYchO/Zd5Jt6iHwMem5bNDBsobVrDrfVQXRG3tQKwGcMYDxJDRxWXqHHVWDnBwCs+Kv4e3T+9qDm1MHr9oPXdhF2WIAoCUPk2l5gmDoOQ699GMxWbz3B4GcxvJwB/ZH5OZXHNIrAZ0zgyKYu86aLmVWVKZwN9xLYNByFYIXgoy+Bb4HOlsV12Zwcil35Osk+ih2MMBgT8gMvRLptMztkoesGbnsNLEZZKpomht+Ht3GqKJ+JbcHDIa4UtvpqO15wnrQazC/FgN2ZqsuCTh6j1zdN2SSR02A7p5bb/+q7aUGVCqMKo8fA6KoVNa3GFRhfI9gBs/SWqZ0YsItVjCAhQES8u72JhA3WafHVdvr56oYIw5GCUDsDOY5LTGLBIzfWXup2Bxn0CBRFsfUBs9XOkngyHY2uOTeK/EbG77PVcRVbFVuP9FlRW1b5YVHewP45y4CXTH5KtJsIXmqoCniidqpnB6beD1QPFqyWayPIxpEZ2PAOGWHwQ3zuo4B6okAl8Zh6N76RFiQfZVS8kNIDVPWdpgLq5wJqlI8mCVwEUwn1lgZ1RjDVD3w/QTy4RowyDx5+mTYKbZ3oBqVeoLd3/wqmJwbTpMqmi3I0D7I8Gd2M5PfH+zBVL9Qqln4mlk4aXi6GZZNlIoNUvXPj6YEHW7s8pTT0wRywWpyeME91HJNZ6Mcsn7AAk/53YxVPFU8/K08RqJz4dPX3glIKH4xRG1DqYh3KMLkeYsdlTvvlLKDNCMyGzxQ/HzA//XThzEYTs1g4WYkrebvNHj8tQ52WKoAeCaAPnoAgYlzLG3ieCxvXns+LJIc2av/7z3+119CL3+SjLibdA8ehgYNw6FK4Etw1kcMNhinRddPs7OBFGYqQD5eQhsHHzqSwxpQYkb28/2ufkI5aYipCHmuJKbHyYVk3xMwp6HL5lwLEg5PQCzBH2S2QCt6AEn0nGvHgsdohpnHgK1i4hACbvg13xhgYwfMjS7cs7lEmqtwQU2opjHtWx6G4dUex86GyMzViuLytmsdxPC8tKrYCip2KnZ+TnXIY1ua7HolN+Jgv7j4sqzwrGgtdAdvoMutjRazs+RkPaZPU2uuNPMvXw2EsVlRbvra6Uq7taQHYyk2eiSLefX/VGnN4XUscPmQi7mzhb+t+GNoYS11S8D81+I8mhZctZrPJ/GZ0rcsrchX8Ffx/Nfh3OvbgDHBrL+/PAHfNubJlgaAqaQSrBWDACsXXUEMq9MPUTQvpBBnO9zq5NOxLWwdzcFzD+Dck3fTIelBW43HWMwCnCBuOAxeRwQdoPvWxY1KL+07Hc5o6MHnY3NdvUqt0R/aCZYtRNLf6uY8V9xX31YHJ3YhpEHJHYnq24YXEtcVVfWrNfHLsLKyaTq4LPS1S23cNcS+1Yqdipzow+R0fmNwd/sS0QiswPQX/04Q/qQgrKM/go2AyHacK/gr+6sBEHZgcngEoIXBLmgm3pPnQLxyuq8SeTijvvIgn31DUNmhVU8ADngLGzCckDxdmneLCDPpvOHMccEejpgA1BRxjCpB0EIMwBkBBjHznrXojQ8XtO4kcmPeDP22IAvwALZZTBMtpwivG/7AlykXb7h9r7YiVQQmT/aOGkPauTLQn2tu1r6d2UmE4y9U8DFlaCL9PcAk8L5+CqO+34dqz52+vpFOoUPjouxwg047EMAZNuVIOE4MDrJOCvWdKr+97sA8DHZb78GgUqB8ignXXt1hggKuOU3CUq0jfJf0C35hTHfxcecEYNzPZoB3SkwtQKEV6Rfr9+IhWQ7ilMBF2BBcXbh0Sre7qEU4smwr+vYAeDxpQHprRZFHHDIQD7d5Y7C1p9oraeuSAKgHEe+X0JNgrZKcT98rYj/8NPP0KF3UtJ7+v5XxXaV9oL5ea3HX2G2ei8Ft8/sLcVMeHnf6CetBk34vvYf++MPoIvnjrccgoZGl73e164F3L0evedyXF3Xz5biRYCxDBcAg6L5cGooeW4XD7J0XiT4iErm5QXPMUbVP9AjeNSbyTftd3ItnJ0HySH8g+15tVAn/dyQXnbsqjCvDReo9T3cFqbncsKlctyq/nmfj1tPsvZic6hs+oTfDoCbXYSIfDZm5YmBH+8L/921LnbivNVvqTXmraxFjozZxYxdzl1xOB0Z2lpqlf6OpGNLXUVEvNs/6ecXcde+iLRuBBP/vhMSM2wa+zTzFH3OPcg18h5W0ndQ/5uPn3OgPEWZhO42tr5DHs01R25N4MYNjK/7KaAdQMcO8j9BVmX+UwrJ2zaEPc6P3g54v2m9qW/fNP/wen6voL5JoAAA==","output":[{"uuid":"018a981f-8658-701c-88bf-f7bbb67f5787","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8658-701c-88bf-f7bbb67f5787\", \"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\": \"wnc1ly33pill989t\", \"$time\": 1694769317.464, \"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--full-width\"], \"attr__class\": \"LemonSwitch 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\": 2912}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-88be-7843-a2eb-9b3f8d6824c0","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-88be-7843-a2eb-9b3f8d6824c0\", \"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\": \"mf15pfzjumoijl97\", \"$time\": 1694769318.079, \"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\": \"Complete\", \"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\": 4, \"nth_of_type\": 4}, {\"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\": 2297}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-88c2-7b27-a725-5dacc56a1faa","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-88c2-7b27-a725-5dacc56a1faa\", \"event\": \"ingestion recordings turned off\", \"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\": \"palw1yb272630vgl\", \"$time\": 1694769318.082, \"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\", \"session_recording_opt_in\": false, \"capture_console_log_opt_in\": false, \"capture_performance_opt_in\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2294}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-892b-73d6-8506-87999ee6c734","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-892b-73d6-8506-87999ee6c734\", \"event\": \"session_recording_opt_in team setting updated\", \"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\": \"hhhbbwdo7md9nbx1\", \"$time\": 1694769318.188, \"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\", \"setting\": \"session_recording_opt_in\", \"value\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2188}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-892c-7c4e-a39c-794d54327278","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-892c-7c4e-a39c-794d54327278\", \"event\": \"capture_console_log_opt_in team setting updated\", \"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\": \"xox7cppwccqiijuc\", \"$time\": 1694769318.189, \"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\", \"setting\": \"capture_console_log_opt_in\", \"value\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2187}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-892d-764e-8a29-511a81f29cbb","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-892d-764e-8a29-511a81f29cbb\", \"event\": \"capture_performance_opt_in team setting updated\", \"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\": \"9o2gsvxhpdfeec3h\", \"$time\": 1694769318.189, \"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\", \"setting\": \"capture_performance_opt_in\", \"value\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2187}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-892e-77b6-b20f-b70f443ad14c","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-892e-77b6-b20f-b70f443ad14c\", \"event\": \"autocapture_opt_out team setting updated\", \"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\": \"7qm17fs8p0isd4oh\", \"$time\": 1694769318.19, \"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\", \"setting\": \"autocapture_opt_out\", \"value\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2186}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8946-75cd-a9ff-648d1207ff84","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8946-75cd-a9ff-648d1207ff84\", \"event\": \"completed_snippet_onboarding team setting updated\", \"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\": \"h4a6c8vyw3srmhbb\", \"$time\": 1694769318.215, \"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\", \"setting\": \"completed_snippet_onboarding\", \"value\": true, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2161}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8957-7215-bd8f-044c4fcf66d6","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8957-7215-bd8f-044c4fcf66d6\", \"event\": \"activation sidebar shown\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"jzurulh5u74oa367\", \"$time\": 1694769318.231, \"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\", \"active_tasks_count\": 5, \"completed_tasks_count\": 2, \"completion_percent_count\": 29, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2145}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-896f-7d14-9381-5569293727c4","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-896f-7d14-9381-5569293727c4\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"4nliqvggjee1poz5\", \"$time\": 1694769318.256, \"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\", \"$feature_flag\": \"cloud-announcement\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2120}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8987-7364-810e-2d4c739c8102","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8987-7364-810e-2d4c739c8102\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"9ika8zb1mp9ogna8\", \"$time\": 1694769318.279, \"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\", \"$feature_flag\": \"hogql-insights\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2097}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8988-7ac8-b724-f40901aa8d06","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8988-7ac8-b724-f40901aa8d06\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"lsnvyrgxdnolgzgk\", \"$time\": 1694769318.28, \"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\", \"$feature_flag\": \"query_running_time\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2096}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8988-7ac8-b724-f40aafb78a72","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8988-7ac8-b724-f40aafb78a72\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"07i9wfbicoqcd79c\", \"$time\": 1694769318.28, \"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\", \"$feature_flag\": \"query-timings\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2096}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89ab-7a48-b670-bb2e0f756c5c","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89ab-7a48-b670-bb2e0f756c5c\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"bmy5wgq2py5nr7sn\", \"$time\": 1694769318.316, \"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\": \"Event Explorer \\u2022 PostHog\"}, \"offset\": 2060}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89d5-7f6a-8662-5e1c7a900226","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89d5-7f6a-8662-5e1c7a900226\", \"event\": \"$set\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"11ek5qp3ka91h4on\", \"$time\": 1694769318.358, \"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\", \"$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\": 2018}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89d6-72b4-a717-d4c3033e8ac9","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89d6-72b4-a717-d4c3033e8ac9\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"m1irapsxiiixr3aw\", \"$time\": 1694769318.358, \"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\": \"project\", \"$group_key\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"$group_set\": {\"id\": 1, \"uuid\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"name\": \"Default Project\", \"ingested_event\": true, \"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\": 2018}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89d6-72b4-a717-d4c40bff477e","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89d6-72b4-a717-d4c40bff477e\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"gqp8nywwqxzgj0te\", \"$time\": 1694769318.358, \"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\": \"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\": 2018}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89ea-7155-85cd-bab752a3eb57","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89ea-7155-85cd-bab752a3eb57\", \"event\": \"$set\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"0zm3r6g4ycnyghx3\", \"$time\": 1694769318.378, \"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\", \"$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\": 1998}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89ea-7155-85cd-bab8418f9641","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89ea-7155-85cd-bab8418f9641\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"p3taqjp0mpm4b61v\", \"$time\": 1694769318.378, \"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\": \"project\", \"$group_key\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"$group_set\": {\"id\": 1, \"uuid\": \"018a981e-f8b2-0000-d5ed-9380ee5aad4b\", \"name\": \"Default Project\", \"ingested_event\": true, \"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\": 1998}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-89ea-7155-85cd-bab923f3d281","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-89ea-7155-85cd-bab923f3d281\", \"event\": \"$groupidentify\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"p9s9cpaen4oa9vkm\", \"$time\": 1694769318.378, \"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\": \"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\": 1998}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8a99-7726-bb09-e5117809ae5c","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8a99-7726-bb09-e5117809ae5c\", \"event\": \"query completed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"kcb99ofy2tm7p2dh\", \"$time\": 1694769318.553, \"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\", \"query\": {\"kind\": \"EventsQuery\", \"select\": [\"*\", \"event\", \"person\", \"coalesce(properties.$current_url, properties.$screen_name) -- Url / Screen\", \"properties.$lib\", \"timestamp\"], \"orderBy\": [\"timestamp DESC\"], \"after\": \"-24h\"}, \"duration\": 274, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1823}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-8fd0-7117-a99f-9706b3cd1ee6","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-8fd0-7117-a99f-9706b3cd1ee6\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/events?onboarding_completed=true\", \"$host\": \"localhost:8000\", \"$pathname\": \"/events\", \"$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\": \"y7z2v0ned8dk7uws\", \"$time\": 1694769319.888, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"a\", \"$el_text\": \"Persons & Groups\", \"classes\": [\"Link\", \"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-stealth\", \"LemonButton--full-width\", \"LemonButton--has-icon\"], \"attr__class\": \"Link LemonButton LemonButton--tertiary LemonButton--status-stealth LemonButton--full-width LemonButton--has-icon\", \"attr__href\": \"/persons\", \"attr__data-attr\": \"menu-item-persons\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"nth_child\": 12, \"nth_of_type\": 9}, {\"tag_name\": \"ul\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider__content\"], \"attr__class\": \"SideBar__slider__content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider\"], \"attr__class\": \"SideBar__slider\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 487}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-9078-7225-8884-0281e137c9ec","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-9078-7225-8884-0281e137c9ec\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/persons\", \"$host\": \"localhost:8000\", \"$pathname\": \"/persons\", \"$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\": \"491y0ux93px6ejqi\", \"$time\": 1694769320.056, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Persons & Groups \\u2022 PostHog\"}, \"offset\": 320}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-90cd-72c2-ba7e-e8ee82c2fae8","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-90cd-72c2-ba7e-e8ee82c2fae8\", \"event\": \"query completed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/persons\", \"$host\": \"localhost:8000\", \"$pathname\": \"/persons\", \"$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\": \"infmvij3g8c7bamx\", \"$time\": 1694769320.142, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"query\": {\"kind\": \"PersonsNode\"}, \"duration\": 131, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 234}","now":"2023-09-15T09:15:20.384514+00:00","sent_at":"2023-09-15T09:15:20.381000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769323385&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:23.391445+00:00","body":"H4sIAAAAAAAAA+0a7W7bNvBVDCPYrzLV90f+temSdli7ZF3bDUUhUCRlMZZFRaLiOEWAPcsebU+yOzm2ZdlOlq7YklV/bOnueHe8b5P++HlY15IPD4aGGdAwMBMSuokgvkfhKTE54dQVruXFjhGYwydDcSFyDeR7tNaK0ULXpQBwUapClFqKanjwebin4Gv4mrLBT28HvwIaANGFKCupckCYxr7p7hsIj0s1rUQJwCNZikRdIpCLC8lEpGeFAMQLUY21KhDB6rIE8VFdZoBItS4Onj7NQI0sVZU+CAzDeApqVCqvkByBQLdOgIiC6jSnE+Tepr9RZqWpafotcEbzUU1HuErk5N1bXFKxUog8SoUcpSDLNFxrBZ1KrlMAepYBwAsppoUq9ZI4NM02eEHtOgGAMxmDnKmIUQq8tM237wf7LsJlDnrpaO6/sjqvrMC6HBX2lDsK8VriHk0vdHwvtC1z34H9cFlpmbObZcWHZ+NXv8xO7cM3vm1b785PL/OXXmEfvQ/e/8D94jfP8b/nb8aXH1jLNesR48QWJ35ABQFBMYkFjyk1LGokDq6p0XhfIExWERcTFUFsnQkGBktoVglgOCpVXTSBtkQtlBEkCWKLgJ8Nwl3BSWgHhhAupdxBU6pyRHN5RfXclKtVrsvmq2jgcmK5zISwd13uctQkrzTNGXp+a9ANr0GrVkJEYGMaZ4JHsHVwXFRJDosX+lOm5YWIEkEb4iSjI9jNx0+AasOigs4yRTnuFASUgmYT1ACkwsZYJtk4VWBczMoJlVkjDL1DL+AN5S9FVhll41vwKa2iWGaZzEEqxPkSAdnBIOMg6qO6gs99sDivIXhoTrOZlgyUg+BeJuYtNBusKlFhREelAJGzLXy2EGww6Rhxg8cmfg/qjAASLDp7HKoOxM8SigbgCqyJ0dHCNmVvUZEa0zf1SLRqBVJlYgJ06MzPQ1AyuikyVQEmBc0yWsGeADvU4lITLhJaZ8gfIno6BP9TrcsoauhgWZto0JA8GeY6jVgqM0gnkIhvKrnRa65BhKtg8aFKoapgXZszTWGDWO/YDfz6yR0q/gi5lz+vtQYPREzlGgv/ho5bqdbUhIK4pqbVkUwb+27Ru62LzNHgLWHrb4Ro7D60nHXhkLi6ruALsgfKaweb1FlG5oW3g4GMIBL2s2XHoMugRdt+XumxDl3XYh230mEdvtRglwcXcE41JfgISAi/mkgtJmRFdVvIdHyRyQ693V1gdFbU2f0kcHmx7tq3UBqfU9hGlcET2nlXqO2k/KoK3Cn364jbKWadvdNhb9/FPiXz4QOgSSZwnMIviIZsU+KCdoAkzUdD9wX7m/NtenypVMcjGzHUYRErjmk756FTKKFNJOKM1NX49spiXsMWtRrD7mHWSFl0Pnp3VLjTK+v52aHzc3V09vq4OLVPjl9AzfZOjcNj4/DEefOMMuNVM8zd9JuN6Qbm4ZjbJI5tg3ie6ceJEwSBF+Kiqcy5mt65JjCNRHDPTXBWUElSCRwXQ7Nx6MYI7nkO8allkjBwLeJYTJghCwKDee0RfG1YgHkEBo4HNYqvStDfG8Vb9I96FJ9NEq8Yu06dz/K0DrC3bI7irg950c/i/Szez+L3nMXbbHByTdWU3GyTyFyXiojLJq9GC3BTUtbcWYqqgF/9K6s/qrYB0B1tIyY+Z4zQWEAeOWYchB71HYYpvGgbYEZVcrDOAOud4IOphNEzVwN0LJapQVVPJvNRuu8l/3Uvqa9c93xmqTgurSvbG23vJcC6byV9K+lbyf1aybwARphP0QTY2FYAmYRarWDAelkxI16XTYwtMSWkcjSlZY5YpmqssAAHkaqcv+MaRK6YIP8YHDenzEewHzw1iiogAN8B8HE1I2PXbxhYbvkWCeKQkzhJOHNZ4Bjcav+GKWD36Ia+2TyAZuNObR1YM31GZ0Z+LtEp25oNqNd3m77b9N3mft3mwVd1UFFqdOjiEH7w5+9/DE4gVF6qUafmN4d4GzWfWwnUfBGTIAxDyGEWJhYNE9dZq/kP9er4Wy37emzqfKbklTOK87HVRHKn7Nv7Norsy35f9vuy/6/eHXeuR0/mf1cZfDc4nkdyf0+665509c+e2+5JV1T/5J60exUV9tek/TVpf036dY4Y/B3Dpu0T3wQOsQEHDJ7J7CRgie1ZODk87AOGb/VPip4T5GcTd6Snl5ZIzs52TJphP2n2k2Y/af6PDxi6Y+yukwbPuf70FzjrKd2kLgAA","output":[{"uuid":"018a981f-95fe-76af-9f1d-da5e526b4081","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-95fe-76af-9f1d-da5e526b4081\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/persons\", \"$host\": \"localhost:8000\", \"$pathname\": \"/persons\", \"$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\": \"0rsqs282xgp3wd4o\", \"$time\": 1694769321.47, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"classes\": [\"text-default\", \"grow\"], \"attr__class\": \"text-default grow\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"Cohorts\", \"attr__href\": \"/cohorts\"}, {\"tag_name\": \"span\", \"classes\": [\"LemonButton__content\"], \"attr__class\": \"LemonButton__content\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"a\", \"$el_text\": \"Cohorts\", \"classes\": [\"Link\", \"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-stealth\", \"LemonButton--full-width\", \"LemonButton--has-icon\"], \"attr__class\": \"Link LemonButton LemonButton--tertiary LemonButton--status-stealth LemonButton--full-width LemonButton--has-icon\", \"attr__href\": \"/cohorts\", \"attr__data-attr\": \"menu-item-cohorts\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"nth_child\": 13, \"nth_of_type\": 10}, {\"tag_name\": \"ul\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider__content\"], \"attr__class\": \"SideBar__slider__content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider\"], \"attr__class\": \"SideBar__slider\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 1913}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-9664-7a21-9852-42ce19c880c6","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-9664-7a21-9852-42ce19c880c6\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/cohorts\", \"$host\": \"localhost:8000\", \"$pathname\": \"/cohorts\", \"$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\": \"ymf6pk54unynhu8h\", \"$time\": 1694769321.573, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"show-product-intro-existing-products\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1810}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-966b-7dcc-abee-f41b896a74cc","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-966b-7dcc-abee-f41b896a74cc\", \"event\": \"recording viewed with no playtime summary\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/cohorts\", \"$host\": \"localhost:8000\", \"$pathname\": \"/cohorts\", \"$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\": \"uz55qy2obbr2z36g\", \"$time\": 1694769321.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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"viewed_time_ms\": 3288, \"play_time_ms\": 0, \"recording_duration_ms\": 0, \"rrweb_warning_count\": 0, \"error_count_during_recording_playback\": 0, \"engagement_score\": 0, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1803}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-966e-7272-8b9d-bffdc5c840d2","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-966e-7272-8b9d-bffdc5c840d2\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/cohorts\", \"$host\": \"localhost:8000\", \"$pathname\": \"/cohorts\", \"$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\": \"5w3t82ytjay0nqiw\", \"$time\": 1694769321.582, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Cohorts \\u2022 PostHog\"}, \"offset\": 1801}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-9d2f-72eb-8999-bec9f2a9f542","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-9d2f-72eb-8999-bec9f2a9f542\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/cohorts\", \"$host\": \"localhost:8000\", \"$pathname\": \"/cohorts\", \"$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\": \"tk1tnyoiz4gbnk2t\", \"$time\": 1694769323.311, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"a\", \"$el_text\": \"Persons & Groups\", \"classes\": [\"Link\", \"LemonButton\", \"LemonButton--tertiary\", \"LemonButton--status-stealth\", \"LemonButton--full-width\", \"LemonButton--has-icon\"], \"attr__class\": \"Link LemonButton LemonButton--tertiary LemonButton--status-stealth LemonButton--full-width LemonButton--has-icon\", \"attr__href\": \"/persons\", \"attr__data-attr\": \"menu-item-persons\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"nth_child\": 12, \"nth_of_type\": 9}, {\"tag_name\": \"ul\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider__content\"], \"attr__class\": \"SideBar__slider__content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar__slider\"], \"attr__class\": \"SideBar__slider\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"SideBar\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 71}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-9d37-712e-b09d-61c3f8cf3624","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-9d37-712e-b09d-61c3f8cf3624\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/persons\", \"$host\": \"localhost:8000\", \"$pathname\": \"/persons\", \"$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\": \"648njm5gtwx2efjj\", \"$time\": 1694769323.319, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Persons & Groups \\u2022 PostHog\"}, \"offset\": 64}","now":"2023-09-15T09:15:23.391445+00:00","sent_at":"2023-09-15T09:15:23.385000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769326396&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:26.539412+00:00","body":"H4sIAAAAAAAAA+1b6Y7cNhJ+lUavf5pt3ccsFtjEWTtZbLx2HCe7CAKBIqmWptWiRqT6GMPAPss+Wp4kH9Xn9JV08scz0PyYaRWLVcW6PrKp+enjsG0LPrwZWnZE48jOSMxTn4R2FJHIDmwS2yLOeGA5GQ+Hz4diJioN9rtWNMsBk9O6FFpwjNSNrEWjC6GGNx+HzyT+DL+lbPDv94P/YBiEZCYaVcgKA7Y1sv2RZehpI+dKNCC+KhqRyYUhcjErmEj0shYY+EqoiZa1GWBt08CCpG1KDORa1zcvXpSS0TKXSt9ElmW9gBlKVsqwGyL4HjKYgZrqvKJTI32ff23MzlLbDvfIJa3GLR2bWaIiH96bKYo1QlRJLopxDl225Ts76rzgOgcxcCwQZ4WY17LRW+bYtvfJG27fi0AuixR65iI1WvCw775RGI18Qy8q2KWTLoTMX97Hd9mcRsEsX9Buni7MGu0g9sIgdh135Hn+8yEvlC4qtp5X//jF5Jvvl+/cl29C13U+3L1bVF8Htfvqh+iHf/Kw/m/ghf/gbyaLH9lebB5mjZc6nIQRFQSaUpIKnlJqOdTKPDOnNd77A8oKlXAxlQmS61YweCyjpRIQOG5kW3eZth3aGCNIFqUOQaAtwn3BSexGlhA+pdwzPpHNmFbFPdUrX+5m+T5bzaKRz4njM9sXvu9z36Q3HK1pxUzoT2bd8BOsoq0GsdZtIxL4mKal4AmWjsglquCYvLGfMl3MRJIJ2jFnJR1jNT/9jKF9WlLTZSkpNyuFgkbQcmosgFYsjJUFm+QSzjWVOaVF2Skz0aEzPBn9W5WqpGxyYTynKkmLsiwqaEWibwdQHgwlh7RPWoXfI3ict0geWtFyqQsG45Dd28q8wHMkSgllUjppBFQuT8g5wXAk5MCJRzKOx5+h0QiwmK7zjKPtIH+2VOMALuFNkx270a7jmYSbFJVJ5LerrvFGIqwIDW+bdUbZFhqAlhNh5tc5S+7GH17V/vze+fL2pfedenX77ev6nfv29Vco5+Cd9fK19fKt9+YLyqxvuoayXvJRgQkSptwlaepaJAjsMM28KIqC2Eyawyg5/805kW1lggd+ZkyWWaYECseJPf/T82MkoA7KIPRcfIpslwQ8tmI3tjKfGZUbJHiYrygJ5PxnCAcvfm+rugIyfrfMRw0rhW1Zd7RN/ZyJtuTdgg5gxRsFLhbUw0oPKz2sXAcrD+JiNnGKcKryVNIG4TmMWyNUDdDZufcx4Ywd+u5pnAk8ElKHosjdmNhxFIcuY2EURns4A3/JhsOLA9PYBB/MC50PKjkwETT9aKDa6ZQCo3vweTrgM79vZpbj5WFqTUMadUhxDD79maYHnx58rgefVSdNTEElU4ixnRiVZKza0SB623qTzSlnO9KglpM5bSozymRrWjXoUCmb1bOZYwZ3Qoz8FIFbcVZjrGdqVqbAgNiB+LhQ7dzpKQhI6HKH0CDIiJ2ljLkOjzPL2T891Vi9CUOPWk8ItSZK6ftFfNuWY1nPQ3oGtfojU49aPWpdjVqfPTrAxEKbgG6+JBz88r//D94iVb6W4wPsOH0i8lIXwj2LpB6Ex17scztFKcfuHnasOur6ONTjxxPCD+Vk97f3AQu1JSZjPT2BH/7IcYMeP3r86PHjWvzY9cnNicVGlRqvdZ4f3uimXbtx1XzW7mOt0nJqMvhgugeZiGMu4e9j2Rj97BFrH5PQtE5iUhDiPBM6DloCE8TJ4tTlQRp5qKS988xe4fSQ9IQgqaqXE2RZE+Tav7tP85OQFEQ9JPWQ1EPS9bdAXf/ctLbO9V1jE3vNwnCV3TdlJpgfhzByjU9DVcOlz4dU6yZJONWUmI/bIwJZL5Rsv4NTRHfvB1U6T1helCgeyDdPMltbsdKXaLEw6f/ddiZy8YFuXswgiJVUQQsMG/4LNVV9T1OVJFBCmKy0wQZk38q+jhUTz/BdNupAeVlc0v1bOrcua2R3Wtsn0aag8FuJ+OBwdTPs8nU7Ck5ArFiYEn9osXtgcXe+27O4Lc9anNLmosVm/NjiEu32Oqedj9gF9VvNSi871YQgGZFTMEERVaIBNaTDmJtBEI3sGD8AhsjxHDuuF38dnOJf7TluBhYYLuRvl63Ggr1FYlf1YJHdnuXSIk3NEVrX5xPyiOPPefU9lvglNfLPKTzi+HMrXIs7q+ey+MNEPRKfk9W+A9SsROqv/sBZ5bHGDe/AsHS/Or4/4NCV3A7dGykPXHRYbIciUsnNdfC6ZnM0z65nmO3RocUPBWOXdSAYS3xMu/jQOreJT3HVjvuIGG/skJRRygRzvDgwG6Incynxl9UOCH3rb2vP7+FXv7s/3t1nuWqDOfazy1DPZq0pmhO7+xgq+919v7vvd/dP9sJiQdFSmr+vv84a4R88VncXl+8xgjg+iTZhhGuM2PFIikIiLp7jLLSF45sb0eMXu8xWdpAJzfLHe5/RQ8+10MNEOrsNdDtO51447869R9AT+/0bXj309NBzNfSYMHTvcpmLCNSQabGGdVeV5kb5sweoPazxXP/Tz78Ca5NvR7g4AAA=","output":[{"uuid":"018a981f-9db5-7188-8161-91e9fd602fd7","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-9db5-7188-8161-91e9fd602fd7\", \"event\": \"query completed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/persons\", \"$host\": \"localhost:8000\", \"$pathname\": \"/persons\", \"$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\": \"c5yz9qfwa86vhxab\", \"$time\": 1694769323.445, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"query\": {\"kind\": \"PersonsNode\"}, \"duration\": 102, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 2945}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a25d-743f-a813-6d909390f5c9","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a25d-743f-a813-6d909390f5c9\", \"event\": \"$feature_flag_called\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"i100qaub5hceuld4\", \"$time\": 1694769324.637, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$feature_flag\": \"cs-dashboards\", \"$feature_flag_response\": false, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1753}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a264-7a2a-9439-198973cc7878","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a264-7a2a-9439-198973cc7878\", \"event\": \"recording viewed with no playtime summary\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"wzrv024h7b0m7a8c\", \"$time\": 1694769324.645, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"viewed_time_ms\": 1295, \"play_time_ms\": 0, \"recording_duration_ms\": 0, \"rrweb_warning_count\": 0, \"error_count_during_recording_playback\": 0, \"engagement_score\": 0, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1745}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a266-73d2-a66f-1fbcc32d9f02","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a266-73d2-a66f-1fbcc32d9f02\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"ksstzx9julgopw7a\", \"$time\": 1694769324.647, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"Persons \\u2022 PostHog\"}, \"offset\": 1743}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a4b3-7b40-b430-9495d1bbed93","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a4b3-7b40-b430-9495d1bbed93\", \"event\": \"person viewed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"s2fzjz6c7t0ekgtm\", \"$time\": 1694769325.236, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"properties_count\": 18, \"has_email\": true, \"has_name\": false, \"custom_properties_count\": 4, \"posthog_properties_count\": 14, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1154}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a676-7722-bece-2f9b3d6b84ce","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a676-7722-bece-2f9b3d6b84ce\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"npyk617r6ht5qzbh\", \"$time\": 1694769325.686, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"attr__data-attr\": \"person-session-recordings-tab\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"Recordings\"}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs__tab-content\"], \"attr__class\": \"LemonTabs__tab-content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"classes\": [\"LemonTabs__tab\"], \"attr__class\": \"LemonTabs__tab\", \"attr__role\": \"tab\", \"attr__aria-selected\": \"false\", \"attr__tabindex\": \"0\", \"nth_child\": 3, \"nth_of_type\": 3}, {\"tag_name\": \"ul\", \"classes\": [\"LemonTabs__bar\"], \"attr__class\": \"LemonTabs__bar\", \"attr__role\": \"tablist\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs\"], \"attr__class\": \"LemonTabs\", \"attr__style\": \"--lemon-tabs-slider-width: 68.19999694824219px; --lemon-tabs-slider-offset: 0px;\", \"attr__data-attr\": \"persons-tabs\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\"], \"attr__class\": \"main-app-content\", \"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\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 704}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a67b-7a6f-9637-bcaacec2496c","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a67b-7a6f-9637-bcaacec2496c\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=sessionRecordings\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"fhsu6w5edy7tvvuy\", \"$time\": 1694769325.691, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"xavier@posthog.com \\u2022 Persons \\u2022 PostHog\"}, \"offset\": 699}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-a783-7924-b938-3a789f71e25a","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-a783-7924-b938-3a789f71e25a\", \"event\": \"recording list fetched\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=sessionRecordings\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"fcebvj6tugbw47wk\", \"$time\": 1694769325.955, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"load_time\": 145, \"listing_version\": \"3\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 435}","now":"2023-09-15T09:15:26.539412+00:00","sent_at":"2023-09-15T09:15:26.396000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +{"path":"/e/?compression=gzip-js&ip=1&_=1694769329412&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:29.500667+00:00","body":"H4sIAAAAAAAAA+0Ya2/bNvCvGF4/bEPo6EVSyjBgbdq0HdauaZp2Q1EIFElZqmlRkehXiwL7Lftp+yU7yo7jV2KkHYoikz/Y1t3xXryX7u3H7miUi+5R13FDFoVuihhLMaJp6qAoTQUKUyzdNMTUpWn3oCvHsjBAfo+NjOasNKNKArisdCkrk8u6e/Sxe0/DT/cZ453fzzp/ABoA8VhWda4LQLhOz8U9x8KTSk9qWQHwJK9kqqcWKOQ45zI2s1IC4qGsB0aXFsFHVQXi41GlAJEZUx4dHipQQ2W6Nkeh4ziHoEati8OlOUHiCURDJlEU0AQlUiSMOR5z0uA7xk0+lq9Y8nMta6vcS8l1JfKiX1txlinIWRdgESUzWcGGVrvbyFsx+MobrktXwIoV/RHrW86yQOdn9kjNKymLOJN5PwN9XAd7V9BJLkwGQOI5ABznclLqyiyJI9ddBV9S4yAEsMoTkDORiZUCD6tX1KNhD1t4XoBeJp7HiB6LQTRVxThKs5QNLN7k1g8uAWtJ5HukF/pgkMhrkxd8ca58c3/w9NXs1D9+Tn3fO784nRZPSOmfvA5f/ypo+ScJ6CPxfDB9w1fufz0s9/h1ZL33GcLyOhZyqGMI4PeSg8dSpmoJDPuVHpVNNC9Rl8pIlIaJhyAYHCSwFCjyQ0dKzJgIrC911WdF/oGZuS+vTmHM56dYiAXyMHexxBgLLKwmRW1Ywe3V74zs7ifQaiXrYvAxS5QUMZgONxfXuYDDl/rPYztOJWuIU8UgqI/evgPUKiwu2UxpJqylIKCSTA2tBiAVDOMq54NMg3Nt6g9Zrhph9nbYGJ6s/KXIWjE+uAGfsTpOcqUgveISAn2JgBTikNYQ9vGohu8eeFyMIHhYwdTM5ByUg+heZv8NNFusFokdVxJEznbw2UGwxWTDiVs8tvH3oJhJILGV7Z6A0gbxs4RaBwgN3rTRsYJtautl2Wtc3xQ9uVIsLJWSQ6Czl/mxC0rGi0pUl+DSgy4zpopjwQxD9q9NiKZC1aiSitlLbbREhtlQLUwW8yxXkDjA2z7pdKHBXFZs5NSG/sncxM7cxE8Ha6JFPgZeXLEavAl6dX+DlCqgrtZxDHIQ14WxbQOCb65eQwoHr6G7Wa8N4Sq/SfY+mUuPVdqGKjC+ArEqZ6gGf3NwG+CacF1igTIvhJzaDF/XGG9ojDc0HqlrNU5YdaPGFr+tsYJqezunXX9jN4hfSq7NrBGNEMSiLmws1ahWUH8q1LSYow4Neh6Bj4cjSjD8lNOfOrvodZrW0hx1XEp6XhRFIaWRF5GQ2hM3xrPlsm51sGF1sM9qm4OIleX1EbpF8WVuPgObHzDL/zqBWxRfZuGC3bVybmbv72OfofkcAtBUQS7Mf8BZalviJS3UEDltvhq6z3DonG/T7SutN1zk72GRaDG7SuIMimlTROy4tKnxOmOYujYYg4lGD8B6CMqMxxf985MSTz54D94fBy/rk/fPHpen/ovHD6F6k1Pn+LFz/CJ4fp9x52kz1y06z9acIxFNhI+SxHcQIS5N0iAMQxLZQxOoOXqy90zoOqkUBKd2apinGBiAqWedsWPiZ4hGMkQMRKFAklAmJOC+szbxl9Dq7CR5B8b9RcM+aZpZO+lvT/ozf0qJZGR4cZF7Fw7bPekHoF476beTfjvp327S/+abBqiYm2bEmzIoKdUvJURJpvs9roedf/76u/NiPoPN/wPuie6vdxpCd3aa1IfUjkQAWR0wJKgXcQqFxhG2wtzR3VLbbPY0G2UmtFJBmuVB0r8g/o5mQ3uRD8Np22zaZtM2m6+6Vvq6u521ndPmmunb2/RsvhI2r1f/s01PCHsbHyqi5wY+xgGJ9m16fEJ7Dmx6KMYhjvx20dMuetpFz3+66HED2uyct8fvgMCw43soYiRCHBNCscvtlvaOLnoam9qpe9fUTWbKxxNhxnjsupo0I/L21N2ueNqpu5262xXP1orHDXaveBLHCRH1KQOZAchkhIVuwjwvsKl92WMuRrKadUBUqaQdudtOc4c7TdgfTcbD0Jtlk1mWarKj04Q91ydtp2k7TdtpbttpmlJqA24ALQAwj5pCdNpAwbvNUsO+wf24LL+NqlB97BueZkrWXH5/VX57q0XyoLOKWBQPW8N+gFf8znmlOoeds8sXw1VSWyRsixlKCM9haV8S50Kfrqcnj9g8tkmYwqNLAwBTjhNbRVhqGlcgL8hsHItRtcgOl0KB+uZ77Gq79Kj/6d2/ToySO28rAAA=","output":[{"uuid":"018a981f-aaf5-7ff0-9ffd-8f5e1f85717f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-aaf5-7ff0-9ffd-8f5e1f85717f\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=sessionRecordings\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"0ovdk9xlnv9fhfak\", \"$time\": 1694769326.837, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"attr__data-attr\": \"persons-related-flags-tab\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"Feature flags\"}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs__tab-content\"], \"attr__class\": \"LemonTabs__tab-content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"classes\": [\"LemonTabs__tab\"], \"attr__class\": \"LemonTabs__tab\", \"attr__role\": \"tab\", \"attr__aria-selected\": \"false\", \"attr__tabindex\": \"0\", \"nth_child\": 5, \"nth_of_type\": 5}, {\"tag_name\": \"ul\", \"classes\": [\"LemonTabs__bar\"], \"attr__class\": \"LemonTabs__bar\", \"attr__role\": \"tablist\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs\"], \"attr__class\": \"LemonTabs\", \"attr__style\": \"--lemon-tabs-slider-width: 74.26666259765625px; --lemon-tabs-slider-offset: 176.29998779296875px;\", \"attr__data-attr\": \"persons-tabs\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\"], \"attr__class\": \"main-app-content\", \"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\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 2572}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-aafa-79e8-abf4-4e68eb64c30f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-aafa-79e8-abf4-4e68eb64c30f\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=featureFlags\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"y3x76ea6mqqi2q0a\", \"$time\": 1694769326.842, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"xavier@posthog.com \\u2022 Persons \\u2022 PostHog\"}, \"offset\": 2567}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-af3d-79d4-be4a-d729c776e0da","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-af3d-79d4-be4a-d729c776e0da\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=featureFlags\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"ltw7rl4fhi4bgq63\", \"$time\": 1694769327.934, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"div\", \"classes\": [\"LemonTabs__tab-content\"], \"attr__class\": \"LemonTabs__tab-content\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"\"}, {\"tag_name\": \"li\", \"classes\": [\"LemonTabs__tab\"], \"attr__class\": \"LemonTabs__tab\", \"attr__role\": \"tab\", \"attr__aria-selected\": \"false\", \"attr__tabindex\": \"0\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"ul\", \"classes\": [\"LemonTabs__bar\"], \"attr__class\": \"LemonTabs__bar\", \"attr__role\": \"tablist\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs\"], \"attr__class\": \"LemonTabs\", \"attr__style\": \"--lemon-tabs-slider-width: 86.23332214355469px; --lemon-tabs-slider-offset: 367.0999755859375px;\", \"attr__data-attr\": \"persons-tabs\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\"], \"attr__class\": \"main-app-content\", \"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\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 1475}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-af46-7832-9a69-c566751c6625","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-af46-7832-9a69-c566751c6625\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=events\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"6yl35wdtv5v11o6c\", \"$time\": 1694769327.942, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"xavier@posthog.com \\u2022 Persons \\u2022 PostHog\"}, \"offset\": 1467}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-b008-737a-bb40-6a6a81ba2244","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-b008-737a-bb40-6a6a81ba2244\", \"event\": \"query completed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=events\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"8guwvm82yhwyhfo6\", \"$time\": 1694769328.136, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"query\": {\"kind\": \"EventsQuery\", \"select\": [\"*\", \"event\", \"person\", \"coalesce(properties.$current_url, properties.$screen_name) -- Url / Screen\", \"properties.$lib\", \"timestamp\"], \"personId\": \"018a981f-4c9a-0000-68ff-417481f7c5b5\", \"after\": \"-24h\"}, \"duration\": 171, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1273}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +### posthog-js with compression query param mismatch, to confirm gzip autodetection +{"path":"/e/?compression=gzip-js&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/?ip=1&_=1694769329412&ver=1.78.5","method":"POST","content_encoding":"","content_type":"text/plain","ip":"127.0.0.1","now":"2023-09-15T09:15:29.500667+00:00","body":"H4sIAAAAAAAAA+0Ya2/bNvCvGF4/bEPo6EVSyjBgbdq0HdauaZp2Q1EIFElZqmlRkehXiwL7Lftp+yU7yo7jV2KkHYoikz/Y1t3xXryX7u3H7miUi+5R13FDFoVuihhLMaJp6qAoTQUKUyzdNMTUpWn3oCvHsjBAfo+NjOasNKNKArisdCkrk8u6e/Sxe0/DT/cZ453fzzp/ABoA8VhWda4LQLhOz8U9x8KTSk9qWQHwJK9kqqcWKOQ45zI2s1IC4qGsB0aXFsFHVQXi41GlAJEZUx4dHipQQ2W6Nkeh4ziHoEati8OlOUHiCURDJlEU0AQlUiSMOR5z0uA7xk0+lq9Y8nMta6vcS8l1JfKiX1txlinIWRdgESUzWcGGVrvbyFsx+MobrktXwIoV/RHrW86yQOdn9kjNKymLOJN5PwN9XAd7V9BJLkwGQOI5ABznclLqyiyJI9ddBV9S4yAEsMoTkDORiZUCD6tX1KNhD1t4XoBeJp7HiB6LQTRVxThKs5QNLN7k1g8uAWtJ5HukF/pgkMhrkxd8ca58c3/w9NXs1D9+Tn3fO784nRZPSOmfvA5f/ypo+ScJ6CPxfDB9w1fufz0s9/h1ZL33GcLyOhZyqGMI4PeSg8dSpmoJDPuVHpVNNC9Rl8pIlIaJhyAYHCSwFCjyQ0dKzJgIrC911WdF/oGZuS+vTmHM56dYiAXyMHexxBgLLKwmRW1Ywe3V74zs7ifQaiXrYvAxS5QUMZgONxfXuYDDl/rPYztOJWuIU8UgqI/evgPUKiwu2UxpJqylIKCSTA2tBiAVDOMq54NMg3Nt6g9Zrhph9nbYGJ6s/KXIWjE+uAGfsTpOcqUgveISAn2JgBTikNYQ9vGohu8eeFyMIHhYwdTM5ByUg+heZv8NNFusFokdVxJEznbw2UGwxWTDiVs8tvH3oJhJILGV7Z6A0gbxs4RaBwgN3rTRsYJtautl2Wtc3xQ9uVIsLJWSQ6Czl/mxC0rGi0pUl+DSgy4zpopjwQxD9q9NiKZC1aiSitlLbbREhtlQLUwW8yxXkDjA2z7pdKHBXFZs5NSG/sncxM7cxE8Ha6JFPgZeXLEavAl6dX+DlCqgrtZxDHIQ14WxbQOCb65eQwoHr6G7Wa8N4Sq/SfY+mUuPVdqGKjC+ArEqZ6gGf3NwG+CacF1igTIvhJzaDF/XGG9ojDc0HqlrNU5YdaPGFr+tsYJqezunXX9jN4hfSq7NrBGNEMSiLmws1ahWUH8q1LSYow4Neh6Bj4cjSjD8lNOfOrvodZrW0hx1XEp6XhRFIaWRF5GQ2hM3xrPlsm51sGF1sM9qm4OIleX1EbpF8WVuPgObHzDL/zqBWxRfZuGC3bVybmbv72OfofkcAtBUQS7Mf8BZalviJS3UEDltvhq6z3DonG/T7SutN1zk72GRaDG7SuIMimlTROy4tKnxOmOYujYYg4lGD8B6CMqMxxf985MSTz54D94fBy/rk/fPHpen/ovHD6F6k1Pn+LFz/CJ4fp9x52kz1y06z9acIxFNhI+SxHcQIS5N0iAMQxLZQxOoOXqy90zoOqkUBKd2apinGBiAqWedsWPiZ4hGMkQMRKFAklAmJOC+szbxl9Dq7CR5B8b9RcM+aZpZO+lvT/ozf0qJZGR4cZF7Fw7bPekHoF476beTfjvp327S/+abBqiYm2bEmzIoKdUvJURJpvs9roedf/76u/NiPoPN/wPuie6vdxpCd3aa1IfUjkQAWR0wJKgXcQqFxhG2wtzR3VLbbPY0G2UmtFJBmuVB0r8g/o5mQ3uRD8Np22zaZtM2m6+6Vvq6u521ndPmmunb2/RsvhI2r1f/s01PCHsbHyqi5wY+xgGJ9m16fEJ7Dmx6KMYhjvx20dMuetpFz3+66HED2uyct8fvgMCw43soYiRCHBNCscvtlvaOLnoam9qpe9fUTWbKxxNhxnjsupo0I/L21N2ueNqpu5262xXP1orHDXaveBLHCRH1KQOZAchkhIVuwjwvsKl92WMuRrKadUBUqaQdudtOc4c7TdgfTcbD0Jtlk1mWarKj04Q91ydtp2k7TdtpbttpmlJqA24ALQAwj5pCdNpAwbvNUsO+wf24LL+NqlB97BueZkrWXH5/VX57q0XyoLOKWBQPW8N+gFf8znmlOoeds8sXw1VSWyRsixlKCM9haV8S50Kfrqcnj9g8tkmYwqNLAwBTjhNbRVhqGlcgL8hsHItRtcgOl0KB+uZ77Gq79Kj/6d2/ToySO28rAAA=","output":[{"uuid":"018a981f-aaf5-7ff0-9ffd-8f5e1f85717f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-aaf5-7ff0-9ffd-8f5e1f85717f\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=sessionRecordings\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"0ovdk9xlnv9fhfak\", \"$time\": 1694769326.837, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"span\", \"attr__data-attr\": \"persons-related-flags-tab\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"Feature flags\"}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs__tab-content\"], \"attr__class\": \"LemonTabs__tab-content\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"li\", \"classes\": [\"LemonTabs__tab\"], \"attr__class\": \"LemonTabs__tab\", \"attr__role\": \"tab\", \"attr__aria-selected\": \"false\", \"attr__tabindex\": \"0\", \"nth_child\": 5, \"nth_of_type\": 5}, {\"tag_name\": \"ul\", \"classes\": [\"LemonTabs__bar\"], \"attr__class\": \"LemonTabs__bar\", \"attr__role\": \"tablist\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs\"], \"attr__class\": \"LemonTabs\", \"attr__style\": \"--lemon-tabs-slider-width: 74.26666259765625px; --lemon-tabs-slider-offset: 176.29998779296875px;\", \"attr__data-attr\": \"persons-tabs\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\"], \"attr__class\": \"main-app-content\", \"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\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 2572}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-aafa-79e8-abf4-4e68eb64c30f","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-aafa-79e8-abf4-4e68eb64c30f\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=featureFlags\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"y3x76ea6mqqi2q0a\", \"$time\": 1694769326.842, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"xavier@posthog.com \\u2022 Persons \\u2022 PostHog\"}, \"offset\": 2567}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-af3d-79d4-be4a-d729c776e0da","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-af3d-79d4-be4a-d729c776e0da\", \"event\": \"$autocapture\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=featureFlags\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"ltw7rl4fhi4bgq63\", \"$time\": 1694769327.934, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"$event_type\": \"click\", \"$ce_version\": 1, \"$elements\": [{\"tag_name\": \"div\", \"classes\": [\"LemonTabs__tab-content\"], \"attr__class\": \"LemonTabs__tab-content\", \"nth_child\": 1, \"nth_of_type\": 1, \"$el_text\": \"\"}, {\"tag_name\": \"li\", \"classes\": [\"LemonTabs__tab\"], \"attr__class\": \"LemonTabs__tab\", \"attr__role\": \"tab\", \"attr__aria-selected\": \"false\", \"attr__tabindex\": \"0\", \"nth_child\": 2, \"nth_of_type\": 2}, {\"tag_name\": \"ul\", \"classes\": [\"LemonTabs__bar\"], \"attr__class\": \"LemonTabs__bar\", \"attr__role\": \"tablist\", \"nth_child\": 1, \"nth_of_type\": 1}, {\"tag_name\": \"div\", \"classes\": [\"LemonTabs\"], \"attr__class\": \"LemonTabs\", \"attr__style\": \"--lemon-tabs-slider-width: 86.23332214355469px; --lemon-tabs-slider-offset: 367.0999755859375px;\", \"attr__data-attr\": \"persons-tabs\", \"nth_child\": 4, \"nth_of_type\": 4}, {\"tag_name\": \"div\", \"classes\": [\"main-app-content\"], \"attr__class\": \"main-app-content\", \"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\"], \"attr__class\": \"SideBar\", \"nth_child\": 4, \"nth_of_type\": 3}, {\"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\": 1475}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-af46-7832-9a69-c566751c6625","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-af46-7832-9a69-c566751c6625\", \"event\": \"$pageview\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=events\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"6yl35wdtv5v11o6c\", \"$time\": 1694769327.942, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\", \"title\": \"xavier@posthog.com \\u2022 Persons \\u2022 PostHog\"}, \"offset\": 1467}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"},{"uuid":"018a981f-b008-737a-bb40-6a6a81ba2244","distinct_id":"pWAkITyQ3CN7332UqQxnH6p3FV8VJd7pY647EdNkxWc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"018a981f-b008-737a-bb40-6a6a81ba2244\", \"event\": \"query completed\", \"properties\": {\"$os\": \"Mac OS X\", \"$os_version\": \"10.15.0\", \"$browser\": \"Firefox\", \"$device_type\": \"Desktop\", \"$current_url\": \"http://localhost:8000/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4#activeTab=events\", \"$host\": \"localhost:8000\", \"$pathname\": \"/person/018a981f-4b2d-78ae-947b-bedbaa02a0f4\", \"$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\": \"8guwvm82yhwyhfo6\", \"$time\": 1694769328.136, \"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, \"has_billing_plan\": false, \"percentage_usage.product_analytics\": 0, \"current_usage.product_analytics\": 0, \"percentage_usage.session_replay\": 0, \"current_usage.session_replay\": 0, \"percentage_usage.feature_flags\": 0, \"current_usage.feature_flags\": 0, \"$referrer\": \"$direct\", \"$referring_domain\": \"$direct\", \"query\": {\"kind\": \"EventsQuery\", \"select\": [\"*\", \"event\", \"person\", \"coalesce(properties.$current_url, properties.$screen_name) -- Url / Screen\", \"properties.$lib\", \"timestamp\"], \"personId\": \"018a981f-4c9a-0000-68ff-417481f7c5b5\", \"after\": \"-24h\"}, \"duration\": 171, \"token\": \"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I\", \"$session_id\": \"018a981f-4b2e-7bd3-bb30-6617bf488869\", \"$window_id\": \"018a981f-4b2e-7bd3-bb30-661810fed65f\"}, \"offset\": 1273}","now":"2023-09-15T09:15:29.500667+00:00","sent_at":"2023-09-15T09:15:29.412000+00:00","token":"phc_qgUFp5wz2BjC4RsFjMGpQ3PGDrsi6Q0CG0CP4NAac0I"}]} +### nodejs, default params +{"path":"/batch/","method":"POST","content_encoding":"","content_type":"application/json","ip":"127.0.0.1","now":"2024-04-17T14:40:56.918578+00:00","body":"eyJhcGlfa2V5IjoicGhjX05WWk95WWI4aFgyR0RzODBtUWQwNzJPVDBXVXJBYmRRb200WGVDVjVOZmgiLCJiYXRjaCI6W3siZGlzdGluY3RfaWQiOiJpZDEiLCJldmVudCI6InRoaXMgZXZlbnQiLCJwcm9wZXJ0aWVzIjp7IiRsaWIiOiJwb3N0aG9nLW5vZGUiLCIkbGliX3ZlcnNpb24iOiI0LjAuMCIsIiRnZW9pcF9kaXNhYmxlIjp0cnVlfSwidHlwZSI6ImNhcHR1cmUiLCJsaWJyYXJ5IjoicG9zdGhvZy1ub2RlIiwibGlicmFyeV92ZXJzaW9uIjoiNC4wLjAiLCJ0aW1lc3RhbXAiOiIyMDI0LTA0LTE3VDE0OjQwOjU2LjkwMFoiLCJ1dWlkIjoiMDE4ZWVjODAtZjA0NC03YzJkLWI5NjYtMzVlZmM4ZWQ2MWQ1In0seyJkaXN0aW5jdF9pZCI6ImRpc3RpbmN0X2lkX29mX3RoZV91c2VyIiwiZXZlbnQiOiJ1c2VyIHNpZ25lZCB1cCIsInByb3BlcnRpZXMiOnsibG9naW5fdHlwZSI6ImVtYWlsIiwiaXNfZnJlZV90cmlhbCI6dHJ1ZSwiJGxpYiI6InBvc3Rob2ctbm9kZSIsIiRsaWJfdmVyc2lvbiI6IjQuMC4wIiwiJGdlb2lwX2Rpc2FibGUiOnRydWV9LCJ0eXBlIjoiY2FwdHVyZSIsImxpYnJhcnkiOiJwb3N0aG9nLW5vZGUiLCJsaWJyYXJ5X3ZlcnNpb24iOiI0LjAuMCIsInRpbWVzdGFtcCI6IjIwMjQtMDQtMTdUMTQ6NDA6NTYuOTAwWiIsInV1aWQiOiIwMThlZWM4MC1mMDQ0LTdjMmQtYjk2Ni0zNWYwNTA1OWYzNzUifV0sInNlbnRfYXQiOiIyMDI0LTA0LTE3VDE0OjQwOjU2LjkwMFoifQ==","output":[{"uuid":"018eec80-f044-7c2d-b966-35efc8ed61d5","distinct_id":"id1","ip":"127.0.0.1","site_url":"http://127.0.0.1:8000","data":"{\"distinct_id\": \"id1\", \"event\": \"this event\", \"properties\": {\"$lib\": \"posthog-node\", \"$lib_version\": \"4.0.0\", \"$geoip_disable\": true}, \"type\": \"capture\", \"library\": \"posthog-node\", \"library_version\": \"4.0.0\", \"timestamp\": \"2024-04-17T14:40:56.900Z\", \"uuid\": \"018eec80-f044-7c2d-b966-35efc8ed61d5\"}","now":"2024-04-17T14:40:56.918578+00:00","sent_at":"2024-04-17T14:40:56.900000+00:00","token":"phc_NVZOyYb8hX2GDs80mQd072OT0WUrAbdQom4XeCV5Nfh"},{"uuid":"018eec80-f044-7c2d-b966-35f05059f375","distinct_id":"distinct_id_of_the_user","ip":"127.0.0.1","site_url":"http://127.0.0.1:8000","data":"{\"distinct_id\": \"distinct_id_of_the_user\", \"event\": \"user signed up\", \"properties\": {\"login_type\": \"email\", \"is_free_trial\": true, \"$lib\": \"posthog-node\", \"$lib_version\": \"4.0.0\", \"$geoip_disable\": true}, \"type\": \"capture\", \"library\": \"posthog-node\", \"library_version\": \"4.0.0\", \"timestamp\": \"2024-04-17T14:40:56.900Z\", \"uuid\": \"018eec80-f044-7c2d-b966-35f05059f375\"}","now":"2024-04-17T14:40:56.918578+00:00","sent_at":"2024-04-17T14:40:56.900000+00:00","token":"phc_NVZOyYb8hX2GDs80mQd072OT0WUrAbdQom4XeCV5Nfh"}]} +### batch from temporal batch export +{"historical_migration":true,"path":"/batch","method":"POST","content_encoding":"","content_type":"application/json","ip":"127.0.0.1","now":"2024-04-29T14:49:29.553700+00:00","body":"eyJhcGlfa2V5IjogImFiY2RlZjEyMzQ1NiIsImhpc3RvcmljYWxfbWlncmF0aW9uIjp0cnVlLCJiYXRjaCI6IFt7InV1aWQiOiJmNGI3NmJhNy1lMGZmLTRkYTctOTBkNS02ZTkwZjY4ZWRjMmUiLCJkaXN0aW5jdF9pZCI6ImJhYTkyOGNiLTU4YjUtNGY0Zi04ZWQ0LWJkYzMzMzQ4YmRjYyIsInRpbWVzdGFtcCI6IjIwMjMtMDQtMjRUMDY6MzQ6MDArMDA6MDAiLCJldmVudCI6InRlc3Qtbm8tcHJvcC0wIiwicHJvcGVydGllcyI6eyIkZ2VvaXBfZGlzYWJsZSI6dHJ1ZX19LHsidXVpZCI6ImFhNDZlODNmLWJmNzctNDJjNy04OGExLTczMGE5MmFlMjZhNyIsImRpc3RpbmN0X2lkIjoiMzhlMWM3MTAtYTAzNC00MjAzLTg2NzUtZTVkNDgxY2FjOTIzIiwidGltZXN0YW1wIjoiMjAyMy0wNC0yMVQyMzo1MzowMCswMDowMCIsImV2ZW50IjoidGVzdC0xIiwicHJvcGVydGllcyI6eyIkYnJvd3NlciI6IkNocm9tZSIsIiRvcyI6Ik1hYyBPUyBYIiwiJGdlb2lwX2Rpc2FibGUiOnRydWV9fSx7InV1aWQiOiJiNWY1ZGFhOS00YmRlLTQ2ZDAtYjk2YS1kM2VkZmI4N2ViODEiLCJkaXN0aW5jdF9pZCI6ImZhMWI1NjFhLTAxNGMtNDMzNS1hM2VmLTk4YzA2ZTRmZDdlOCIsInRpbWVzdGFtcCI6IjIwMjMtMDQtMjJUMTU6MDY6MDArMDA6MDAiLCJldmVudCI6InRlc3QtMCIsInByb3BlcnRpZXMiOnsiJGJyb3dzZXIiOiJDaHJvbWUiLCIkb3MiOiJNYWMgT1MgWCIsIiRnZW9pcF9kaXNhYmxlIjp0cnVlfX0seyJ1dWlkIjoiYjQyOTM1ZTUtZDIwNi00ODRiLTk1OTEtODcxZjI2MmYzMGRkIiwiZGlzdGluY3RfaWQiOiIxMDk5OGJmZS03M2Y4LTQ3MDktYTBkNi0yMDI3MTk1MjA2MDYiLCJ0aW1lc3RhbXAiOiIyMDIzLTA0LTI0VDEzOjQ2OjAwKzAwOjAwIiwiZXZlbnQiOiJ0ZXN0LTIiLCJwcm9wZXJ0aWVzIjp7IiRicm93c2VyIjoiQ2hyb21lIiwiJG9zIjoiTWFjIE9TIFgiLCIkZ2VvaXBfZGlzYWJsZSI6dHJ1ZX19XX0=","output":[{"uuid":"f4b76ba7-e0ff-4da7-90d5-6e90f68edc2e","distinct_id":"baa928cb-58b5-4f4f-8ed4-bdc33348bdcc","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"f4b76ba7-e0ff-4da7-90d5-6e90f68edc2e\", \"distinct_id\": \"baa928cb-58b5-4f4f-8ed4-bdc33348bdcc\", \"timestamp\": \"2023-04-24T06:34:00+00:00\", \"event\": \"test-no-prop-0\", \"properties\": {\"$geoip_disable\": true}}","now":"2024-04-29T14:49:29.553700+00:00","sent_at":"","token":"abcdef123456"},{"uuid":"aa46e83f-bf77-42c7-88a1-730a92ae26a7","distinct_id":"38e1c710-a034-4203-8675-e5d481cac923","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"aa46e83f-bf77-42c7-88a1-730a92ae26a7\", \"distinct_id\": \"38e1c710-a034-4203-8675-e5d481cac923\", \"timestamp\": \"2023-04-21T23:53:00+00:00\", \"event\": \"test-1\", \"properties\": {\"$browser\": \"Chrome\", \"$os\": \"Mac OS X\", \"$geoip_disable\": true}}","now":"2024-04-29T14:49:29.553700+00:00","sent_at":"","token":"abcdef123456"},{"uuid":"b5f5daa9-4bde-46d0-b96a-d3edfb87eb81","distinct_id":"fa1b561a-014c-4335-a3ef-98c06e4fd7e8","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"b5f5daa9-4bde-46d0-b96a-d3edfb87eb81\", \"distinct_id\": \"fa1b561a-014c-4335-a3ef-98c06e4fd7e8\", \"timestamp\": \"2023-04-22T15:06:00+00:00\", \"event\": \"test-0\", \"properties\": {\"$browser\": \"Chrome\", \"$os\": \"Mac OS X\", \"$geoip_disable\": true}}","now":"2024-04-29T14:49:29.553700+00:00","sent_at":"","token":"abcdef123456"},{"uuid":"b42935e5-d206-484b-9591-871f262f30dd","distinct_id":"10998bfe-73f8-4709-a0d6-202719520606","ip":"127.0.0.1","site_url":"http://localhost:8000","data":"{\"uuid\": \"b42935e5-d206-484b-9591-871f262f30dd\", \"distinct_id\": \"10998bfe-73f8-4709-a0d6-202719520606\", \"timestamp\": \"2023-04-24T13:46:00+00:00\", \"event\": \"test-2\", \"properties\": {\"$browser\": \"Chrome\", \"$os\": \"Mac OS X\", \"$geoip_disable\": true}}","now":"2024-04-29T14:49:29.553700+00:00","sent_at":"","token":"abcdef123456"}]} +### end \ No newline at end of file diff --git a/rust/common/README.md b/rust/common/README.md new file mode 100644 index 0000000000000..0e490c70a6245 --- /dev/null +++ b/rust/common/README.md @@ -0,0 +1,8 @@ +# Common crates for the hog-rs services + +This folder holds internal crates for code reuse between services in the monorepo. To keep maintenance costs low, +these crates should ideally: + +- Cover a small feature scope and use as little dependencies as possible +- Only use `{ workspace = true }` dependencies, instead of pinning versions that could diverge from the workspace +- Have adequate test coverage and documentation diff --git a/rust/common/health/Cargo.toml b/rust/common/health/Cargo.toml new file mode 100644 index 0000000000000..c38e704bd7ce3 --- /dev/null +++ b/rust/common/health/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "health" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/rust/common/health/src/lib.rs b/rust/common/health/src/lib.rs new file mode 100644 index 0000000000000..5d42bafa8ff05 --- /dev/null +++ b/rust/common/health/src/lib.rs @@ -0,0 +1,344 @@ +use std::collections::HashMap; +use std::ops::Add; +use std::sync::{Arc, RwLock}; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use time::Duration; +use tokio::sync::mpsc; +use tracing::{info, warn}; + +/// Health reporting for components of the service. +/// +/// The capture server contains several asynchronous loops, and +/// the process can only be trusted with user data if all the +/// loops are properly running and reporting. +/// +/// HealthRegistry allows an arbitrary number of components to +/// be registered and report their health. The process' health +/// status is the combination of these individual health status: +/// - if any component is unhealthy, the process is unhealthy +/// - if all components recently reported healthy, the process is healthy +/// - if a component failed to report healthy for its defined deadline, +/// it is considered unhealthy, and the check fails. +/// +/// Trying to merge the k8s concepts of liveness and readiness in +/// a single state is full of foot-guns, so HealthRegistry does not +/// try to do it. Each probe should have its separate instance of +/// the registry to avoid confusions. + +#[derive(Default, Debug)] +pub struct HealthStatus { + /// The overall status: true of all components are healthy + pub healthy: bool, + /// Current status of each registered component, for display + pub components: HashMap, +} +impl IntoResponse for HealthStatus { + /// Computes the axum status code based on the overall health status, + /// and prints each component status in the body for debugging. + fn into_response(self) -> Response { + let body = format!("{:?}", self); + match self.healthy { + true => (StatusCode::OK, body), + false => (StatusCode::INTERNAL_SERVER_ERROR, body), + } + .into_response() + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum ComponentStatus { + /// Automatically set when a component is newly registered + Starting, + /// Recently reported healthy, will need to report again before the date + HealthyUntil(time::OffsetDateTime), + /// Reported unhealthy + Unhealthy, + /// Automatically set when the HealthyUntil deadline is reached + Stalled, +} +struct HealthMessage { + component: String, + status: ComponentStatus, +} + +pub struct HealthHandle { + component: String, + deadline: Duration, + sender: mpsc::Sender, +} + +impl HealthHandle { + /// Asynchronously report healthy, returns when the message is queued. + /// Must be called more frequently than the configured deadline. + pub async fn report_healthy(&self) { + self.report_status(ComponentStatus::HealthyUntil( + time::OffsetDateTime::now_utc().add(self.deadline), + )) + .await + } + + /// Asynchronously report component status, returns when the message is queued. + pub async fn report_status(&self, status: ComponentStatus) { + let message = HealthMessage { + component: self.component.clone(), + status, + }; + if let Err(err) = self.sender.send(message).await { + warn!("failed to report heath status: {}", err) + } + } + + /// Synchronously report as healthy, returns when the message is queued. + /// Must be called more frequently than the configured deadline. + pub fn report_healthy_blocking(&self) { + self.report_status_blocking(ComponentStatus::HealthyUntil( + time::OffsetDateTime::now_utc().add(self.deadline), + )) + } + + /// Synchronously report component status, returns when the message is queued. + pub fn report_status_blocking(&self, status: ComponentStatus) { + let message = HealthMessage { + component: self.component.clone(), + status, + }; + if let Err(err) = self.sender.blocking_send(message) { + warn!("failed to report heath status: {}", err) + } + } +} + +#[derive(Clone)] +pub struct HealthRegistry { + name: String, + components: Arc>>, + sender: mpsc::Sender, +} + +impl HealthRegistry { + pub fn new(name: &str) -> Self { + let (tx, mut rx) = mpsc::channel::(16); + let registry = Self { + name: name.to_owned(), + components: Default::default(), + sender: tx, + }; + + let components = registry.components.clone(); + tokio::spawn(async move { + while let Some(message) = rx.recv().await { + if let Ok(mut map) = components.write() { + _ = map.insert(message.component, message.status); + } else { + // Poisoned mutex: Just warn, the probes will fail and the process restart + warn!("poisoned HeathRegistry mutex") + } + } + }); + + registry + } + + /// Registers a new component in the registry. The returned handle should be passed + /// to the component, to allow it to frequently report its health status. + pub async fn register(&self, component: String, deadline: Duration) -> HealthHandle { + let handle = HealthHandle { + component, + deadline, + sender: self.sender.clone(), + }; + handle.report_status(ComponentStatus::Starting).await; + handle + } + + /// Returns the overall process status, computed from the status of all the components + /// currently registered. Can be used as an axum handler. + pub fn get_status(&self) -> HealthStatus { + let components = self + .components + .read() + .expect("poisoned HeathRegistry mutex"); + + let result = HealthStatus { + healthy: !components.is_empty(), // unhealthy if no component has registered yet + components: Default::default(), + }; + let now = time::OffsetDateTime::now_utc(); + + let result = components + .iter() + .fold(result, |mut result, (name, status)| { + match status { + ComponentStatus::HealthyUntil(until) => { + if until.gt(&now) { + _ = result.components.insert(name.clone(), status.clone()) + } else { + result.healthy = false; + _ = result + .components + .insert(name.clone(), ComponentStatus::Stalled) + } + } + _ => { + result.healthy = false; + _ = result.components.insert(name.clone(), status.clone()) + } + } + result + }); + match result.healthy { + true => info!("{} health check ok", self.name), + false => warn!("{} health check failed: {:?}", self.name, result.components), + } + result + } +} + +#[cfg(test)] +mod tests { + use crate::{ComponentStatus, HealthRegistry, HealthStatus}; + use axum::http::StatusCode; + use axum::response::IntoResponse; + use std::ops::{Add, Sub}; + use time::{Duration, OffsetDateTime}; + + async fn assert_or_retry(check: F) + where + F: Fn() -> bool, + { + assert_or_retry_for_duration(check, Duration::seconds(5)).await + } + + async fn assert_or_retry_for_duration(check: F, timeout: Duration) + where + F: Fn() -> bool, + { + let deadline = OffsetDateTime::now_utc().add(timeout); + while !check() && OffsetDateTime::now_utc().lt(&deadline) { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + assert!(check()) + } + #[tokio::test] + async fn defaults_to_unhealthy() { + let registry = HealthRegistry::new("liveness"); + assert!(!registry.get_status().healthy); + } + + #[tokio::test] + async fn one_component() { + let registry = HealthRegistry::new("liveness"); + + // New components are registered in Starting + let handle = registry + .register("one".to_string(), Duration::seconds(30)) + .await; + assert_or_retry(|| registry.get_status().components.len() == 1).await; + let mut status = registry.get_status(); + assert!(!status.healthy); + assert_eq!( + status.components.get("one"), + Some(&ComponentStatus::Starting) + ); + + // Status goes healthy once the component reports + handle.report_healthy().await; + assert_or_retry(|| registry.get_status().healthy).await; + status = registry.get_status(); + assert_eq!(status.components.len(), 1); + + // Status goes unhealthy if the components says so + handle.report_status(ComponentStatus::Unhealthy).await; + assert_or_retry(|| !registry.get_status().healthy).await; + status = registry.get_status(); + assert_eq!(status.components.len(), 1); + assert_eq!( + status.components.get("one"), + Some(&ComponentStatus::Unhealthy) + ); + } + + #[tokio::test] + async fn staleness_check() { + let registry = HealthRegistry::new("liveness"); + let handle = registry + .register("one".to_string(), Duration::seconds(30)) + .await; + + // Status goes healthy once the component reports + handle.report_healthy().await; + assert_or_retry(|| registry.get_status().healthy).await; + let mut status = registry.get_status(); + assert_eq!(status.components.len(), 1); + + // If the component's ping is too old, it is considered stalled and the healthcheck fails + // FIXME: we should mock the time instead + handle + .report_status(ComponentStatus::HealthyUntil( + OffsetDateTime::now_utc().sub(Duration::seconds(1)), + )) + .await; + assert_or_retry(|| !registry.get_status().healthy).await; + status = registry.get_status(); + assert_eq!(status.components.len(), 1); + assert_eq!( + status.components.get("one"), + Some(&ComponentStatus::Stalled) + ); + } + + #[tokio::test] + async fn several_components() { + let registry = HealthRegistry::new("liveness"); + let handle1 = registry + .register("one".to_string(), Duration::seconds(30)) + .await; + let handle2 = registry + .register("two".to_string(), Duration::seconds(30)) + .await; + assert_or_retry(|| registry.get_status().components.len() == 2).await; + + // First component going healthy is not enough + handle1.report_healthy().await; + assert_or_retry(|| { + registry.get_status().components.get("one").unwrap() != &ComponentStatus::Starting + }) + .await; + assert!(!registry.get_status().healthy); + + // Second component going healthy brings the health to green + handle2.report_healthy().await; + assert_or_retry(|| { + registry.get_status().components.get("two").unwrap() != &ComponentStatus::Starting + }) + .await; + assert!(registry.get_status().healthy); + + // First component going unhealthy takes down the health to red + handle1.report_status(ComponentStatus::Unhealthy).await; + assert_or_retry(|| !registry.get_status().healthy).await; + + // First component recovering returns the health to green + handle1.report_healthy().await; + assert_or_retry(|| registry.get_status().healthy).await; + + // Second component going unhealthy takes down the health to red + handle2.report_status(ComponentStatus::Unhealthy).await; + assert_or_retry(|| !registry.get_status().healthy).await; + } + + #[tokio::test] + async fn into_response() { + let nok = HealthStatus::default().into_response(); + assert_eq!(nok.status(), StatusCode::INTERNAL_SERVER_ERROR); + + let ok = HealthStatus { + healthy: true, + components: Default::default(), + } + .into_response(); + assert_eq!(ok.status(), StatusCode::OK); + } +} diff --git a/rust/depot.json b/rust/depot.json new file mode 100644 index 0000000000000..316dd6ef94564 --- /dev/null +++ b/rust/depot.json @@ -0,0 +1 @@ +{ "id": "x19jffd9zf" } diff --git a/rust/docker-compose.yml b/rust/docker-compose.yml new file mode 100644 index 0000000000000..7abfe14cae79c --- /dev/null +++ b/rust/docker-compose.yml @@ -0,0 +1,89 @@ +version: '3' + +services: + zookeeper: + image: zookeeper:3.7.0 + restart: on-failure + + kafka: + image: ghcr.io/posthog/kafka-container:v2.8.2 + restart: on-failure + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1001 + KAFKA_CFG_RESERVED_BROKER_MAX_ID: 1001 + KAFKA_CFG_LISTENERS: PLAINTEXT://:9092 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181 + ALLOW_PLAINTEXT_LISTENER: 'true' + ports: + - '9092:9092' + healthcheck: + test: kafka-cluster.sh cluster-id --bootstrap-server localhost:9092 || exit 1 + interval: 3s + timeout: 10s + retries: 10 + + redis: + image: redis:6.2.7-alpine + restart: on-failure + command: redis-server --maxmemory-policy allkeys-lru --maxmemory 200mb + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 3s + timeout: 10s + retries: 10 + + kafka-ui: + image: provectuslabs/kafka-ui:latest + profiles: ['ui'] + ports: + - '8080:8080' + depends_on: + - zookeeper + - kafka + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181 + + db: + container_name: db + image: docker.io/library/postgres:16-alpine + restart: on-failure + environment: + POSTGRES_USER: posthog + POSTGRES_DB: posthog + POSTGRES_PASSWORD: posthog + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U posthog'] + interval: 5s + timeout: 5s + ports: + - '15432:5432' + command: postgres -c max_connections=1000 -c idle_in_transaction_session_timeout=300000 + + setup_test_db: + container_name: setup-test-db + build: + context: . + dockerfile: Dockerfile.migrate + restart: on-failure + depends_on: + db: + condition: service_healthy + restart: true + environment: + DATABASE_URL: postgres://posthog:posthog@db:5432/test_database + + echo_server: + image: docker.io/library/caddy:2 + container_name: echo-server + restart: on-failure + ports: + - '18081:8081' + volumes: + - ./docker/echo-server/Caddyfile:/etc/caddy/Caddyfile diff --git a/rust/docker/echo-server/Caddyfile b/rust/docker/echo-server/Caddyfile new file mode 100644 index 0000000000000..a13ac68a24d6b --- /dev/null +++ b/rust/docker/echo-server/Caddyfile @@ -0,0 +1,17 @@ +{ + auto_https off +} + +:8081 + +route /echo { + respond `{http.request.body}` 200 { + close + } +} + +route /fail { + respond `{http.request.body}` 400 { + close + } +} diff --git a/rust/feature-flags/Cargo.toml b/rust/feature-flags/Cargo.toml new file mode 100644 index 0000000000000..08ff21eaed0d8 --- /dev/null +++ b/rust/feature-flags/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "feature-flags" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +axum = { workspace = true } +axum-client-ip = { workspace = true } +envconfig = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +bytes = { workspace = true } +rand = { workspace = true } +redis = { version = "0.23.3", features = [ + "tokio-comp", + "cluster", + "cluster-async", +] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +serde-pickle = { version = "1.1.1"} +sha1 = "0.10.6" +regex = "1.10.4" + +[lints] +workspace = true + +[dev-dependencies] +assert-json-diff = { workspace = true } +once_cell = "1.18.0" +reqwest = { workspace = true } + diff --git a/rust/feature-flags/README.md b/rust/feature-flags/README.md new file mode 100644 index 0000000000000..1c9500900aade --- /dev/null +++ b/rust/feature-flags/README.md @@ -0,0 +1,36 @@ + +# Testing + +``` +cargo test --package feature-flags +``` + +### To watch changes + +``` +brew install cargo-watch +``` + +and then run: + +``` +cargo watch -x test --package feature-flags +``` + +To run a specific test: + +``` +cargo watch -x "test --package feature-flags --lib -- property_matching::tests::test_match_properties_math_operators --exact --show-output" +``` + +# Running + +``` +RUST_LOG=debug cargo run --bin feature-flags +``` + +# Format code + +``` +cargo fmt --package feature-flags +``` \ No newline at end of file diff --git a/rust/feature-flags/src/api.rs b/rust/feature-flags/src/api.rs new file mode 100644 index 0000000000000..ccf4735e5b04a --- /dev/null +++ b/rust/feature-flags/src/api.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] +pub enum FlagsResponseCode { + Ok = 1, +} + +#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlagsResponse { + pub error_while_computing_flags: bool, + // TODO: better typing here, support bool responses + pub feature_flags: HashMap, +} + +#[derive(Error, Debug)] +pub enum FlagError { + #[error("failed to decode request: {0}")] + RequestDecodingError(String), + #[error("failed to parse request: {0}")] + RequestParsingError(#[from] serde_json::Error), + + #[error("Empty distinct_id in request")] + EmptyDistinctId, + #[error("No distinct_id in request")] + MissingDistinctId, + + #[error("No api_key in request")] + NoTokenError, + #[error("API key is not valid")] + TokenValidationError, + + #[error("rate limited")] + RateLimited, + + #[error("failed to parse redis cache data")] + DataParsingError, + #[error("redis unavailable")] + RedisUnavailable, +} + +impl IntoResponse for FlagError { + fn into_response(self) -> Response { + match self { + FlagError::RequestDecodingError(_) + | FlagError::RequestParsingError(_) + | FlagError::EmptyDistinctId + | FlagError::MissingDistinctId => (StatusCode::BAD_REQUEST, self.to_string()), + + FlagError::NoTokenError | FlagError::TokenValidationError => { + (StatusCode::UNAUTHORIZED, self.to_string()) + } + + FlagError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, self.to_string()), + + FlagError::DataParsingError | FlagError::RedisUnavailable => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + } + .into_response() + } +} diff --git a/rust/feature-flags/src/config.rs b/rust/feature-flags/src/config.rs new file mode 100644 index 0000000000000..cc7ad37bf72c1 --- /dev/null +++ b/rust/feature-flags/src/config.rs @@ -0,0 +1,24 @@ +use std::net::SocketAddr; + +use envconfig::Envconfig; + +#[derive(Envconfig, Clone)] +pub struct Config { + #[envconfig(default = "127.0.0.1:3001")] + pub address: SocketAddr, + + #[envconfig(default = "postgres://posthog:posthog@localhost:15432/test_database")] + pub write_database_url: String, + + #[envconfig(default = "postgres://posthog:posthog@localhost:15432/test_database")] + pub read_database_url: String, + + #[envconfig(default = "1024")] + pub max_concurrent_jobs: usize, + + #[envconfig(default = "100")] + pub max_pg_connections: u32, + + #[envconfig(default = "redis://localhost:6379/")] + pub redis_url: String, +} diff --git a/rust/feature-flags/src/flag_definitions.rs b/rust/feature-flags/src/flag_definitions.rs new file mode 100644 index 0000000000000..fbbd0445b5998 --- /dev/null +++ b/rust/feature-flags/src/flag_definitions.rs @@ -0,0 +1,214 @@ +use serde::Deserialize; +use std::sync::Arc; +use tracing::instrument; + +use crate::{ + api::FlagError, + redis::{Client, CustomRedisError}, +}; + +// TRICKY: This cache data is coming from django-redis. If it ever goes out of sync, we'll bork. +// TODO: Add integration tests across repos to ensure this doesn't happen. +pub const TEAM_FLAGS_CACHE_PREFIX: &str = "posthog:1:team_feature_flags_"; + +// TODO: Hmm, revisit when dealing with groups, but seems like +// ideal to just treat it as a u8 and do our own validation on top +#[derive(Debug, Deserialize)] +pub enum GroupTypeIndex {} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperatorType { + Exact, + IsNot, + Icontains, + NotIcontains, + Regex, + NotRegex, + Gt, + Lt, + Gte, + Lte, + IsSet, + IsNotSet, + IsDateExact, + IsDateAfter, + IsDateBefore, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PropertyFilter { + pub key: String, + // TODO: Probably need a default for value? + // incase operators like is_set, is_not_set are used + // not guaranteed to have a value, if say created via api + pub value: serde_json::Value, + pub operator: Option, + #[serde(rename = "type")] + pub prop_type: String, + pub group_type_index: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FlagGroupType { + pub properties: Option>, + pub rollout_percentage: Option, + pub variant: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MultivariateFlagVariant { + pub key: String, + pub name: Option, + pub rollout_percentage: f64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MultivariateFlagOptions { + pub variants: Vec, +} + +// TODO: test name with https://www.fileformat.info/info/charset/UTF-16/list.htm values, like '𝖕𝖗𝖔𝖕𝖊𝖗𝖙𝖞': `𝓿𝓪𝓵𝓾𝓮` + +#[derive(Debug, Clone, Deserialize)] +pub struct FlagFilters { + pub groups: Vec, + pub multivariate: Option, + pub aggregation_group_type_index: Option, + pub payloads: Option, + pub super_groups: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FeatureFlag { + pub id: i64, + pub team_id: i64, + pub name: Option, + pub key: String, + pub filters: FlagFilters, + #[serde(default)] + pub deleted: bool, + #[serde(default)] + pub active: bool, + #[serde(default)] + pub ensure_experience_continuity: bool, +} + +impl FeatureFlag { + pub fn get_group_type_index(&self) -> Option { + self.filters.aggregation_group_type_index + } + + pub fn get_conditions(&self) -> &Vec { + &self.filters.groups + } + + pub fn get_variants(&self) -> Vec { + self.filters + .multivariate + .clone() + .map_or(vec![], |m| m.variants) + } +} + +#[derive(Debug, Deserialize)] + +pub struct FeatureFlagList { + pub flags: Vec, +} + +impl FeatureFlagList { + /// Returns feature flags from redis given a team_id + #[instrument(skip_all)] + pub async fn from_redis( + client: Arc, + team_id: i64, + ) -> Result { + // TODO: Instead of failing here, i.e. if not in redis, fallback to pg + let serialized_flags = client + .get(format!("{TEAM_FLAGS_CACHE_PREFIX}{}", team_id)) + .await + .map_err(|e| match e { + CustomRedisError::NotFound => FlagError::TokenValidationError, + CustomRedisError::PickleError(_) => { + // TODO: Implement From trait for FlagError so we don't need to map + // CustomRedisError ourselves + tracing::error!("failed to fetch data: {}", e); + println!("failed to fetch data: {}", e); + FlagError::DataParsingError + } + _ => { + tracing::error!("Unknown redis error: {}", e); + FlagError::RedisUnavailable + } + })?; + + let flags_list: Vec = + serde_json::from_str(&serialized_flags).map_err(|e| { + tracing::error!("failed to parse data to flags list: {}", e); + println!("failed to parse data: {}", e); + + FlagError::DataParsingError + })?; + + Ok(FeatureFlagList { flags: flags_list }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{ + insert_flags_for_team_in_redis, insert_new_team_in_redis, setup_redis_client, + }; + + #[tokio::test] + async fn test_fetch_flags_from_redis() { + let client = setup_redis_client(None); + + let team = insert_new_team_in_redis(client.clone()) + .await + .expect("Failed to insert team"); + + insert_flags_for_team_in_redis(client.clone(), team.id, None) + .await + .expect("Failed to insert flags"); + + let flags_from_redis = FeatureFlagList::from_redis(client.clone(), team.id) + .await + .expect("Failed to fetch flags from redis"); + assert_eq!(flags_from_redis.flags.len(), 1); + let flag = flags_from_redis.flags.get(0).expect("Empty flags in redis"); + assert_eq!(flag.key, "flag1"); + assert_eq!(flag.team_id, team.id); + assert_eq!(flag.filters.groups.len(), 1); + assert_eq!( + flag.filters.groups[0] + .properties + .as_ref() + .expect("Properties don't exist on flag") + .len(), + 1 + ); + } + + #[tokio::test] + async fn test_fetch_invalid_team_from_redis() { + let client = setup_redis_client(None); + + match FeatureFlagList::from_redis(client.clone(), 1234).await { + Err(FlagError::TokenValidationError) => (), + _ => panic!("Expected TokenValidationError"), + }; + } + + #[tokio::test] + async fn test_cant_connect_to_redis_error_is_not_token_validation_error() { + let client = setup_redis_client(Some("redis://localhost:1111/".to_string())); + + match FeatureFlagList::from_redis(client.clone(), 1234).await { + Err(FlagError::RedisUnavailable) => (), + _ => panic!("Expected RedisUnavailable"), + }; + } +} diff --git a/rust/feature-flags/src/flag_matching.rs b/rust/feature-flags/src/flag_matching.rs new file mode 100644 index 0000000000000..510fc153dc87a --- /dev/null +++ b/rust/feature-flags/src/flag_matching.rs @@ -0,0 +1,160 @@ +use crate::flag_definitions::{FeatureFlag, FlagGroupType}; +use sha1::{Digest, Sha1}; +use std::fmt::Write; + +#[derive(Debug, PartialEq, Eq)] +pub struct FeatureFlagMatch { + pub matches: bool, + pub variant: Option, + //reason + //condition_index + //payload +} + +// TODO: Rework FeatureFlagMatcher - python has a pretty awkward interface, where we pass in all flags, and then again +// the flag to match. I don't think there's any reason anymore to store the flags in the matcher, since we can just +// pass the flag to match directly to the get_match method. This will also make the matcher more stateless. +// Potentially, we could also make the matcher a long-lived object, with caching for group keys and such. +// It just takes in the flag and distinct_id and returns the match... +// Or, make this fully stateless +// and have a separate cache struct for caching group keys, cohort definitions, etc. - and check size, if we can keep it in memory +// for all teams. If not, we can have a LRU cache, or a cache that stores only the most recent N keys. +// But, this can be a future refactor, for now just focusing on getting the basic matcher working, write lots and lots of tests +// and then we can easily refactor stuff around. +#[derive(Debug)] +pub struct FeatureFlagMatcher { + // pub flags: Vec, + pub distinct_id: String, +} + +const LONG_SCALE: u64 = 0xfffffffffffffff; + +impl FeatureFlagMatcher { + pub fn new(distinct_id: String) -> Self { + FeatureFlagMatcher { + // flags, + distinct_id, + } + } + + pub fn get_match(&self, feature_flag: &FeatureFlag) -> FeatureFlagMatch { + if self.hashed_identifier(feature_flag).is_none() { + return FeatureFlagMatch { + matches: false, + variant: None, + }; + } + + // TODO: super groups for early access + // TODO: Variant overrides condition sort + + for (index, condition) in feature_flag.get_conditions().iter().enumerate() { + let (is_match, _evaluation_reason) = + self.is_condition_match(feature_flag, condition, index); + + if is_match { + // TODO: This is a bit awkward, we should handle overrides only when variants exist. + let variant = match condition.variant.clone() { + Some(variant_override) => { + if feature_flag + .get_variants() + .iter() + .any(|v| v.key == variant_override) + { + Some(variant_override) + } else { + self.get_matching_variant(feature_flag) + } + } + None => self.get_matching_variant(feature_flag), + }; + + // let payload = self.get_matching_payload(is_match, variant, feature_flag); + return FeatureFlagMatch { + matches: true, + variant, + }; + } + } + FeatureFlagMatch { + matches: false, + variant: None, + } + } + + pub fn is_condition_match( + &self, + feature_flag: &FeatureFlag, + condition: &FlagGroupType, + _index: usize, + ) -> (bool, String) { + let rollout_percentage = condition.rollout_percentage.unwrap_or(100.0); + let mut condition_match = true; + if condition.properties.is_some() { + // TODO: Handle matching conditions + if !condition.properties.as_ref().unwrap().is_empty() { + condition_match = false; + } + } + + if !condition_match { + return (false, "NO_CONDITION_MATCH".to_string()); + } else if rollout_percentage == 100.0 { + // TODO: Check floating point schenanigans if any + return (true, "CONDITION_MATCH".to_string()); + } + + if self.get_hash(feature_flag, "") > (rollout_percentage / 100.0) { + return (false, "OUT_OF_ROLLOUT_BOUND".to_string()); + } + + (true, "CONDITION_MATCH".to_string()) + } + + pub fn hashed_identifier(&self, feature_flag: &FeatureFlag) -> Option { + if feature_flag.get_group_type_index().is_none() { + // TODO: Use hash key overrides for experience continuity + Some(self.distinct_id.clone()) + } else { + // TODO: Handle getting group key + Some("".to_string()) + } + } + + /// This function takes a identifier and a feature flag key and returns a float between 0 and 1. + /// Given the same identifier and key, it'll always return the same float. These floats are + /// uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic + /// we can do _hash(key, identifier) < 0.2 + pub fn get_hash(&self, feature_flag: &FeatureFlag, salt: &str) -> f64 { + // check if hashed_identifier is None + let hashed_identifier = self + .hashed_identifier(feature_flag) + .expect("hashed_identifier is None when computing hash"); + let hash_key = format!("{}.{}{}", feature_flag.key, hashed_identifier, salt); + let mut hasher = Sha1::new(); + hasher.update(hash_key.as_bytes()); + let result = hasher.finalize(); + // :TRICKY: Convert the first 15 characters of the digest to a hexadecimal string + let hex_str: String = result.iter().fold(String::new(), |mut acc, byte| { + let _ = write!(acc, "{:02x}", byte); + acc + })[..15] + .to_string(); + let hash_val = u64::from_str_radix(&hex_str, 16).unwrap(); + + hash_val as f64 / LONG_SCALE as f64 + } + + pub fn get_matching_variant(&self, feature_flag: &FeatureFlag) -> Option { + let hash = self.get_hash(feature_flag, "variant"); + let mut total_percentage = 0.0; + + for variant in feature_flag.get_variants() { + total_percentage += variant.rollout_percentage / 100.0; + if hash < total_percentage { + return Some(variant.key.clone()); + } + } + None + } +} diff --git a/rust/feature-flags/src/lib.rs b/rust/feature-flags/src/lib.rs new file mode 100644 index 0000000000000..7f03747b9ee6d --- /dev/null +++ b/rust/feature-flags/src/lib.rs @@ -0,0 +1,19 @@ +pub mod api; +pub mod config; +pub mod flag_definitions; +pub mod flag_matching; +pub mod property_matching; +pub mod redis; +pub mod router; +pub mod server; +pub mod team; +pub mod v0_endpoint; +pub mod v0_request; + +// Test modules don't need to be compiled with main binary +// #[cfg(test)] +// TODO: To use in integration tests, we need to compile with binary +// or make it a separate feature using cfg(feature = "integration-tests") +// and then use this feature only in tests. +// For now, ok to just include in binary +pub mod test_utils; diff --git a/rust/feature-flags/src/main.rs b/rust/feature-flags/src/main.rs new file mode 100644 index 0000000000000..980db6973893f --- /dev/null +++ b/rust/feature-flags/src/main.rs @@ -0,0 +1,39 @@ +use envconfig::Envconfig; +use tokio::signal; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +use feature_flags::config::Config; +use feature_flags::server::serve; + +async fn shutdown() { + let mut term = signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + + let mut interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt()) + .expect("failed to register SIGINT handler"); + + tokio::select! { + _ = term.recv() => {}, + _ = interrupt.recv() => {}, + }; + + tracing::info!("Shutting down gracefully..."); +} + +#[tokio::main] +async fn main() { + let config = Config::init_from_env().expect("Invalid configuration:"); + + // Basic logging for now: + // - stdout with a level configured by the RUST_LOG envvar (default=ERROR) + let log_layer = tracing_subscriber::fmt::layer().with_filter(EnvFilter::from_default_env()); + tracing_subscriber::registry().with(log_layer).init(); + + // Open the TCP port and start the server + let listener = tokio::net::TcpListener::bind(config.address) + .await + .expect("could not bind port"); + serve(config, listener, shutdown()).await +} diff --git a/rust/feature-flags/src/property_matching.rs b/rust/feature-flags/src/property_matching.rs new file mode 100644 index 0000000000000..9f7d9ea173963 --- /dev/null +++ b/rust/feature-flags/src/property_matching.rs @@ -0,0 +1,1647 @@ +use std::collections::HashMap; + +use crate::flag_definitions::{OperatorType, PropertyFilter}; +use regex::Regex; +use serde_json::Value; + +#[derive(Debug, PartialEq, Eq)] +pub enum FlagMatchingError { + ValidationError(String), + MissingProperty(String), + InconclusiveOperatorMatch, + InvalidRegexPattern, +} + +pub fn to_string_representation(value: &Value) -> String { + if value.is_string() { + return value + .as_str() + .expect("string slice should always exist for string value") + .to_string(); + } + value.to_string() +} + +pub fn to_f64_representation(value: &Value) -> Option { + if value.is_number() { + return value.as_f64(); + } + to_string_representation(value).parse::().ok() +} + +pub fn match_property( + property: &PropertyFilter, + matching_property_values: &HashMap, + partial_props: bool, +) -> Result { + // only looks for matches where key exists in override_property_values + // doesn't support operator is_not_set with partial_props + + if partial_props && !matching_property_values.contains_key(&property.key) { + return Err(FlagMatchingError::MissingProperty(format!( + "can't match properties without a value. Missing property: {}", + property.key + ))); + } + + let key = &property.key; + let operator = property.operator.clone().unwrap_or(OperatorType::Exact); + let value = &property.value; + let match_value = matching_property_values.get(key); + + match operator { + OperatorType::Exact | OperatorType::IsNot => { + let compute_exact_match = |value: &Value, override_value: &Value| -> bool { + if is_truthy_or_falsy_property_value(value) { + // Do boolean handling, such that passing in "true" or "True" or "false" or "False" as matching value is equivalent + let truthy = is_truthy_property_value(value); + return override_value.to_string().to_lowercase() + == truthy.to_string().to_lowercase(); + } + + if value.is_array() { + return value + .as_array() + .expect("expected array value") + .iter() + .map(|v| to_string_representation(v).to_lowercase()) + .collect::>() + .contains(&to_string_representation(override_value).to_lowercase()); + } + to_string_representation(value).to_lowercase() + == to_string_representation(override_value).to_lowercase() + }; + + if let Some(match_value) = match_value { + if operator == OperatorType::Exact { + Ok(compute_exact_match(value, match_value)) + } else { + Ok(!compute_exact_match(value, match_value)) + } + } else { + Ok(false) + } + } + OperatorType::IsSet => Ok(matching_property_values.contains_key(key)), + OperatorType::IsNotSet => { + if partial_props { + if matching_property_values.contains_key(key) { + Ok(false) + } else { + Err(FlagMatchingError::InconclusiveOperatorMatch) + } + } else { + Ok(!matching_property_values.contains_key(key)) + } + } + OperatorType::Icontains | OperatorType::NotIcontains => { + if let Some(match_value) = match_value { + // TODO: Check eq_ignore_ascii_case and to_ascii_lowercase + // see https://doc.rust-lang.org/std/string/struct.String.html#method.to_lowercase + // do we want to lowercase non-ascii stuff? + let is_contained = to_string_representation(match_value) + .to_lowercase() + .contains(&to_string_representation(value).to_lowercase()); + + if operator == OperatorType::Icontains { + Ok(is_contained) + } else { + Ok(!is_contained) + } + } else { + // When value doesn't exist, it's not a match + Ok(false) + } + } + OperatorType::Regex | OperatorType::NotRegex => { + if match_value.is_none() { + return Ok(false); + } + + let pattern = match Regex::new(&to_string_representation(value)) { + Ok(pattern) => pattern, + Err(_) => return Ok(false), + //TODO: Should we return Err here and handle elsewhere? + //Err(FlagMatchingError::InvalidRegexPattern) + // python just returns false here + }; + let haystack = to_string_representation(match_value.unwrap_or(&Value::Null)); + let match_ = pattern.find(&haystack); + + if operator == OperatorType::Regex { + Ok(match_.is_some()) + } else { + Ok(match_.is_none()) + } + } + OperatorType::Gt | OperatorType::Gte | OperatorType::Lt | OperatorType::Lte => { + if match_value.is_none() { + return Ok(false); + } + // TODO: Move towards only numeric matching of these operators??? + + let compare = |lhs: f64, rhs: f64, operator: OperatorType| -> bool { + match operator { + OperatorType::Gt => lhs > rhs, + OperatorType::Gte => lhs >= rhs, + OperatorType::Lt => lhs < rhs, + OperatorType::Lte => lhs <= rhs, + _ => false, + } + }; + + let parsed_value = match to_f64_representation(match_value.unwrap_or(&Value::Null)) { + Some(parsed_value) => parsed_value, + None => { + return Err(FlagMatchingError::ValidationError( + "value is not a number".to_string(), + )) + } + }; + + if let Some(override_value) = to_f64_representation(value) { + Ok(compare(parsed_value, override_value, operator)) + } else { + Err(FlagMatchingError::ValidationError( + "override value is not a number".to_string(), + )) + } + } + OperatorType::IsDateExact | OperatorType::IsDateAfter | OperatorType::IsDateBefore => { + // TODO: Handle date operators + Ok(false) + // let parsed_date = determine_parsed_date_for_property_matching(match_value); + + // if parsed_date.is_none() { + // return Ok(false); + // } + + // if let Some(override_value) = value.as_str() { + // let override_date = match parser::parse(override_value) { + // Ok(override_date) => override_date, + // Err(_) => return Ok(false), + // }; + + // match operator { + // OperatorType::IsDateBefore => Ok(override_date < parsed_date.unwrap()), + // OperatorType::IsDateAfter => Ok(override_date > parsed_date.unwrap()), + // _ => Ok(false), + // } + // } else { + // Ok(false) + // } + } + } +} + +fn is_truthy_or_falsy_property_value(value: &Value) -> bool { + if value.is_boolean() { + return true; + } + + if value.is_string() { + let parsed_value = value + .as_str() + .expect("expected string value") + .to_lowercase(); + return parsed_value == "true" || parsed_value == "false"; + } + + if value.is_array() { + return value + .as_array() + .expect("expected array value") + .iter() + .all(is_truthy_or_falsy_property_value); + } + + false +} + +fn is_truthy_property_value(value: &Value) -> bool { + if value.is_boolean() { + return value.as_bool().expect("expected boolean value"); + } + + if value.is_string() { + let parsed_value = value + .as_str() + .expect("expected string value") + .to_lowercase(); + return parsed_value == "true"; + } + + if value.is_array() { + return value + .as_array() + .expect("expected array value") + .iter() + .all(is_truthy_property_value); + } + + false +} + +/// Copy of https://github.com/PostHog/posthog/blob/master/posthog/queries/test/test_base.py#L35 +/// with some modifications to match Rust's behavior +/// and to test the match_property function +#[cfg(test)] +mod test_match_properties { + use super::*; + use serde_json::json; + + #[test] + fn test_match_properties_exact_with_partial_props() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: None, + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(""))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key2".to_string(), json!("value"))]), + true + ) + .is_err(), + true + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key2".to_string(), json!("value"))]), + true + ) + .err() + .expect("expected match to exist"), + FlagMatchingError::MissingProperty( + "can't match properties without a value. Missing property: key".to_string() + ) + ); + assert_eq!( + match_property(&property_a, &HashMap::from([]), true).is_err(), + true + ); + + let property_b = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: Some(OperatorType::Exact), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_c = PropertyFilter { + key: "key".to_string(), + value: json!(["value1", "value2", "value3"]), + operator: Some(OperatorType::Exact), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value1"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value4"))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key2".to_string(), json!("value"))]), + true + ) + .is_err(), + true + ); + } + + #[test] + fn test_match_properties_is_not() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: Some(OperatorType::IsNot), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(""))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + true + ); + + // partial mode returns error when key doesn't exist + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key2".to_string(), json!("value1"))]), + true + ) + .is_err(), + true + ); + + let property_c = PropertyFilter { + key: "key".to_string(), + value: json!(["value1", "value2", "value3"]), + operator: Some(OperatorType::IsNot), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value4"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value5"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value6"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(""))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value3"))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value1"))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key2".to_string(), json!("value1"))]), + true + ) + .is_err(), + true + ); + } + + #[test] + fn test_match_properties_is_set() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: Some(OperatorType::IsSet), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(""))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key2".to_string(), json!("value1"))]), + true + ) + .is_err(), + true + ); + + assert_eq!( + match_property(&property_a, &HashMap::from([]), true).is_err(), + true + ); + } + + #[test] + fn test_match_properties_icontains() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("valUe"), + operator: Some(OperatorType::Icontains), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("vaLue4"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("343tfvalue5"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("Alakazam"))]), + true + ) + .expect("expected match to exist"), + false + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(123))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_b = PropertyFilter { + key: "key".to_string(), + value: json!("3"), + operator: Some(OperatorType::Icontains), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(323))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("val3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("three"))]), + true + ) + .expect("expected match to exist"), + false + ); + } + + #[test] + fn test_match_properties_regex() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!(r"\.com$"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value.com"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("value2.com"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(".com343tfvalue5"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("Alakazam"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(123))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_b = PropertyFilter { + key: "key".to_string(), + value: json!("3"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("3"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(323))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("val3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("three"))]), + true + ) + .expect("expected match to exist"), + false + ); + + // invalid regex + let property_c = PropertyFilter { + key: "key".to_string(), + value: json!(r"?*"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("value2"))]), + true + ) + .expect("expected match to exist"), + false + ); + + // non string value + let property_d = PropertyFilter { + key: "key".to_string(), + value: json!(4), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("4"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!(4))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("expected match to exist"), + false + ); + } + + #[test] + fn test_match_properties_math_operators() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Gt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(2))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(3))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(0))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(-1))]), + true + ) + .expect("expected match to exist"), + false + ); + + // # we handle type mismatches so this should be true + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("23"))]), + true + ) + .expect("expected match to exist"), + true + ); + + let property_b = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Lt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(0))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(-1))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(-3))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(1))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("1"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!("3"))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_c = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Gte), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(1))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(2))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(0))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(-1))]), + true + ) + .expect("expected match to exist"), + false + ); + // # now we handle type mismatches so this should be true + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("3"))]), + true + ) + .expect("expected match to exist"), + true + ); + + let property_d = PropertyFilter { + key: "key".to_string(), + value: json!("43"), + operator: Some(OperatorType::Lt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("41"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("42"))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!(42))]), + true + ) + .expect("expected match to exist"), + true + ); + + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("43"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!("44"))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!(44))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_e = PropertyFilter { + key: "key".to_string(), + value: json!("30"), + operator: Some(OperatorType::Lt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_e, + &HashMap::from([("key".to_string(), json!("29"))]), + true + ) + .expect("expected match to exist"), + true + ); + + // # depending on the type of override, we adjust type comparison + // This is wonky, do we want to continue this behavior? :/ + // TODO: Come back to this + // assert_eq!( + // match_property( + // &property_e, + // &HashMap::from([("key".to_string(), json!("100"))]), + // true + // ) + // .expect("expected match to exist"), + // true + // ); + assert_eq!( + match_property( + &property_e, + &HashMap::from([("key".to_string(), json!(100))]), + true + ) + .expect("expected match to exist"), + false + ); + + // let property_f = PropertyFilter { + // key: "key".to_string(), + // value: json!("123aloha"), + // operator: Some(OperatorType::Gt), + // prop_type: "person".to_string(), + // group_type_index: None, + // }; + + // TODO: This test fails because 123aloha is not a number + // and currently we don't support string comparison.. + // assert_eq!( + // match_property( + // &property_f, + // &HashMap::from([("key".to_string(), json!("123"))]), + // true + // ) + // .expect("expected match to exist"), + // false + // ); + // assert_eq!( + // match_property( + // &property_f, + // &HashMap::from([("key".to_string(), json!(122))]), + // true + // ) + // .expect("expected match to exist"), + // false + // ); + + // # this turns into a string comparison + // TODO: Fix + // assert_eq!( + // match_property( + // &property_f, + // &HashMap::from([("key".to_string(), json!(129))]), + // true + // ) + // .expect("expected match to exist"), + // true + // ); + } + + #[test] + fn test_none_property_value_with_all_operators() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("null"), + operator: Some(OperatorType::IsNot), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + false + ); + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key".to_string(), json!("non"))]), + true + ) + .expect("expected match to exist"), + true + ); + + let property_b = PropertyFilter { + key: "key".to_string(), + value: json!(null), + operator: Some(OperatorType::IsSet), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_b, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + true + ); + + let property_c = PropertyFilter { + key: "key".to_string(), + value: json!("nu"), + operator: Some(OperatorType::Icontains), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + true + ); + assert_eq!( + match_property( + &property_c, + &HashMap::from([("key".to_string(), json!("smh"))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_d = PropertyFilter { + key: "key".to_string(), + value: json!("Nu"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_d, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + false + ); + + let property_d_upper_case = PropertyFilter { + key: "key".to_string(), + value: json!("Nu"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_d_upper_case, + &HashMap::from([("key".to_string(), json!(null))]), + true + ) + .expect("expected match to exist"), + false + ); + + // TODO: Fails because not a number + // let property_e = PropertyFilter { + // key: "key".to_string(), + // value: json!(1), + // operator: Some(OperatorType::Gt), + // prop_type: "person".to_string(), + // group_type_index: None, + // }; + + // assert_eq!( + // match_property(&property_e, &HashMap::from([("key".to_string(), json!(null))]), true) + // .expect("expected match to exist"), + // true + // ); + } + + #[test] + fn test_match_properties_all_operators_with_full_props() { + let property_a = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: None, + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_a, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode for non-existent keys"), + false + ); + assert_eq!( + match_property(&property_a, &HashMap::from([]), false), + Ok(false) + ); + + let property_exact = PropertyFilter { + key: "key".to_string(), + value: json!(["value1", "value2", "value3"]), + operator: Some(OperatorType::Exact), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_exact, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_is_set = PropertyFilter { + key: "key".to_string(), + value: json!("value"), + operator: Some(OperatorType::IsSet), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_is_set, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_is_not_set = PropertyFilter { + key: "key".to_string(), + value: json!(null), + operator: Some(OperatorType::IsNotSet), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_is_not_set, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + true + ); + assert_eq!( + match_property( + &property_is_not_set, + &HashMap::from([("key".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + // is not set with partial props returns false when key exists + assert_eq!( + match_property( + &property_is_not_set, + &HashMap::from([("key".to_string(), json!("value"))]), + true + ) + .expect("Expected no errors with full props mode"), + false + ); + // is not set returns error when key doesn't exist + assert_eq!( + match_property( + &property_is_not_set, + &HashMap::from([("key2".to_string(), json!("value"))]), + true + ) + .is_err(), + true + ); + + let property_icontains = PropertyFilter { + key: "key".to_string(), + value: json!("valUe"), + operator: Some(OperatorType::Icontains), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_icontains, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_not_icontains = PropertyFilter { + key: "key".to_string(), + value: json!("valUe"), + operator: Some(OperatorType::NotIcontains), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_not_icontains, + &HashMap::from([("key2".to_string(), json!("value"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_regex = PropertyFilter { + key: "key".to_string(), + value: json!(r"\.com$"), + operator: Some(OperatorType::Regex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_regex, + &HashMap::from([("key2".to_string(), json!("value.com"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_not_regex = PropertyFilter { + key: "key".to_string(), + value: json!(r"\.com$"), + operator: Some(OperatorType::NotRegex), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_not_regex, + &HashMap::from([("key2".to_string(), json!("value.com"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_gt = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Gt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_gt, + &HashMap::from([("key2".to_string(), json!(2))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_gte = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Gte), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_gte, + &HashMap::from([("key2".to_string(), json!(2))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_lt = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Lt), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_lt, + &HashMap::from([("key2".to_string(), json!(0))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + let property_lte = PropertyFilter { + key: "key".to_string(), + value: json!(1), + operator: Some(OperatorType::Lte), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_lte, + &HashMap::from([("key2".to_string(), json!(0))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + + // TODO: Handle date operators + let property_is_date_before = PropertyFilter { + key: "key".to_string(), + value: json!("2021-01-01"), + operator: Some(OperatorType::IsDateBefore), + prop_type: "person".to_string(), + group_type_index: None, + }; + + assert_eq!( + match_property( + &property_is_date_before, + &HashMap::from([("key2".to_string(), json!("2021-01-02"))]), + false + ) + .expect("Expected no errors with full props mode"), + false + ); + } +} diff --git a/rust/feature-flags/src/redis.rs b/rust/feature-flags/src/redis.rs new file mode 100644 index 0000000000000..89dde421d0abc --- /dev/null +++ b/rust/feature-flags/src/redis.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use redis::{AsyncCommands, RedisError}; +use thiserror::Error; +use tokio::time::timeout; + +// average for all commands is <10ms, check grafana +const REDIS_TIMEOUT_MILLISECS: u64 = 10; + +#[derive(Error, Debug)] +pub enum CustomRedisError { + #[error("Not found in redis")] + NotFound, + + #[error("Pickle error: {0}")] + PickleError(#[from] serde_pickle::Error), + + #[error("Redis error: {0}")] + Other(#[from] RedisError), + + #[error("Timeout error")] + Timeout(#[from] tokio::time::error::Elapsed), +} +/// A simple redis wrapper +/// Copied from capture/src/redis.rs. +/// TODO: Modify this to support hincrby + +#[async_trait] +pub trait Client { + // A very simplified wrapper, but works for our usage + async fn zrangebyscore(&self, k: String, min: String, max: String) -> Result>; + + async fn get(&self, k: String) -> Result; + async fn set(&self, k: String, v: String) -> Result<()>; +} + +pub struct RedisClient { + client: redis::Client, +} + +impl RedisClient { + pub fn new(addr: String) -> Result { + let client = redis::Client::open(addr)?; + + Ok(RedisClient { client }) + } +} + +#[async_trait] +impl Client for RedisClient { + async fn zrangebyscore(&self, k: String, min: String, max: String) -> Result> { + let mut conn = self.client.get_async_connection().await?; + + let results = conn.zrangebyscore(k, min, max); + let fut = timeout(Duration::from_secs(REDIS_TIMEOUT_MILLISECS), results).await?; + + Ok(fut?) + } + + async fn get(&self, k: String) -> Result { + let mut conn = self.client.get_async_connection().await?; + + let results = conn.get(k); + let fut: Result, RedisError> = + timeout(Duration::from_secs(REDIS_TIMEOUT_MILLISECS), results).await?; + + // return NotFound error when empty or not found + if match &fut { + Ok(v) => v.is_empty(), + Err(_) => false, + } { + return Err(CustomRedisError::NotFound); + } + + // TRICKY: We serialise data to json, then django pickles it. + // Here we deserialize the bytes using serde_pickle, to get the json string. + let string_response: String = serde_pickle::from_slice(&fut?, Default::default())?; + + Ok(string_response) + } + + async fn set(&self, k: String, v: String) -> Result<()> { + // TRICKY: We serialise data to json, then django pickles it. + // Here we serialize the json string to bytes using serde_pickle. + let bytes = serde_pickle::to_vec(&v, Default::default())?; + + let mut conn = self.client.get_async_connection().await?; + + let results = conn.set(k, bytes); + let fut = timeout(Duration::from_secs(REDIS_TIMEOUT_MILLISECS), results).await?; + + Ok(fut?) + } +} diff --git a/rust/feature-flags/src/router.rs b/rust/feature-flags/src/router.rs new file mode 100644 index 0000000000000..8824d44efdbde --- /dev/null +++ b/rust/feature-flags/src/router.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use axum::{routing::post, Router}; + +use crate::{redis::Client, v0_endpoint}; + +#[derive(Clone)] +pub struct State { + pub redis: Arc, + // TODO: Add pgClient when ready +} + +pub fn router(redis: Arc) -> Router { + let state = State { redis }; + + Router::new() + .route("/flags", post(v0_endpoint::flags).get(v0_endpoint::flags)) + .with_state(state) +} diff --git a/rust/feature-flags/src/server.rs b/rust/feature-flags/src/server.rs new file mode 100644 index 0000000000000..ffe6b0efb7068 --- /dev/null +++ b/rust/feature-flags/src/server.rs @@ -0,0 +1,31 @@ +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::net::TcpListener; + +use crate::config::Config; + +use crate::redis::RedisClient; +use crate::router; + +pub async fn serve(config: Config, listener: TcpListener, shutdown: F) +where + F: Future + Send + 'static, +{ + let redis_client = + Arc::new(RedisClient::new(config.redis_url).expect("failed to create redis client")); + + let app = router::router(redis_client); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + tracing::info!("listening on {:?}", listener.local_addr().unwrap()); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await + .unwrap() +} diff --git a/rust/feature-flags/src/team.rs b/rust/feature-flags/src/team.rs new file mode 100644 index 0000000000000..e872aa477968f --- /dev/null +++ b/rust/feature-flags/src/team.rs @@ -0,0 +1,140 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tracing::instrument; + +use crate::{ + api::FlagError, + redis::{Client, CustomRedisError}, +}; + +// TRICKY: This cache data is coming from django-redis. If it ever goes out of sync, we'll bork. +// TODO: Add integration tests across repos to ensure this doesn't happen. +pub const TEAM_TOKEN_CACHE_PREFIX: &str = "posthog:1:team_token:"; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Team { + pub id: i64, + pub name: String, + pub api_token: String, +} + +impl Team { + /// Validates a token, and returns a team if it exists. + + #[instrument(skip_all)] + pub async fn from_redis( + client: Arc, + token: String, + ) -> Result { + // TODO: Instead of failing here, i.e. if not in redis, fallback to pg + let serialized_team = client + .get(format!("{TEAM_TOKEN_CACHE_PREFIX}{}", token)) + .await + .map_err(|e| match e { + CustomRedisError::NotFound => FlagError::TokenValidationError, + CustomRedisError::PickleError(_) => { + tracing::error!("failed to fetch data: {}", e); + FlagError::DataParsingError + } + _ => { + tracing::error!("Unknown redis error: {}", e); + FlagError::RedisUnavailable + } + })?; + + // TODO: Consider an LRU cache for teams as well, with small TTL to skip redis/pg lookups + let team: Team = serde_json::from_str(&serialized_team).map_err(|e| { + tracing::error!("failed to parse data to team: {}", e); + FlagError::DataParsingError + })?; + + Ok(team) + } +} + +#[cfg(test)] +mod tests { + use rand::Rng; + use redis::AsyncCommands; + + use super::*; + use crate::{ + team, + test_utils::{insert_new_team_in_redis, random_string, setup_redis_client}, + }; + + #[tokio::test] + async fn test_fetch_team_from_redis() { + let client = setup_redis_client(None); + + let team = insert_new_team_in_redis(client.clone()).await.unwrap(); + + let target_token = team.api_token; + + let team_from_redis = Team::from_redis(client.clone(), target_token.clone()) + .await + .unwrap(); + assert_eq!(team_from_redis.api_token, target_token); + assert_eq!(team_from_redis.id, team.id); + } + + #[tokio::test] + async fn test_fetch_invalid_team_from_redis() { + let client = setup_redis_client(None); + + match Team::from_redis(client.clone(), "banana".to_string()).await { + Err(FlagError::TokenValidationError) => (), + _ => panic!("Expected TokenValidationError"), + }; + } + + #[tokio::test] + async fn test_cant_connect_to_redis_error_is_not_token_validation_error() { + let client = setup_redis_client(Some("redis://localhost:1111/".to_string())); + + match Team::from_redis(client.clone(), "banana".to_string()).await { + Err(FlagError::RedisUnavailable) => (), + _ => panic!("Expected RedisUnavailable"), + }; + } + + #[tokio::test] + async fn test_corrupted_data_in_redis_is_handled() { + // TODO: Extend this test with fallback to pg + let id = rand::thread_rng().gen_range(0..10_000_000); + let token = random_string("phc_", 12); + let team = Team { + id, + name: "team".to_string(), + api_token: token, + }; + let serialized_team = serde_json::to_string(&team).expect("Failed to serialise team"); + + // manually insert non-pickled data in redis + let client = + redis::Client::open("redis://localhost:6379/").expect("Failed to create redis client"); + let mut conn = client + .get_async_connection() + .await + .expect("Failed to get redis connection"); + conn.set::( + format!( + "{}{}", + team::TEAM_TOKEN_CACHE_PREFIX, + team.api_token.clone() + ), + serialized_team, + ) + .await + .expect("Failed to write data to redis"); + + // now get client connection for data + let client = setup_redis_client(None); + + match Team::from_redis(client.clone(), team.api_token.clone()).await { + Err(FlagError::DataParsingError) => (), + Err(other) => panic!("Expected DataParsingError, got {:?}", other), + Ok(_) => panic!("Expected DataParsingError"), + }; + } +} diff --git a/rust/feature-flags/src/test_utils.rs b/rust/feature-flags/src/test_utils.rs new file mode 100644 index 0000000000000..92bc8a4ff4494 --- /dev/null +++ b/rust/feature-flags/src/test_utils.rs @@ -0,0 +1,126 @@ +use anyhow::Error; +use serde_json::json; +use std::sync::Arc; + +use crate::{ + flag_definitions::{self, FeatureFlag}, + redis::{Client, RedisClient}, + team::{self, Team}, +}; +use rand::{distributions::Alphanumeric, Rng}; + +pub fn random_string(prefix: &str, length: usize) -> String { + let suffix: String = rand::thread_rng() + .sample_iter(Alphanumeric) + .take(length) + .map(char::from) + .collect(); + format!("{}{}", prefix, suffix) +} + +pub async fn insert_new_team_in_redis(client: Arc) -> Result { + let id = rand::thread_rng().gen_range(0..10_000_000); + let token = random_string("phc_", 12); + let team = Team { + id, + name: "team".to_string(), + api_token: token, + }; + + let serialized_team = serde_json::to_string(&team)?; + client + .set( + format!( + "{}{}", + team::TEAM_TOKEN_CACHE_PREFIX, + team.api_token.clone() + ), + serialized_team, + ) + .await?; + + Ok(team) +} + +pub async fn insert_flags_for_team_in_redis( + client: Arc, + team_id: i64, + json_value: Option, +) -> Result<(), Error> { + let payload = match json_value { + Some(value) => value, + None => json!([{ + "id": 1, + "key": "flag1", + "name": "flag1 description", + "active": true, + "deleted": false, + "team_id": team_id, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": "a@b.com", + "type": "person", + }, + ] + }, + ], + }, + }]) + .to_string(), + }; + + client + .set( + format!("{}{}", flag_definitions::TEAM_FLAGS_CACHE_PREFIX, team_id), + payload, + ) + .await?; + + Ok(()) +} + +pub fn setup_redis_client(url: Option) -> Arc { + let redis_url = match url { + Some(value) => value, + None => "redis://localhost:6379/".to_string(), + }; + let client = RedisClient::new(redis_url).expect("Failed to create redis client"); + Arc::new(client) +} + +pub fn create_flag_from_json(json_value: Option) -> Vec { + let payload = match json_value { + Some(value) => value, + None => json!([{ + "id": 1, + "key": "flag1", + "name": "flag1 description", + "active": true, + "deleted": false, + "team_id": 1, + "filters": { + "groups": [ + { + "properties": [ + { + "key": "email", + "value": "a@b.com", + "type": "person", + }, + ], + "rollout_percentage": 50, + }, + ], + }, + }]) + .to_string(), + }; + + let flags: Vec = + serde_json::from_str(&payload).expect("Failed to parse data to flags list"); + flags +} diff --git a/rust/feature-flags/src/v0_endpoint.rs b/rust/feature-flags/src/v0_endpoint.rs new file mode 100644 index 0000000000000..ba4bcef8fec47 --- /dev/null +++ b/rust/feature-flags/src/v0_endpoint.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; + +use axum::{debug_handler, Json}; +use bytes::Bytes; +// TODO: stream this instead +use axum::extract::{MatchedPath, Query, State}; +use axum::http::{HeaderMap, Method}; +use axum_client_ip::InsecureClientIp; +use tracing::instrument; + +use crate::{ + api::{FlagError, FlagsResponse}, + router, + v0_request::{FlagRequest, FlagsQueryParams}, +}; + +/// Feature flag evaluation endpoint. +/// Only supports a specific shape of data, and rejects any malformed data. + +#[instrument( + skip_all, + fields( + path, + token, + batch_size, + user_agent, + content_encoding, + content_type, + version, + compression, + historical_migration + ) +)] +#[debug_handler] +pub async fn flags( + state: State, + InsecureClientIp(ip): InsecureClientIp, + meta: Query, + headers: HeaderMap, + method: Method, + path: MatchedPath, + body: Bytes, +) -> Result, FlagError> { + let user_agent = headers + .get("user-agent") + .map_or("unknown", |v| v.to_str().unwrap_or("unknown")); + let content_encoding = headers + .get("content-encoding") + .map_or("unknown", |v| v.to_str().unwrap_or("unknown")); + + tracing::Span::current().record("user_agent", user_agent); + tracing::Span::current().record("content_encoding", content_encoding); + tracing::Span::current().record("version", meta.version.clone()); + tracing::Span::current().record("method", method.as_str()); + tracing::Span::current().record("path", path.as_str().trim_end_matches('/')); + tracing::Span::current().record("ip", ip.to_string()); + + let request = match headers + .get("content-type") + .map_or("", |v| v.to_str().unwrap_or("")) + { + "application/json" => { + tracing::Span::current().record("content_type", "application/json"); + FlagRequest::from_bytes(body) + } + ct => { + return Err(FlagError::RequestDecodingError(format!( + "unsupported content type: {}", + ct + ))); + } + }?; + + let token = request + .extract_and_verify_token(state.redis.clone()) + .await?; + + let distinct_id = request.extract_distinct_id()?; + + tracing::Span::current().record("token", &token); + tracing::Span::current().record("distinct_id", &distinct_id); + + tracing::debug!("request: {:?}", request); + + // TODO: Some actual processing for evaluating the feature flag + + Ok(Json(FlagsResponse { + error_while_computing_flags: false, + feature_flags: HashMap::from([ + ("beta-feature".to_string(), "variant-1".to_string()), + ("rollout-flag".to_string(), true.to_string()), + ]), + })) +} diff --git a/rust/feature-flags/src/v0_request.rs b/rust/feature-flags/src/v0_request.rs new file mode 100644 index 0000000000000..63b26b455f6f4 --- /dev/null +++ b/rust/feature-flags/src/v0_request.rs @@ -0,0 +1,138 @@ +use std::{collections::HashMap, sync::Arc}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::instrument; + +use crate::{api::FlagError, redis::Client, team::Team}; + +#[derive(Deserialize, Default)] +pub struct FlagsQueryParams { + #[serde(alias = "v")] + pub version: Option, +} + +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct FlagRequest { + #[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, + pub geoip_disable: Option, + #[serde(default)] + pub person_properties: Option>, + #[serde(default)] + pub groups: Option>, + // TODO: better type this since we know its going to be a nested json + #[serde(default)] + pub group_properties: Option>, + #[serde(alias = "$anon_distinct_id", skip_serializing_if = "Option::is_none")] + pub anon_distinct_id: Option, +} + +impl FlagRequest { + /// Takes a request payload and tries to read it. + /// Only supports base64 encoded payloads or uncompressed utf-8 as json. + #[instrument(skip_all)] + pub fn from_bytes(bytes: Bytes) -> Result { + tracing::debug!(len = bytes.len(), "decoding new request"); + // TODO: Add base64 decoding + let payload = String::from_utf8(bytes.into()).map_err(|e| { + tracing::error!("failed to decode body: {}", e); + FlagError::RequestDecodingError(String::from("invalid body encoding")) + })?; + + tracing::debug!(json = payload, "decoded event data"); + Ok(serde_json::from_str::(&payload)?) + } + + pub async fn extract_and_verify_token( + &self, + redis_client: Arc, + ) -> Result { + let token = match self { + FlagRequest { + token: Some(token), .. + } => token.to_string(), + _ => return Err(FlagError::NoTokenError), + }; + + // validate token + Team::from_redis(redis_client, token.clone()).await?; + + // TODO: fallback when token not found in redis + + Ok(token) + } + + pub fn extract_distinct_id(&self) -> Result { + let distinct_id = match &self.distinct_id { + None => return Err(FlagError::MissingDistinctId), + Some(id) => id, + }; + + match distinct_id.len() { + 0 => Err(FlagError::EmptyDistinctId), + 1..=200 => Ok(distinct_id.to_owned()), + _ => Ok(distinct_id.chars().take(200).collect()), + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::FlagError; + use crate::v0_request::FlagRequest; + use bytes::Bytes; + use serde_json::json; + + #[test] + fn empty_distinct_id_not_accepted() { + let json = json!({ + "distinct_id": "", + "token": "my_token1", + }); + let bytes = Bytes::from(json.to_string()); + + let flag_payload = FlagRequest::from_bytes(bytes).expect("failed to parse request"); + + match flag_payload.extract_distinct_id() { + Err(FlagError::EmptyDistinctId) => (), + _ => panic!("expected empty distinct id error"), + }; + } + + #[test] + fn too_large_distinct_id_is_truncated() { + let json = json!({ + "distinct_id": std::iter::repeat("a").take(210).collect::(), + "token": "my_token1", + }); + let bytes = Bytes::from(json.to_string()); + + let flag_payload = FlagRequest::from_bytes(bytes).expect("failed to parse request"); + + assert_eq!(flag_payload.extract_distinct_id().unwrap().len(), 200); + } + + #[test] + fn distinct_id_is_returned_correctly() { + let json = json!({ + "$distinct_id": "alakazam", + "token": "my_token1", + }); + let bytes = Bytes::from(json.to_string()); + + let flag_payload = FlagRequest::from_bytes(bytes).expect("failed to parse request"); + + match flag_payload.extract_distinct_id() { + Ok(id) => assert_eq!(id, "alakazam"), + _ => panic!("expected distinct id"), + }; + } +} diff --git a/rust/feature-flags/tests/common/mod.rs b/rust/feature-flags/tests/common/mod.rs new file mode 100644 index 0000000000000..c8644fe1f4542 --- /dev/null +++ b/rust/feature-flags/tests/common/mod.rs @@ -0,0 +1,71 @@ +use std::net::SocketAddr; +use std::str::FromStr; +use std::string::ToString; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use reqwest::header::CONTENT_TYPE; +use tokio::net::TcpListener; +use tokio::sync::Notify; + +use feature_flags::config::Config; +use feature_flags::server::serve; + +pub static DEFAULT_CONFIG: Lazy = Lazy::new(|| Config { + address: SocketAddr::from_str("127.0.0.1:0").unwrap(), + redis_url: "redis://localhost:6379/".to_string(), + write_database_url: "postgres://posthog:posthog@localhost:15432/test_database".to_string(), + read_database_url: "postgres://posthog:posthog@localhost:15432/test_database".to_string(), + max_concurrent_jobs: 1024, + max_pg_connections: 100, +}); + +pub struct ServerHandle { + pub addr: SocketAddr, + shutdown: Arc, +} + +impl ServerHandle { + pub async fn for_config(config: Config) -> ServerHandle { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let notify = Arc::new(Notify::new()); + let shutdown = notify.clone(); + + tokio::spawn(async move { + serve(config, listener, async move { notify.notified().await }).await + }); + ServerHandle { addr, shutdown } + } + + pub async fn send_flags_request>(&self, body: T) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("http://{:?}/flags", self.addr)) + .body(body) + .header(CONTENT_TYPE, "application/json") + .send() + .await + .expect("failed to send request") + } + + pub async fn send_invalid_header_for_flags_request>( + &self, + body: T, + ) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("http://{:?}/flags", self.addr)) + .body(body) + .header(CONTENT_TYPE, "xyz") + .send() + .await + .expect("failed to send request") + } +} + +impl Drop for ServerHandle { + fn drop(&mut self) { + self.shutdown.notify_one() + } +} diff --git a/rust/feature-flags/tests/test_flag_matching_consistency.rs b/rust/feature-flags/tests/test_flag_matching_consistency.rs new file mode 100644 index 0000000000000..4a24b0e16d50e --- /dev/null +++ b/rust/feature-flags/tests/test_flag_matching_consistency.rs @@ -0,0 +1,1209 @@ +/// These tests are common between all libraries doing local evaluation of feature flags. +/// This ensures there are no mismatches between implementations. +use feature_flags::flag_matching::{FeatureFlagMatch, FeatureFlagMatcher}; + +use feature_flags::test_utils::create_flag_from_json; +use serde_json::json; + +#[test] +fn it_is_consistent_with_rollout_calculation_for_simple_flags() { + let flags = create_flag_from_json(Some( + json!([{ + "id": 1, + "key": "simple-flag", + "name": "Simple flag", + "active": true, + "deleted": false, + "team_id": 1, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 45, + }, + ], + }, + }]) + .to_string(), + )); + + let results = vec![ + false, true, true, false, true, false, false, true, false, true, false, true, true, false, + true, false, false, false, true, true, false, true, false, false, true, false, true, true, + false, false, false, true, true, true, true, false, false, false, false, false, false, + true, true, false, true, true, false, false, false, true, true, false, false, false, false, + true, false, true, false, true, false, true, true, false, true, false, true, false, true, + true, false, false, true, false, false, true, false, true, false, false, true, false, + false, false, true, true, false, true, true, false, true, true, true, true, true, false, + true, true, false, false, true, true, true, true, false, false, true, false, true, true, + true, false, false, false, false, false, true, false, false, true, true, true, false, + false, true, false, true, false, false, true, false, false, false, false, false, false, + false, false, true, true, false, false, true, false, false, true, true, false, false, true, + false, true, false, true, true, true, false, false, false, true, false, false, false, + false, true, true, false, true, true, false, true, false, true, true, false, true, false, + true, true, true, false, true, false, false, true, true, false, true, false, true, true, + false, false, true, true, true, true, false, true, true, false, false, true, false, true, + false, false, true, true, false, true, false, true, false, false, false, false, false, + false, false, true, false, true, true, false, false, true, false, true, false, false, + false, true, false, true, false, false, false, true, false, false, true, false, true, true, + false, false, false, false, true, false, false, false, false, false, false, false, false, + false, false, false, false, false, true, true, false, true, false, true, true, false, true, + false, true, false, false, false, true, true, true, true, false, false, false, false, + false, true, true, true, false, false, true, true, false, false, false, false, false, true, + false, true, true, true, true, false, true, true, true, false, false, true, false, true, + false, false, true, true, true, false, true, false, false, false, true, true, false, true, + false, true, false, true, true, true, true, true, false, false, true, false, true, false, + true, true, true, false, true, false, true, true, false, true, true, true, true, true, + false, false, false, false, false, true, false, true, false, false, true, true, false, + false, false, true, false, true, true, true, true, false, false, false, false, true, true, + false, false, true, true, false, true, true, true, true, false, true, true, true, false, + false, true, true, false, false, true, false, false, true, false, false, false, false, + false, false, false, false, false, false, true, true, false, false, true, false, false, + true, false, true, false, false, true, false, false, false, false, false, false, true, + false, false, false, false, false, false, false, false, false, true, true, true, false, + false, false, true, false, true, false, false, false, true, false, false, false, false, + false, false, false, true, false, false, false, false, false, false, false, false, true, + false, true, false, true, true, true, false, false, false, true, true, true, false, true, + false, true, true, false, false, false, true, false, false, false, false, true, false, + true, false, true, true, false, true, false, false, false, true, false, false, true, true, + false, true, false, false, false, false, false, false, true, true, false, false, true, + false, false, true, true, true, false, false, false, true, false, false, false, false, + true, false, true, false, false, false, true, false, true, true, false, true, false, true, + false, true, false, false, true, false, false, true, false, true, false, true, false, true, + false, false, true, true, true, true, false, true, false, false, false, false, false, true, + false, false, true, false, false, true, true, false, false, false, false, true, true, true, + false, false, true, false, false, true, true, true, true, false, false, false, true, false, + false, false, true, false, false, true, true, true, true, false, false, true, true, false, + true, false, true, false, false, true, true, false, true, true, true, true, false, false, + true, false, false, true, true, false, true, false, true, false, false, true, false, false, + false, false, true, true, true, false, true, false, false, true, false, false, true, false, + false, false, false, true, false, true, false, true, true, false, false, true, false, true, + true, true, false, false, false, false, true, true, false, true, false, false, false, true, + false, false, false, false, true, true, true, false, false, false, true, true, true, true, + false, true, true, false, true, true, true, false, true, false, false, true, false, true, + true, true, true, false, true, false, true, false, true, false, false, true, true, false, + false, true, false, true, false, false, false, false, true, false, true, false, false, + false, true, true, true, false, false, false, true, false, true, true, false, false, false, + false, false, true, false, true, false, false, true, true, false, true, true, true, true, + false, false, true, false, false, true, false, true, false, true, true, false, false, + false, true, false, true, true, false, false, false, true, false, true, false, true, true, + false, true, false, false, true, false, false, false, true, true, true, false, false, + false, false, false, true, false, false, true, true, true, true, true, false, false, false, + false, false, false, false, false, true, true, true, false, false, true, true, false, true, + true, false, true, false, true, false, false, false, true, false, false, true, false, + false, true, true, true, true, false, false, true, false, true, true, false, false, true, + false, false, true, true, false, true, false, false, true, true, true, false, false, false, + false, false, true, false, true, false, false, false, false, false, true, true, false, + true, true, true, false, false, false, false, true, true, true, true, false, true, true, + false, true, false, true, false, true, false, false, false, false, true, true, true, true, + false, false, true, false, true, true, false, false, false, false, false, false, true, + false, true, false, true, true, false, false, true, true, true, true, false, false, true, + false, true, true, false, false, true, true, true, false, true, false, false, true, true, + false, false, false, true, false, false, true, false, false, false, true, true, true, true, + false, true, false, true, false, true, false, true, false, false, true, false, false, true, + false, true, true, + ]; + + for i in 0..1000 { + let distinct_id = format!("distinct_id_{}", i); + + let feature_flag_match = FeatureFlagMatcher::new(distinct_id).get_match(&flags[0]); + + if results[i] { + assert_eq!( + feature_flag_match, + FeatureFlagMatch { + matches: true, + variant: None, + } + ); + } else { + assert_eq!( + feature_flag_match, + FeatureFlagMatch { + matches: false, + variant: None, + } + ); + } + } +} + +#[test] +fn it_is_consistent_with_rollout_calculation_for_multivariate_flags() { + let flags = create_flag_from_json(Some( + json!([{ + "id": 1, + "key": "multivariate-flag", + "name": "Multivariate flag", + "active": true, + "deleted": false, + "team_id": 1, + "filters": { + "groups": [ + { + "properties": [], + "rollout_percentage": 55, + }, + ], + "multivariate": { + "variants": [ + { + "key": "first-variant", + "name": "First Variant", + "rollout_percentage": 50, + }, + { + "key": "second-variant", + "name": "Second Variant", + "rollout_percentage": 20, + }, + { + "key": "third-variant", + "name": "Third Variant", + "rollout_percentage": 20, + }, + { + "key": "fourth-variant", + "name": "Fourth Variant", + "rollout_percentage": 5, + }, + { + "key": "fifth-variant", + "name": "Fifth Variant", + "rollout_percentage": 5, + }, + ], + }, + }, + }]) + .to_string(), + )); + + let results = vec![ + Some("second-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("fourth-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + None, + None, + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + Some("fifth-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + None, + None, + None, + None, + Some("third-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("second-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("fifth-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("third-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + Some("fourth-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("fifth-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + Some("fifth-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("fifth-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("fourth-variant".to_string()), + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + None, + None, + Some("third-variant".to_string()), + Some("fourth-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + None, + Some("fourth-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("fourth-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("fourth-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("fourth-variant".to_string()), + None, + None, + None, + Some("fourth-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + None, + None, + Some("fifth-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + None, + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + None, + None, + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("second-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + None, + None, + None, + None, + None, + Some("fourth-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("fifth-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("fifth-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("third-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + Some("second-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + None, + None, + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + Some("second-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + None, + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("fourth-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("fifth-variant".to_string()), + None, + None, + None, + Some("second-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("second-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + None, + None, + Some("first-variant".to_string()), + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("third-variant".to_string()), + Some("second-variant".to_string()), + Some("third-variant".to_string()), + None, + None, + Some("third-variant".to_string()), + Some("first-variant".to_string()), + None, + Some("first-variant".to_string()), + ]; + + for i in 0..1000 { + let distinct_id = format!("distinct_id_{}", i); + + let feature_flag_match = FeatureFlagMatcher::new(distinct_id).get_match(&flags[0]); + + if results[i].is_some() { + assert_eq!( + feature_flag_match, + FeatureFlagMatch { + matches: true, + variant: results[i].clone(), + } + ); + } else { + assert_eq!( + feature_flag_match, + FeatureFlagMatch { + matches: false, + variant: None, + } + ); + } + } +} diff --git a/rust/feature-flags/tests/test_flags.rs b/rust/feature-flags/tests/test_flags.rs new file mode 100644 index 0000000000000..2ceba24efd712 --- /dev/null +++ b/rust/feature-flags/tests/test_flags.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use assert_json_diff::assert_json_include; + +use reqwest::StatusCode; +use serde_json::{json, Value}; + +use crate::common::*; + +use feature_flags::test_utils::{insert_new_team_in_redis, setup_redis_client}; + +pub mod common; + +#[tokio::test] +async fn it_sends_flag_request() -> Result<()> { + let config = DEFAULT_CONFIG.clone(); + + let distinct_id = "user_distinct_id".to_string(); + + let client = setup_redis_client(Some(config.redis_url.clone())); + let team = insert_new_team_in_redis(client.clone()).await.unwrap(); + let token = team.api_token; + + let server = ServerHandle::for_config(config).await; + + let payload = json!({ + "token": token, + "distinct_id": distinct_id, + "groups": {"group1": "group1"} + }); + let res = server.send_flags_request(payload.to_string()).await; + assert_eq!(StatusCode::OK, res.status()); + + // We don't want to deserialize the data into a flagResponse struct here, + // because we want to assert the shape of the raw json data. + let json_data = res.json::().await?; + + assert_json_include!( + actual: json_data, + expected: json!({ + "errorWhileComputingFlags": false, + "featureFlags": { + "beta-feature": "variant-1", + "rollout-flag": "true", + } + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn it_rejects_invalid_headers_flag_request() -> Result<()> { + let config = DEFAULT_CONFIG.clone(); + + let distinct_id = "user_distinct_id".to_string(); + + let client = setup_redis_client(Some(config.redis_url.clone())); + let team = insert_new_team_in_redis(client.clone()).await.unwrap(); + let token = team.api_token; + + let server = ServerHandle::for_config(config).await; + + let payload = json!({ + "token": token, + "distinct_id": distinct_id, + "groups": {"group1": "group1"} + }); + let res = server + .send_invalid_header_for_flags_request(payload.to_string()) + .await; + assert_eq!(StatusCode::BAD_REQUEST, res.status()); + + // We don't want to deserialize the data into a flagResponse struct here, + // because we want to assert the shape of the raw json data. + let response_text = res.text().await?; + + assert_eq!( + response_text, + "failed to decode request: unsupported content type: xyz" + ); + + Ok(()) +} diff --git a/rust/hook-api/Cargo.toml b/rust/hook-api/Cargo.toml new file mode 100644 index 0000000000000..eb82438c47d65 --- /dev/null +++ b/rust/hook-api/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "hook-api" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +envconfig = { workspace = true } +eyre = { workspace = true } +hook-common = { path = "../hook-common" } +http-body-util = { workspace = true } +metrics = { workspace = true } +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { workspace = true } diff --git a/rust/hook-api/src/config.rs b/rust/hook-api/src/config.rs new file mode 100644 index 0000000000000..e15f0d3fac77a --- /dev/null +++ b/rust/hook-api/src/config.rs @@ -0,0 +1,28 @@ +use envconfig::Envconfig; + +#[derive(Envconfig)] +pub struct Config { + #[envconfig(from = "BIND_HOST", default = "0.0.0.0")] + pub host: String, + + #[envconfig(from = "BIND_PORT", default = "3300")] + pub port: u16, + + #[envconfig(default = "postgres://posthog:posthog@localhost:15432/test_database")] + pub database_url: String, + + #[envconfig(default = "default")] + pub queue_name: String, + + #[envconfig(default = "100")] + pub max_pg_connections: u32, + + #[envconfig(default = "5000000")] + pub max_body_size: usize, +} + +impl Config { + pub fn bind(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} diff --git a/rust/hook-api/src/handlers/app.rs b/rust/hook-api/src/handlers/app.rs new file mode 100644 index 0000000000000..7cbbc449e424d --- /dev/null +++ b/rust/hook-api/src/handlers/app.rs @@ -0,0 +1,53 @@ +use axum::{routing, Router}; +use tower_http::limit::RequestBodyLimitLayer; + +use hook_common::pgqueue::PgQueue; + +use super::webhook; + +pub fn add_routes(router: Router, pg_pool: PgQueue, max_body_size: usize) -> Router { + router + .route("/", routing::get(index)) + .route("/_readiness", routing::get(index)) + .route("/_liveness", routing::get(index)) // No async loop for now, just check axum health + .route( + "/webhook", + routing::post(webhook::post) + .with_state(pg_pool) + .layer(RequestBodyLimitLayer::new(max_body_size)), + ) +} + +pub async fn index() -> &'static str { + "rusty-hook api" +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use hook_common::pgqueue::PgQueue; + use http_body_util::BodyExt; // for `collect` + use sqlx::PgPool; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + #[sqlx::test(migrations = "../migrations")] + async fn index(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, 1_000_000); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"rusty-hook api"); + } +} diff --git a/rust/hook-api/src/handlers/mod.rs b/rust/hook-api/src/handlers/mod.rs new file mode 100644 index 0000000000000..e392f8a9b67f7 --- /dev/null +++ b/rust/hook-api/src/handlers/mod.rs @@ -0,0 +1,4 @@ +mod app; +mod webhook; + +pub use app::add_routes; diff --git a/rust/hook-api/src/handlers/webhook.rs b/rust/hook-api/src/handlers/webhook.rs new file mode 100644 index 0000000000000..808c94878291b --- /dev/null +++ b/rust/hook-api/src/handlers/webhook.rs @@ -0,0 +1,279 @@ +use std::time::Instant; + +use axum::{extract::State, http::StatusCode, Json}; +use hook_common::webhook::{WebhookJobMetadata, WebhookJobParameters}; +use serde_derive::Deserialize; +use url::Url; + +use hook_common::pgqueue::{NewJob, PgQueue}; +use serde::Serialize; +use tracing::{debug, error}; + +#[derive(Serialize, Deserialize)] +pub struct WebhookPostResponse { + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// The body of a request made to create a webhook Job. +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct WebhookPostRequestBody { + parameters: WebhookJobParameters, + metadata: WebhookJobMetadata, + + #[serde(default = "default_max_attempts")] + max_attempts: u32, +} + +fn default_max_attempts() -> u32 { + 3 +} + +pub async fn post( + State(pg_queue): State, + Json(payload): Json, +) -> Result, (StatusCode, Json)> { + debug!("received payload: {:?}", payload); + + let url_hostname = get_hostname(&payload.parameters.url)?; + // We could cast to i32, but this ensures we are not wrapping. + let max_attempts = i32::try_from(payload.max_attempts).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(WebhookPostResponse { + error: Some("invalid number of max attempts".to_owned()), + }), + ) + })?; + let job = NewJob::new( + max_attempts, + payload.metadata, + payload.parameters, + url_hostname.as_str(), + ); + + let start_time = Instant::now(); + + pg_queue.enqueue(job).await.map_err(internal_error)?; + + let elapsed_time = start_time.elapsed().as_secs_f64(); + metrics::histogram!("webhook_api_enqueue").record(elapsed_time); + + Ok(Json(WebhookPostResponse { error: None })) +} + +fn internal_error(err: E) -> (StatusCode, Json) +where + E: std::error::Error, +{ + error!("internal error: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(WebhookPostResponse { + error: Some(err.to_string()), + }), + ) +} + +fn get_hostname(url_str: &str) -> Result)> { + let url = Url::parse(url_str).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(WebhookPostResponse { + error: Some("could not parse url".to_owned()), + }), + ) + })?; + + match url.host_str() { + Some(hostname) => Ok(hostname.to_owned()), + None => Err(( + StatusCode::BAD_REQUEST, + Json(WebhookPostResponse { + error: Some("couldn't extract hostname from url".to_owned()), + }), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::{ + body::Body, + http::{self, Request, StatusCode}, + Router, + }; + use hook_common::pgqueue::PgQueue; + use hook_common::webhook::{HttpMethod, WebhookJobParameters}; + use http_body_util::BodyExt; + use sqlx::PgPool; // for `collect` + use std::collections; + use tower::ServiceExt; // for `call`, `oneshot`, and `ready` + + use crate::handlers::app::add_routes; + + const MAX_BODY_SIZE: usize = 1_000_000; + + #[sqlx::test(migrations = "../migrations")] + async fn webhook_success(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, MAX_BODY_SIZE); + + let mut headers = collections::HashMap::new(); + headers.insert("Content-Type".to_owned(), "application/json".to_owned()); + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/webhook") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&WebhookPostRequestBody { + parameters: WebhookJobParameters { + headers, + method: HttpMethod::POST, + url: "http://example.com/".to_owned(), + body: r#"{"a": "b"}"#.to_owned(), + }, + metadata: WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }, + max_attempts: 1, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"{}"); + } + + #[sqlx::test(migrations = "../migrations")] + async fn webhook_bad_url(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, MAX_BODY_SIZE); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/webhook") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&WebhookPostRequestBody { + parameters: WebhookJobParameters { + headers: collections::HashMap::new(), + method: HttpMethod::POST, + url: "invalid".to_owned(), + body: r#"{"a": "b"}"#.to_owned(), + }, + metadata: WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }, + max_attempts: 1, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "../migrations")] + async fn webhook_payload_missing_fields(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, MAX_BODY_SIZE); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/webhook") + .header(http::header::CONTENT_TYPE, "application/json") + .body("{}".to_owned()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[sqlx::test(migrations = "../migrations")] + async fn webhook_payload_not_json(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, MAX_BODY_SIZE); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/webhook") + .header(http::header::CONTENT_TYPE, "application/json") + .body("x".to_owned()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[sqlx::test(migrations = "../migrations")] + async fn webhook_payload_body_too_large(db: PgPool) { + let pg_queue = PgQueue::new_from_pool("test_index", db).await; + + let app = add_routes(Router::new(), pg_queue, MAX_BODY_SIZE); + + let bytes: Vec = vec![b'a'; MAX_BODY_SIZE + 1]; + let long_string = String::from_utf8_lossy(&bytes); + + let response = app + .oneshot( + Request::builder() + .method(http::Method::POST) + .uri("/webhook") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_string(&WebhookPostRequestBody { + parameters: WebhookJobParameters { + headers: collections::HashMap::new(), + method: HttpMethod::POST, + url: "http://example.com".to_owned(), + body: long_string.to_string(), + }, + metadata: WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }, + max_attempts: 1, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} diff --git a/rust/hook-api/src/main.rs b/rust/hook-api/src/main.rs new file mode 100644 index 0000000000000..ad05edef1ff98 --- /dev/null +++ b/rust/hook-api/src/main.rs @@ -0,0 +1,44 @@ +use axum::Router; +use config::Config; +use envconfig::Envconfig; +use eyre::Result; + +use hook_common::metrics::setup_metrics_routes; +use hook_common::pgqueue::PgQueue; + +mod config; +mod handlers; + +async fn listen(app: Router, bind: String) -> Result<()> { + let listener = tokio::net::TcpListener::bind(bind).await?; + + axum::serve(listener, app).await?; + + Ok(()) +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let config = Config::init_from_env().expect("failed to load configuration from env"); + + let pg_queue = PgQueue::new( + // TODO: Coupling the queue name to the PgQueue object doesn't seem ideal from the api + // side, but we don't need more than one queue for now. + &config.queue_name, + &config.database_url, + config.max_pg_connections, + "hook-api", + ) + .await + .expect("failed to initialize queue"); + + let app = handlers::add_routes(Router::new(), pg_queue, config.max_body_size); + let app = setup_metrics_routes(app); + + match listen(app, config.bind()).await { + Ok(_) => {} + Err(e) => tracing::error!("failed to start hook-api http server, {}", e), + } +} diff --git a/rust/hook-common/Cargo.toml b/rust/hook-common/Cargo.toml new file mode 100644 index 0000000000000..58232a80fe17d --- /dev/null +++ b/rust/hook-common/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hook-common" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +axum = { workspace = true, features = ["http2"] } +chrono = { workspace = true } +http = { workspace = true } +metrics = { workspace = true } +metrics-exporter-prometheus = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } # We need a runtime for async tests diff --git a/rust/hook-common/README.md b/rust/hook-common/README.md new file mode 100644 index 0000000000000..d277a6c8600c9 --- /dev/null +++ b/rust/hook-common/README.md @@ -0,0 +1,2 @@ +# hook-common +Library of common utilities used by rusty-hook. diff --git a/rust/hook-common/src/kafka_messages/app_metrics.rs b/rust/hook-common/src/kafka_messages/app_metrics.rs new file mode 100644 index 0000000000000..f941f58138b76 --- /dev/null +++ b/rust/hook-common/src/kafka_messages/app_metrics.rs @@ -0,0 +1,208 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use uuid::Uuid; + +use super::{deserialize_datetime, serialize_datetime}; + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub enum AppMetricCategory { + ProcessEvent, + OnEvent, + ScheduledTask, + Webhook, + ComposeWebhook, +} + +// NOTE: These are stored in Postgres and deserialized by the cleanup/janitor process, so these +// names need to remain stable, or new variants need to be deployed to the cleanup/janitor +// process before they are used. +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub enum ErrorType { + TimeoutError, + ConnectionError, + BadHttpStatus(u16), + ParseError, +} + +// NOTE: This is stored in Postgres and deserialized by the cleanup/janitor process, so this +// shouldn't change. It is intended to replicate the shape of `error_details` used in the +// plugin-server and by the frontend. +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct ErrorDetails { + pub error: Error, +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct Error { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + // This field will only be useful if we start running plugins in Rust (via a WASM runtime or + // something) and want to provide the user with stack traces like we do for TypeScript plugins. + #[serde(skip_serializing_if = "Option::is_none")] + pub stack: Option, +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct AppMetric { + #[serde( + serialize_with = "serialize_datetime", + deserialize_with = "deserialize_datetime" + )] + pub timestamp: DateTime, + pub team_id: u32, + pub plugin_config_id: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_id: Option, + #[serde( + serialize_with = "serialize_category", + deserialize_with = "deserialize_category" + )] + pub category: AppMetricCategory, + pub successes: u32, + pub successes_on_retry: u32, + pub failures: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_uuid: Option, + #[serde( + serialize_with = "serialize_error_type", + deserialize_with = "deserialize_error_type", + default, + skip_serializing_if = "Option::is_none" + )] + pub error_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_details: Option, +} + +fn serialize_category(category: &AppMetricCategory, serializer: S) -> Result +where + S: Serializer, +{ + let category_str = match category { + AppMetricCategory::ProcessEvent => "processEvent", + AppMetricCategory::OnEvent => "onEvent", + AppMetricCategory::ScheduledTask => "scheduledTask", + AppMetricCategory::Webhook => "webhook", + AppMetricCategory::ComposeWebhook => "composeWebhook", + }; + serializer.serialize_str(category_str) +} + +fn deserialize_category<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + + let category = match &s[..] { + "processEvent" => AppMetricCategory::ProcessEvent, + "onEvent" => AppMetricCategory::OnEvent, + "scheduledTask" => AppMetricCategory::ScheduledTask, + "webhook" => AppMetricCategory::Webhook, + "composeWebhook" => AppMetricCategory::ComposeWebhook, + _ => { + return Err(serde::de::Error::unknown_variant( + &s, + &[ + "processEvent", + "onEvent", + "scheduledTask", + "webhook", + "composeWebhook", + ], + )) + } + }; + + Ok(category) +} + +fn serialize_error_type(error_type: &Option, serializer: S) -> Result +where + S: Serializer, +{ + let error_type = match error_type { + Some(error_type) => error_type, + None => return serializer.serialize_none(), + }; + + let error_type = match error_type { + ErrorType::ConnectionError => "Connection Error".to_owned(), + ErrorType::TimeoutError => "Timeout Error".to_owned(), + ErrorType::BadHttpStatus(s) => format!("Bad HTTP Status: {}", s), + ErrorType::ParseError => "Parse Error".to_owned(), + }; + serializer.serialize_str(&error_type) +} + +fn deserialize_error_type<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + let error_type = match opt { + Some(s) => { + let error_type = match &s[..] { + "Connection Error" => ErrorType::ConnectionError, + "Timeout Error" => ErrorType::TimeoutError, + _ if s.starts_with("Bad HTTP Status:") => { + let status = &s["Bad HTTP Status:".len()..]; + ErrorType::BadHttpStatus(status.parse().map_err(serde::de::Error::custom)?) + } + "Parse Error" => ErrorType::ParseError, + _ => { + return Err(serde::de::Error::unknown_variant( + &s, + &[ + "Connection Error", + "Timeout Error", + "Bad HTTP Status: ", + "Parse Error", + ], + )) + } + }; + Some(error_type) + } + None => None, + }; + + Ok(error_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_metric_serialization() { + use chrono::prelude::*; + + let app_metric = AppMetric { + timestamp: Utc.with_ymd_and_hms(2023, 12, 14, 12, 2, 0).unwrap(), + team_id: 123, + plugin_config_id: 456, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 10, + successes_on_retry: 0, + failures: 2, + error_uuid: Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655447777").unwrap()), + error_type: Some(ErrorType::ConnectionError), + error_details: Some(ErrorDetails { + error: Error { + name: "FooError".to_owned(), + message: Some("Error Message".to_owned()), + stack: None, + }, + }), + }; + + let serialized_json = serde_json::to_string(&app_metric).unwrap(); + + let expected_json = r#"{"timestamp":"2023-12-14 12:02:00","team_id":123,"plugin_config_id":456,"category":"webhook","successes":10,"successes_on_retry":0,"failures":2,"error_uuid":"550e8400-e29b-41d4-a716-446655447777","error_type":"Connection Error","error_details":{"error":{"name":"FooError","message":"Error Message"}}}"#; + + assert_eq!(serialized_json, expected_json); + } +} diff --git a/rust/hook-common/src/kafka_messages/mod.rs b/rust/hook-common/src/kafka_messages/mod.rs new file mode 100644 index 0000000000000..f548563af5ba1 --- /dev/null +++ b/rust/hook-common/src/kafka_messages/mod.rs @@ -0,0 +1,25 @@ +pub mod app_metrics; +pub mod plugin_logs; + +use chrono::{DateTime, NaiveDateTime, Utc}; +use serde::{Deserialize, Deserializer, Serializer}; + +pub fn serialize_datetime(datetime: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&datetime.format("%Y-%m-%d %H:%M:%S").to_string()) +} + +pub fn deserialize_datetime<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let formatted: String = Deserialize::deserialize(deserializer)?; + let datetime = match NaiveDateTime::parse_from_str(&formatted, "%Y-%m-%d %H:%M:%S") { + Ok(d) => d.and_utc(), + Err(_) => return Err(serde::de::Error::custom("Invalid datetime format")), + }; + + Ok(datetime) +} diff --git a/rust/hook-common/src/kafka_messages/plugin_logs.rs b/rust/hook-common/src/kafka_messages/plugin_logs.rs new file mode 100644 index 0000000000000..039788afe2dc5 --- /dev/null +++ b/rust/hook-common/src/kafka_messages/plugin_logs.rs @@ -0,0 +1,126 @@ +use chrono::{DateTime, Utc}; +use serde::{Serialize, Serializer}; +use uuid::Uuid; + +use super::serialize_datetime; + +#[derive(Serialize)] +pub enum PluginLogEntrySource { + System, + Plugin, + Console, +} + +#[derive(Serialize)] +pub enum PluginLogEntryType { + Debug, + Log, + Info, + Warn, + Error, +} + +#[derive(Serialize)] +pub struct PluginLogEntry { + #[serde(serialize_with = "serialize_source")] + pub source: PluginLogEntrySource, + #[serde(rename = "type", serialize_with = "serialize_type")] + pub type_: PluginLogEntryType, + pub id: Uuid, + pub team_id: u32, + pub plugin_id: i32, + pub plugin_config_id: i32, + #[serde(serialize_with = "serialize_datetime")] + pub timestamp: DateTime, + #[serde(serialize_with = "serialize_message")] + pub message: String, + pub instance_id: Uuid, +} + +fn serialize_source(source: &PluginLogEntrySource, serializer: S) -> Result +where + S: Serializer, +{ + let source_str = match source { + PluginLogEntrySource::System => "SYSTEM", + PluginLogEntrySource::Plugin => "PLUGIN", + PluginLogEntrySource::Console => "CONSOLE", + }; + serializer.serialize_str(source_str) +} + +fn serialize_type(type_: &PluginLogEntryType, serializer: S) -> Result +where + S: Serializer, +{ + let type_str = match type_ { + PluginLogEntryType::Debug => "DEBUG", + PluginLogEntryType::Log => "LOG", + PluginLogEntryType::Info => "INFO", + PluginLogEntryType::Warn => "WARN", + PluginLogEntryType::Error => "ERROR", + }; + serializer.serialize_str(type_str) +} + +fn serialize_message(msg: &str, serializer: S) -> Result +where + S: Serializer, +{ + if msg.len() > 50_000 { + return Err(serde::ser::Error::custom( + "Message is too long for ClickHouse", + )); + } + + serializer.serialize_str(msg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_log_entry_serialization() { + use chrono::prelude::*; + + let log_entry = PluginLogEntry { + source: PluginLogEntrySource::Plugin, + type_: PluginLogEntryType::Warn, + id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), + team_id: 4, + plugin_id: 5, + plugin_config_id: 6, + timestamp: Utc.with_ymd_and_hms(2023, 12, 14, 12, 2, 0).unwrap(), + message: "My message!".to_string(), + instance_id: Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(), + }; + + let serialized_json = serde_json::to_string(&log_entry).unwrap(); + + assert_eq!( + serialized_json, + r#"{"source":"PLUGIN","type":"WARN","id":"550e8400-e29b-41d4-a716-446655440000","team_id":4,"plugin_id":5,"plugin_config_id":6,"timestamp":"2023-12-14 12:02:00","message":"My message!","instance_id":"00000000-0000-0000-0000-000000000000"}"# + ); + } + + #[test] + fn test_plugin_log_entry_message_too_long() { + use chrono::prelude::*; + + let log_entry = PluginLogEntry { + source: PluginLogEntrySource::Plugin, + type_: PluginLogEntryType::Warn, + id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), + team_id: 4, + plugin_id: 5, + plugin_config_id: 6, + timestamp: Utc.with_ymd_and_hms(2023, 12, 14, 12, 2, 0).unwrap(), + message: "My message!".repeat(10_000).to_string(), + instance_id: Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(), + }; + + let err = serde_json::to_string(&log_entry).unwrap_err(); + assert_eq!(err.to_string(), "Message is too long for ClickHouse"); + } +} diff --git a/rust/hook-common/src/lib.rs b/rust/hook-common/src/lib.rs new file mode 100644 index 0000000000000..8e63ded5a7bf2 --- /dev/null +++ b/rust/hook-common/src/lib.rs @@ -0,0 +1,5 @@ +pub mod kafka_messages; +pub mod metrics; +pub mod pgqueue; +pub mod retry; +pub mod webhook; diff --git a/rust/hook-common/src/metrics.rs b/rust/hook-common/src/metrics.rs new file mode 100644 index 0000000000000..66bcfc95ceeec --- /dev/null +++ b/rust/hook-common/src/metrics.rs @@ -0,0 +1,82 @@ +use std::time::{Instant, SystemTime}; + +use axum::{ + body::Body, extract::MatchedPath, http::Request, middleware::Next, response::IntoResponse, + routing::get, Router, +}; +use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle}; + +/// Bind a `TcpListener` on the provided bind address to serve a `Router` on it. +/// This function is intended to take a Router as returned by `setup_metrics_router`, potentially with more routes added by the caller. +pub async fn serve(router: Router, bind: &str) -> Result<(), std::io::Error> { + let listener = tokio::net::TcpListener::bind(bind).await?; + + axum::serve(listener, router).await?; + + Ok(()) +} + +/// Add the prometheus endpoint and middleware to a router, should be called last. +pub fn setup_metrics_routes(router: Router) -> Router { + let recorder_handle = setup_metrics_recorder(); + + router + .route( + "/metrics", + get(move || std::future::ready(recorder_handle.render())), + ) + .layer(axum::middleware::from_fn(track_metrics)) +} + +pub fn setup_metrics_recorder() -> PrometheusHandle { + const EXPONENTIAL_SECONDS: &[f64] = &[ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, + ]; + + PrometheusBuilder::new() + .set_buckets(EXPONENTIAL_SECONDS) + .unwrap() + .install_recorder() + .unwrap() +} + +/// Middleware to record some common HTTP metrics +/// Someday tower-http might provide a metrics middleware: https://github.com/tower-rs/tower-http/issues/57 +pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse { + let start = Instant::now(); + + let path = if let Some(matched_path) = req.extensions().get::() { + matched_path.as_str().to_owned() + } else { + req.uri().path().to_owned() + }; + + let method = req.method().clone(); + + // Run the rest of the request handling first, so we can measure it and get response + // codes. + let response = next.run(req).await; + + let latency = start.elapsed().as_secs_f64(); + let status = response.status().as_u16().to_string(); + + let labels = [ + ("method", method.to_string()), + ("path", path), + ("status", status), + ]; + + metrics::counter!("http_requests_total", &labels).increment(1); + metrics::histogram!("http_requests_duration_seconds", &labels).record(latency); + + response +} + +/// Returns the number of seconds since the Unix epoch, to use in prom gauges. +/// Saturates to zero if the system time is set before epoch. +pub fn get_current_timestamp_seconds() -> f64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as f64 +} diff --git a/rust/hook-common/src/pgqueue.rs b/rust/hook-common/src/pgqueue.rs new file mode 100644 index 0000000000000..5d8a14485697e --- /dev/null +++ b/rust/hook-common/src/pgqueue.rs @@ -0,0 +1,957 @@ +//! # PgQueue +//! +//! A job queue implementation backed by a PostgreSQL table. +use std::time; +use std::{str::FromStr, sync::Arc}; + +use async_trait::async_trait; +use chrono; +use serde; +use sqlx::postgres::any::AnyConnectionBackend; +use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions}; +use thiserror::Error; +use tokio::sync::Mutex; +use tracing::error; + +/// Enumeration of parsing errors in PgQueue. +#[derive(Error, Debug)] +pub enum ParseError { + #[error("{0} is not a valid JobStatus")] + ParseJobStatusError(String), + #[error("{0} is not a valid HttpMethod")] + ParseHttpMethodError(String), + #[error("transaction was already closed")] + TransactionAlreadyClosedError, +} + +/// Enumeration of database-related errors in PgQueue. +/// Errors that can originate from sqlx and are wrapped by us to provide additional context. +#[derive(Error, Debug)] +pub enum DatabaseError { + #[error("pool creation failed with: {error}")] + PoolCreationError { error: sqlx::Error }, + #[error("connection failed with: {error}")] + ConnectionError { error: sqlx::Error }, + #[error("{command} query failed with: {error}")] + QueryError { command: String, error: sqlx::Error }, + #[error("transaction {command} failed with: {error}")] + TransactionError { command: String, error: sqlx::Error }, + #[error("transaction was already closed")] + TransactionAlreadyClosedError, +} + +/// An error that occurs when a job cannot be retried. +/// Returns the underlying job so that a client can fail it. +#[derive(Error, Debug)] +#[error("retry is an invalid state for this job: {error}")] +pub struct RetryInvalidError { + pub job: T, + pub error: String, +} + +/// Enumeration of errors that can occur when retrying a job. +/// They are in a separate enum a failed retry could be returning the underlying job. +#[derive(Error, Debug)] +pub enum RetryError { + #[error(transparent)] + DatabaseError(#[from] DatabaseError), + #[error(transparent)] + RetryInvalidError(#[from] RetryInvalidError), +} + +/// Enumeration of possible statuses for a Job. +#[derive(Debug, PartialEq, sqlx::Type)] +#[sqlx(type_name = "job_status")] +#[sqlx(rename_all = "lowercase")] +pub enum JobStatus { + /// A job that is waiting in the queue to be picked up by a worker. + Available, + /// A job that was cancelled by a worker. + Cancelled, + /// A job that was successfully completed by a worker. + Completed, + /// A job that has + Discarded, + /// A job that was unsuccessfully completed by a worker. + Failed, +} + +/// Allow casting JobStatus from strings. +impl FromStr for JobStatus { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "available" => Ok(JobStatus::Available), + "completed" => Ok(JobStatus::Completed), + "failed" => Ok(JobStatus::Failed), + invalid => Err(ParseError::ParseJobStatusError(invalid.to_owned())), + } + } +} + +/// JobParameters are stored and read to and from a JSONB field, so we accept anything that fits `sqlx::types::Json`. +pub type JobParameters = sqlx::types::Json; + +/// JobMetadata are stored and read to and from a JSONB field, so we accept anything that fits `sqlx::types::Json`. +pub type JobMetadata = sqlx::types::Json; + +/// A Job to be executed by a worker dequeueing a PgQueue. +#[derive(sqlx::FromRow, Debug)] +pub struct Job { + /// A unique id identifying a job. + pub id: i64, + /// A number corresponding to the current job attempt. + pub attempt: i32, + /// A datetime corresponding to when the job was attempted. + pub attempted_at: chrono::DateTime, + /// A vector of identifiers that have attempted this job. E.g. thread ids, pod names, etc... + pub attempted_by: Vec, + /// A datetime corresponding to when the job was created. + pub created_at: chrono::DateTime, + /// The current job's number of max attempts. + pub max_attempts: i32, + /// Arbitrary job metadata stored as JSON. + pub metadata: JobMetadata, + /// Arbitrary job parameters stored as JSON. + pub parameters: JobParameters, + /// The queue this job belongs to. + pub queue: String, + /// The current status of the job. + pub status: JobStatus, + /// The target of the job. E.g. an endpoint or service we are trying to reach. + pub target: String, +} + +impl Job { + /// Return true if this job attempt is greater or equal to the maximum number of possible attempts. + pub fn is_gte_max_attempts(&self) -> bool { + self.attempt >= self.max_attempts + } + + /// Consume `Job` to transition it to a `RetryableJob`, i.e. a `Job` that may be retried. + fn retryable(self) -> RetryableJob { + RetryableJob { + id: self.id, + attempt: self.attempt, + queue: self.queue, + retry_queue: None, + } + } + + /// Consume `Job` to complete it. + /// A `CompletedJob` is finalized and cannot be used further; it is returned for reporting or inspection. + /// + /// # Arguments + /// + /// * `executor`: Any sqlx::Executor that can execute the UPDATE query required to mark this `Job` as completed. + async fn complete<'c, E>(self, executor: E) -> Result + where + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + let base_query = r#" +UPDATE + job_queue +SET + last_attempt_finished_at = NOW(), + status = 'completed'::job_status +WHERE + queue = $1 + AND id = $2 +RETURNING + job_queue.* + "#; + + sqlx::query(base_query) + .bind(&self.queue) + .bind(self.id) + .execute(executor) + .await?; + + Ok(CompletedJob { + id: self.id, + queue: self.queue, + }) + } + + /// Consume `Job` to fail it. + /// A `FailedJob` is finalized and cannot be used further; it is returned for reporting or inspection. + /// + /// # Arguments + /// + /// * `error`: Any JSON-serializable value to be stored as an error. + /// * `executor`: Any sqlx::Executor that can execute the UPDATE query required to mark this `Job` as failed. + async fn fail<'c, E, S>(self, error: S, executor: E) -> Result, sqlx::Error> + where + S: serde::Serialize + std::marker::Sync + std::marker::Send, + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + let json_error = sqlx::types::Json(error); + let base_query = r#" +UPDATE + job_queue +SET + last_attempt_finished_at = NOW(), + status = 'failed'::job_status, + errors = array_append(errors, $3) +WHERE + queue = $1 + AND id = $2 +RETURNING + job_queue.* + "#; + + sqlx::query(base_query) + .bind(&self.queue) + .bind(self.id) + .bind(&json_error) + .execute(executor) + .await?; + + Ok(FailedJob { + id: self.id, + error: json_error, + queue: self.queue, + }) + } +} + +#[async_trait] +pub trait PgQueueJob { + async fn complete(mut self) -> Result; + + async fn fail( + mut self, + error: E, + ) -> Result, DatabaseError>; + + async fn retry( + mut self, + error: E, + retry_interval: time::Duration, + queue: &str, + ) -> Result>>; +} + +/// A Job within an open PostgreSQL transaction. +/// This implementation allows 'hiding' the job from any other workers running SKIP LOCKED queries. +#[derive(Debug)] +pub struct PgTransactionJob<'c, J, M> { + pub job: Job, + + /// The open transaction this job came from. If multiple jobs were queried at once, then this + /// transaction will be shared between them (across async tasks and threads as necessary). See + /// below for more information. + shared_txn: Arc>>>, +} + +// Container struct for a batch of PgTransactionJob. Includes a reference to the shared transaction +// for committing the work when all of the jobs are finished. +pub struct PgTransactionBatch<'c, J, M> { + pub jobs: Vec>, + + /// The open transaction the jobs in the Vec came from. This should be used to commit or + /// rollback when all of the work is finished. + shared_txn: Arc>>>, +} + +impl<'c, J, M> PgTransactionBatch<'_, J, M> { + pub async fn commit(self) -> PgQueueResult<()> { + let mut txn_guard = self.shared_txn.lock().await; + + txn_guard + .as_deref_mut() + .ok_or(DatabaseError::TransactionAlreadyClosedError)? + .commit() + .await + .map_err(|e| DatabaseError::QueryError { + command: "COMMIT".to_owned(), + error: e, + })?; + + Ok(()) + } +} + +#[async_trait] +impl<'c, J: std::marker::Send, M: std::marker::Send> PgQueueJob for PgTransactionJob<'c, J, M> { + async fn complete(mut self) -> Result { + let mut txn_guard = self.shared_txn.lock().await; + + let txn_ref = txn_guard + .as_deref_mut() + .ok_or(DatabaseError::TransactionAlreadyClosedError)?; + + let completed_job = + self.job + .complete(txn_ref) + .await + .map_err(|error| DatabaseError::QueryError { + command: "UPDATE".to_owned(), + error, + })?; + + Ok(completed_job) + } + + async fn fail( + mut self, + error: S, + ) -> Result, DatabaseError> { + let mut txn_guard = self.shared_txn.lock().await; + + let txn_ref = txn_guard + .as_deref_mut() + .ok_or(DatabaseError::TransactionAlreadyClosedError)?; + + let failed_job = + self.job + .fail(error, txn_ref) + .await + .map_err(|error| DatabaseError::QueryError { + command: "UPDATE".to_owned(), + error, + })?; + + Ok(failed_job) + } + + async fn retry( + mut self, + error: E, + retry_interval: time::Duration, + queue: &str, + ) -> Result>>> { + // Ideally, the transition to RetryableJob should be fallible. + // But taking ownership of self when we return this error makes things difficult. + if self.job.is_gte_max_attempts() { + return Err(RetryError::from(RetryInvalidError { + job: Box::new(self), + error: "Maximum attempts reached".to_owned(), + })); + } + + let mut txn_guard = self.shared_txn.lock().await; + + let txn_ref = txn_guard + .as_deref_mut() + .ok_or(DatabaseError::TransactionAlreadyClosedError)?; + + let retried_job = self + .job + .retryable() + .queue(queue) + .retry(error, retry_interval, txn_ref) + .await + .map_err(|error| DatabaseError::QueryError { + command: "UPDATE".to_owned(), + error, + })?; + + Ok(retried_job) + } +} + +/// A Job that has failed but can still be enqueued into a PgQueue to be retried at a later point. +/// The time until retry will depend on the PgQueue's RetryPolicy. +pub struct RetryableJob { + /// A unique id identifying a job. + pub id: i64, + /// A number corresponding to the current job attempt. + pub attempt: i32, + /// A unique id identifying a job queue. + queue: String, + /// An optional separate queue where to enqueue this job when retrying. + retry_queue: Option, +} + +impl RetryableJob { + /// Set the queue for a `RetryableJob`. + /// If not set, `Job` will be retried to its original queue on calling `retry`. + fn queue(mut self, queue: &str) -> Self { + self.retry_queue = Some(queue.to_owned()); + self + } + + /// Return the queue that a `Job` is to be retried into. + fn retry_queue(&self) -> &str { + self.retry_queue.as_ref().unwrap_or(&self.queue) + } + + /// Consume `Job` to retry it. + /// A `RetriedJob` cannot be used further; it is returned for reporting or inspection. + /// + /// # Arguments + /// + /// * `error`: Any JSON-serializable value to be stored as an error. + /// * `retry_interval`: The duration until the `Job` is to be retried again. Used to set `scheduled_at`. + /// * `executor`: Any sqlx::Executor that can execute the UPDATE query required to mark this `Job` as completed. + async fn retry<'c, S, E>( + self, + error: S, + retry_interval: time::Duration, + executor: E, + ) -> Result + where + S: serde::Serialize + std::marker::Sync + std::marker::Send, + E: sqlx::Executor<'c, Database = sqlx::Postgres>, + { + let json_error = sqlx::types::Json(error); + let base_query = r#" +UPDATE + job_queue +SET + last_attempt_finished_at = NOW(), + status = 'available'::job_status, + scheduled_at = NOW() + $3, + errors = array_append(errors, $4), + queue = $5 +WHERE + queue = $1 + AND id = $2 +RETURNING + job_queue.* + "#; + + sqlx::query(base_query) + .bind(&self.queue) + .bind(self.id) + .bind(retry_interval) + .bind(&json_error) + .bind(self.retry_queue()) + .execute(executor) + .await?; + + Ok(RetriedJob { + id: self.id, + queue: self.queue, + retry_queue: self.retry_queue.to_owned(), + }) + } +} + +/// State a `Job` is transitioned to after successfully completing. +#[derive(Debug)] +pub struct CompletedJob { + /// A unique id identifying a job. + pub id: i64, + /// A unique id identifying a job queue. + pub queue: String, +} + +/// State a `Job` is transitioned to after it has been enqueued for retrying. +#[derive(Debug)] +pub struct RetriedJob { + /// A unique id identifying a job. + pub id: i64, + /// A unique id identifying a job queue. + pub queue: String, + pub retry_queue: Option, +} + +/// State a `Job` is transitioned to after exhausting all of their attempts. +#[derive(Debug)] +pub struct FailedJob { + /// A unique id identifying a job. + pub id: i64, + /// Any JSON-serializable value to be stored as an error. + pub error: sqlx::types::Json, + /// A unique id identifying a job queue. + pub queue: String, +} + +/// This struct represents a new job being created to be enqueued into a `PgQueue`. +#[derive(Debug)] +pub struct NewJob { + /// The maximum amount of attempts this NewJob has to complete. + pub max_attempts: i32, + /// The JSON-deserializable parameters for this NewJob. + pub metadata: JobMetadata, + /// The JSON-deserializable parameters for this NewJob. + pub parameters: JobParameters, + /// The target of the NewJob. E.g. an endpoint or service we are trying to reach. + pub target: String, +} + +impl NewJob { + pub fn new(max_attempts: i32, metadata: M, parameters: J, target: &str) -> Self { + Self { + max_attempts, + metadata: sqlx::types::Json(metadata), + parameters: sqlx::types::Json(parameters), + target: target.to_owned(), + } + } +} + +/// A queue implemented on top of a PostgreSQL table. +#[derive(Clone)] +pub struct PgQueue { + /// A name to identify this PgQueue as multiple may share a table. + name: String, + /// A connection pool used to connect to the PostgreSQL database. + pool: PgPool, +} + +pub type PgQueueResult = std::result::Result; + +impl PgQueue { + /// Initialize a new PgQueue backed by table in PostgreSQL by intializing a connection pool to the database in `url`. + /// + /// # Arguments + /// + /// * `queue_name`: A name for the queue we are going to initialize. + /// * `url`: A URL pointing to where the PostgreSQL database is hosted. + pub async fn new( + queue_name: &str, + url: &str, + max_connections: u32, + app_name: &'static str, + ) -> PgQueueResult { + let name = queue_name.to_owned(); + let options = PgConnectOptions::from_str(url) + .map_err(|error| DatabaseError::PoolCreationError { error })? + .application_name(app_name); + let pool = PgPoolOptions::new() + .max_connections(max_connections) + .connect_lazy_with(options); + + Ok(Self { name, pool }) + } + + /// Initialize a new PgQueue backed by table in PostgreSQL from a provided connection pool. + /// + /// # Arguments + /// + /// * `queue_name`: A name for the queue we are going to initialize. + /// * `pool`: A database connection pool to be used by this queue. + pub async fn new_from_pool(queue_name: &str, pool: PgPool) -> PgQueue { + let name = queue_name.to_owned(); + + Self { name, pool } + } + + /// Dequeue up to `limit` `Job`s from this `PgQueue` and hold the transaction. + /// Any other `dequeue_tx` calls will skip rows locked, so by holding a transaction we ensure only one + /// worker can dequeue a job. Holding a transaction open can have performance implications, but + /// it means no `'running'` state is required. + pub async fn dequeue_tx< + 'a, + J: for<'d> serde::Deserialize<'d> + std::marker::Send + std::marker::Unpin + 'static, + M: for<'d> serde::Deserialize<'d> + std::marker::Send + std::marker::Unpin + 'static, + >( + &self, + attempted_by: &str, + limit: u32, + ) -> PgQueueResult>> { + let mut tx = self + .pool + .begin() + .await + .map_err(|error| DatabaseError::ConnectionError { error })?; + + // The query that follows uses a FOR UPDATE SKIP LOCKED clause. + // For more details on this see: 2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5. + let base_query = r#" +WITH available_in_queue AS ( + SELECT + id + FROM + job_queue + WHERE + status = 'available' + AND scheduled_at <= NOW() + AND queue = $1 + ORDER BY + attempt, + scheduled_at + LIMIT $2 + FOR UPDATE SKIP LOCKED +) +UPDATE + job_queue +SET + attempted_at = NOW(), + attempt = attempt + 1, + attempted_by = array_append(attempted_by, $3::text) +FROM + available_in_queue +WHERE + job_queue.id = available_in_queue.id +RETURNING + job_queue.* + "#; + + let query_result: Result>, sqlx::Error> = sqlx::query_as(base_query) + .bind(&self.name) + .bind(limit as i64) + .bind(attempted_by) + .fetch_all(&mut *tx) + .await; + + match query_result { + Ok(jobs) => { + if jobs.is_empty() { + return Ok(None); + } + + let shared_txn = Arc::new(Mutex::new(Some(tx))); + + let pg_jobs: Vec> = jobs + .into_iter() + .map(|job| PgTransactionJob { + job, + shared_txn: shared_txn.clone(), + }) + .collect(); + + Ok(Some(PgTransactionBatch { + jobs: pg_jobs, + shared_txn: shared_txn.clone(), + })) + } + + // Transaction is rolled back on drop. + Err(sqlx::Error::RowNotFound) => Ok(None), + Err(e) => Err(DatabaseError::QueryError { + command: "UPDATE".to_owned(), + error: e, + }), + } + } + + /// Enqueue a `NewJob` into this PgQueue. + /// We take ownership of `NewJob` to enforce a specific `NewJob` is only enqueued once. + pub async fn enqueue< + J: serde::Serialize + std::marker::Sync, + M: serde::Serialize + std::marker::Sync, + >( + &self, + job: NewJob, + ) -> PgQueueResult<()> { + // TODO: Escaping. I think sqlx doesn't support identifiers. + let base_query = r#" +INSERT INTO job_queue + (attempt, created_at, scheduled_at, max_attempts, metadata, parameters, queue, status, target) +VALUES + (0, NOW(), NOW(), $1, $2, $3, $4, 'available'::job_status, $5) + "#; + + sqlx::query(base_query) + .bind(job.max_attempts) + .bind(&job.metadata) + .bind(&job.parameters) + .bind(&self.name) + .bind(&job.target) + .execute(&self.pool) + .await + .map_err(|error| DatabaseError::QueryError { + command: "INSERT".to_owned(), + error, + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::retry::RetryPolicy; + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Clone)] + struct JobMetadata { + team_id: u32, + plugin_config_id: i32, + plugin_id: i32, + } + + impl Default for JobMetadata { + fn default() -> Self { + Self { + team_id: 0, + plugin_config_id: 1, + plugin_id: 2, + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Clone)] + struct JobParameters { + method: String, + body: String, + url: String, + } + + impl Default for JobParameters { + fn default() -> Self { + Self { + method: "POST".to_string(), + body: "{\"event\":\"event-name\"}".to_string(), + url: "https://localhost".to_string(), + } + } + } + + /// Use process id as a worker id for tests. + fn worker_id() -> String { + std::process::id().to_string() + } + + /// Hardcoded test value for job target. + fn job_target() -> String { + "https://myhost/endpoint".to_owned() + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_can_dequeue_tx_job(db: PgPool) { + let job_target = job_target(); + let job_metadata = JobMetadata::default(); + let job_parameters = JobParameters::default(); + let worker_id = worker_id(); + + let queue = PgQueue::new_from_pool("test_can_dequeue_tx_job", db).await; + + let new_job = NewJob::new(1, job_metadata, job_parameters, &job_target); + queue.enqueue(new_job).await.expect("failed to enqueue job"); + + let mut batch: PgTransactionBatch<'_, JobParameters, JobMetadata> = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue jobs") + .expect("didn't find any jobs to dequeue"); + + let tx_job = batch.jobs.pop().unwrap(); + + assert_eq!(tx_job.job.attempt, 1); + assert!(tx_job.job.attempted_by.contains(&worker_id)); + assert_eq!(tx_job.job.attempted_by.len(), 1); + assert_eq!(tx_job.job.max_attempts, 1); + assert_eq!(*tx_job.job.metadata.as_ref(), JobMetadata::default()); + assert_eq!(*tx_job.job.parameters.as_ref(), JobParameters::default()); + assert_eq!(tx_job.job.target, job_target); + + // Transactional jobs must be completed, failed or retried before being dropped. This is + // to prevent logic bugs when using the shared txn. + tx_job.complete().await.expect("failed to complete job"); + + batch.commit().await.expect("failed to commit transaction"); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_can_dequeue_multiple_tx_jobs(db: PgPool) { + let job_target = job_target(); + let job_metadata = JobMetadata::default(); + let job_parameters = JobParameters::default(); + let worker_id = worker_id(); + + let queue = PgQueue::new_from_pool("test_can_dequeue_multiple_tx_jobs", db).await; + + for _ in 0..5 { + queue + .enqueue(NewJob::new( + 1, + job_metadata.clone(), + job_parameters.clone(), + &job_target, + )) + .await + .expect("failed to enqueue job"); + } + + // Only get 4 jobs, leaving one in the queue. + let limit = 4; + let mut batch: PgTransactionBatch<'_, JobParameters, JobMetadata> = queue + .dequeue_tx(&worker_id, limit) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + + assert_eq!(batch.jobs.len(), limit as usize); + + // Complete those 4 and commit. + for job in std::mem::take(&mut batch.jobs) { + job.complete().await.expect("failed to complete job"); + } + batch.commit().await.expect("failed to commit transaction"); + + // Try to get up to 4 jobs, but only 1 remains. + let mut batch: PgTransactionBatch<'_, JobParameters, JobMetadata> = queue + .dequeue_tx(&worker_id, limit) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + assert_eq!(batch.jobs.len(), 1); // Only one job should have been left in the queue. + + for job in std::mem::take(&mut batch.jobs) { + job.complete().await.expect("failed to complete job"); + } + batch.commit().await.expect("failed to commit transaction"); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_dequeue_tx_returns_none_on_no_jobs(db: PgPool) { + let worker_id = worker_id(); + let queue = PgQueue::new_from_pool("test_dequeue_tx_returns_none_on_no_jobs", db).await; + + let batch: Option> = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job"); + + assert!(batch.is_none()); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_can_retry_job_with_remaining_attempts(db: PgPool) { + let job_target = job_target(); + let job_parameters = JobParameters::default(); + let job_metadata = JobMetadata::default(); + let worker_id = worker_id(); + let new_job = NewJob::new(2, job_metadata, job_parameters, &job_target); + let queue_name = "test_can_retry_job_with_remaining_attempts".to_owned(); + + let retry_policy = RetryPolicy::build(0, time::Duration::from_secs(0)) + .queue(&queue_name) + .provide(); + + let queue = PgQueue::new_from_pool(&queue_name, db).await; + + queue.enqueue(new_job).await.expect("failed to enqueue job"); + let mut batch: PgTransactionBatch<'_, JobParameters, JobMetadata> = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + let job = batch.jobs.pop().unwrap(); + + let retry_interval = retry_policy.retry_interval(job.job.attempt as u32, None); + let retry_queue = retry_policy.retry_queue(&job.job.queue).to_owned(); + drop( + job.retry( + "a very reasonable failure reason", + retry_interval, + &retry_queue, + ) + .await + .expect("failed to retry job"), + ); + batch.commit().await.expect("failed to commit transaction"); + + let retried_job: PgTransactionJob = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job") + .expect("didn't find retried job to dequeue") + .jobs + .pop() + .unwrap(); + + assert_eq!(retried_job.job.attempt, 2); + assert!(retried_job.job.attempted_by.contains(&worker_id)); + assert_eq!(retried_job.job.attempted_by.len(), 2); + assert_eq!(retried_job.job.max_attempts, 2); + assert_eq!( + *retried_job.job.parameters.as_ref(), + JobParameters::default() + ); + assert_eq!(retried_job.job.target, job_target); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_can_retry_job_to_different_queue(db: PgPool) { + let job_target = job_target(); + let job_parameters = JobParameters::default(); + let job_metadata = JobMetadata::default(); + let worker_id = worker_id(); + let new_job = NewJob::new(2, job_metadata, job_parameters, &job_target); + let queue_name = "test_can_retry_job_to_different_queue".to_owned(); + let retry_queue_name = "test_can_retry_job_to_different_queue_retry".to_owned(); + + let retry_policy = RetryPolicy::build(0, time::Duration::from_secs(0)) + .queue(&retry_queue_name) + .provide(); + + let queue = PgQueue::new_from_pool(&queue_name, db.clone()).await; + + queue.enqueue(new_job).await.expect("failed to enqueue job"); + let mut batch: PgTransactionBatch = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + let job = batch.jobs.pop().unwrap(); + + let retry_interval = retry_policy.retry_interval(job.job.attempt as u32, None); + let retry_queue = retry_policy.retry_queue(&job.job.queue).to_owned(); + drop( + job.retry( + "a very reasonable failure reason", + retry_interval, + &retry_queue, + ) + .await + .expect("failed to retry job"), + ); + batch.commit().await.expect("failed to commit transaction"); + + let retried_job_not_found: Option> = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job"); + + assert!(retried_job_not_found.is_none()); + + let queue = PgQueue::new_from_pool(&retry_queue_name, db).await; + + let retried_job: PgTransactionJob = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job") + .expect("job not found in retry queue") + .jobs + .pop() + .unwrap(); + + assert_eq!(retried_job.job.attempt, 2); + assert!(retried_job.job.attempted_by.contains(&worker_id)); + assert_eq!(retried_job.job.attempted_by.len(), 2); + assert_eq!(retried_job.job.max_attempts, 2); + assert_eq!( + *retried_job.job.parameters.as_ref(), + JobParameters::default() + ); + assert_eq!(retried_job.job.target, job_target); + } + + #[sqlx::test(migrations = "../migrations")] + #[should_panic(expected = "failed to retry job")] + async fn test_cannot_retry_job_without_remaining_attempts(db: PgPool) { + let job_target = job_target(); + let job_parameters = JobParameters::default(); + let job_metadata = JobMetadata::default(); + let worker_id = worker_id(); + let new_job = NewJob::new(1, job_metadata, job_parameters, &job_target); + let retry_policy = RetryPolicy::build(0, time::Duration::from_secs(0)).provide(); + + let queue = + PgQueue::new_from_pool("test_cannot_retry_job_without_remaining_attempts", db).await; + + queue.enqueue(new_job).await.expect("failed to enqueue job"); + + let job: PgTransactionJob = queue + .dequeue_tx(&worker_id, 1) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue") + .jobs + .pop() + .unwrap(); + + let retry_interval = retry_policy.retry_interval(job.job.attempt as u32, None); + + job.retry("a very reasonable failure reason", retry_interval, "any") + .await + .expect("failed to retry job"); + } +} diff --git a/rust/hook-common/src/retry.rs b/rust/hook-common/src/retry.rs new file mode 100644 index 0000000000000..b00f967a7b6e5 --- /dev/null +++ b/rust/hook-common/src/retry.rs @@ -0,0 +1,225 @@ +//! # Retry +//! +//! Module providing a `RetryPolicy` struct to configure job retrying. +use std::time; + +#[derive(Clone, Debug)] +/// A retry policy to determine retry parameters for a job. +pub struct RetryPolicy { + /// Coefficient to multiply initial_interval with for every past attempt. + pub backoff_coefficient: u32, + /// The backoff interval for the first retry. + pub initial_interval: time::Duration, + /// The maximum possible backoff between retries. + pub maximum_interval: Option, + /// An optional queue to send WebhookJob retries to. + pub queue: Option, +} + +impl RetryPolicy { + /// Initialize a `RetryPolicyBuilder`. + pub fn build(backoff_coefficient: u32, initial_interval: time::Duration) -> RetryPolicyBuilder { + RetryPolicyBuilder::new(backoff_coefficient, initial_interval) + } + + /// Determine interval for retrying at a given attempt number. + /// If not `None`, this method will respect `preferred_retry_interval` as long as it falls within `candidate_interval <= preferred_retry_interval <= maximum_interval`. + pub fn retry_interval( + &self, + attempt: u32, + preferred_retry_interval: Option, + ) -> time::Duration { + let candidate_interval = + self.initial_interval * self.backoff_coefficient.pow(attempt.saturating_sub(1)); + + match (preferred_retry_interval, self.maximum_interval) { + (Some(duration), Some(max_interval)) => { + let min_interval_allowed = std::cmp::min(candidate_interval, max_interval); + + if min_interval_allowed <= duration && duration <= max_interval { + duration + } else { + min_interval_allowed + } + } + (Some(duration), None) => std::cmp::max(candidate_interval, duration), + (None, Some(max_interval)) => std::cmp::min(candidate_interval, max_interval), + (None, None) => candidate_interval, + } + } + + /// Determine the queue to be used for retrying. + /// Only whether a queue is configured in this RetryPolicy is used to determine which queue to use for retrying. + /// This may be extended in the future to support more decision parameters. + pub fn retry_queue<'s>(&'s self, current_queue: &'s str) -> &'s str { + if let Some(new_queue) = &self.queue { + new_queue + } else { + current_queue + } + } +} + +impl Default for RetryPolicy { + fn default() -> Self { + RetryPolicyBuilder::default().provide() + } +} + +/// Builder pattern struct to provide a `RetryPolicy`. +pub struct RetryPolicyBuilder { + /// Coefficient to multiply initial_interval with for every past attempt. + pub backoff_coefficient: u32, + /// The backoff interval for the first retry. + pub initial_interval: time::Duration, + /// The maximum possible backoff between retries. + pub maximum_interval: Option, + /// An optional queue to send WebhookJob retries to. + pub queue: Option, +} + +impl Default for RetryPolicyBuilder { + fn default() -> Self { + Self { + backoff_coefficient: 2, + initial_interval: time::Duration::from_secs(1), + maximum_interval: None, + queue: None, + } + } +} + +impl RetryPolicyBuilder { + pub fn new(backoff_coefficient: u32, initial_interval: time::Duration) -> Self { + Self { + backoff_coefficient, + initial_interval, + ..RetryPolicyBuilder::default() + } + } + + pub fn maximum_interval(mut self, interval: time::Duration) -> RetryPolicyBuilder { + self.maximum_interval = Some(interval); + self + } + + pub fn queue(mut self, queue: &str) -> RetryPolicyBuilder { + self.queue = Some(queue.to_owned()); + self + } + + /// Provide a `RetryPolicy` according to build parameters provided thus far. + pub fn provide(&self) -> RetryPolicy { + RetryPolicy { + backoff_coefficient: self.backoff_coefficient, + initial_interval: self.initial_interval, + maximum_interval: self.maximum_interval, + queue: self.queue.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constant_retry_interval() { + let retry_policy = RetryPolicy::build(1, time::Duration::from_secs(2)).provide(); + let first_interval = retry_policy.retry_interval(1, None); + let second_interval = retry_policy.retry_interval(2, None); + let third_interval = retry_policy.retry_interval(3, None); + + assert_eq!(first_interval, time::Duration::from_secs(2)); + assert_eq!(second_interval, time::Duration::from_secs(2)); + assert_eq!(third_interval, time::Duration::from_secs(2)); + } + + #[test] + fn test_retry_interval_never_exceeds_maximum() { + let retry_policy = RetryPolicy::build(2, time::Duration::from_secs(2)) + .maximum_interval(time::Duration::from_secs(4)) + .provide(); + let first_interval = retry_policy.retry_interval(1, None); + let second_interval = retry_policy.retry_interval(2, None); + let third_interval = retry_policy.retry_interval(3, None); + let fourth_interval = retry_policy.retry_interval(4, None); + + assert_eq!(first_interval, time::Duration::from_secs(2)); + assert_eq!(second_interval, time::Duration::from_secs(4)); + assert_eq!(third_interval, time::Duration::from_secs(4)); + assert_eq!(fourth_interval, time::Duration::from_secs(4)); + } + + #[test] + fn test_retry_interval_increases_with_coefficient() { + let retry_policy = RetryPolicy::build(2, time::Duration::from_secs(2)).provide(); + let first_interval = retry_policy.retry_interval(1, None); + let second_interval = retry_policy.retry_interval(2, None); + let third_interval = retry_policy.retry_interval(3, None); + + assert_eq!(first_interval, time::Duration::from_secs(2)); + assert_eq!(second_interval, time::Duration::from_secs(4)); + assert_eq!(third_interval, time::Duration::from_secs(8)); + } + + #[test] + fn test_retry_interval_respects_preferred() { + let retry_policy = RetryPolicy::build(1, time::Duration::from_secs(2)).provide(); + let preferred = time::Duration::from_secs(999); + let first_interval = retry_policy.retry_interval(1, Some(preferred)); + let second_interval = retry_policy.retry_interval(2, Some(preferred)); + let third_interval = retry_policy.retry_interval(3, Some(preferred)); + + assert_eq!(first_interval, preferred); + assert_eq!(second_interval, preferred); + assert_eq!(third_interval, preferred); + } + + #[test] + fn test_retry_interval_ignores_small_preferred() { + let retry_policy = RetryPolicy::build(1, time::Duration::from_secs(5)).provide(); + let preferred = time::Duration::from_secs(2); + let first_interval = retry_policy.retry_interval(1, Some(preferred)); + let second_interval = retry_policy.retry_interval(2, Some(preferred)); + let third_interval = retry_policy.retry_interval(3, Some(preferred)); + + assert_eq!(first_interval, time::Duration::from_secs(5)); + assert_eq!(second_interval, time::Duration::from_secs(5)); + assert_eq!(third_interval, time::Duration::from_secs(5)); + } + + #[test] + fn test_retry_interval_ignores_large_preferred() { + let retry_policy = RetryPolicy::build(2, time::Duration::from_secs(2)) + .maximum_interval(time::Duration::from_secs(4)) + .provide(); + let preferred = time::Duration::from_secs(10); + let first_interval = retry_policy.retry_interval(1, Some(preferred)); + let second_interval = retry_policy.retry_interval(2, Some(preferred)); + let third_interval = retry_policy.retry_interval(3, Some(preferred)); + + assert_eq!(first_interval, time::Duration::from_secs(2)); + assert_eq!(second_interval, time::Duration::from_secs(4)); + assert_eq!(third_interval, time::Duration::from_secs(4)); + } + + #[test] + fn test_returns_retry_queue_if_set() { + let retry_queue_name = "retry_queue".to_owned(); + let retry_policy = RetryPolicy::build(0, time::Duration::from_secs(0)) + .queue(&retry_queue_name) + .provide(); + let current_queue = "queue".to_owned(); + + assert_eq!(retry_policy.retry_queue(¤t_queue), retry_queue_name); + } + + #[test] + fn test_returns_queue_if_retry_queue_not_set() { + let retry_policy = RetryPolicy::build(0, time::Duration::from_secs(0)).provide(); + let current_queue = "queue".to_owned(); + + assert_eq!(retry_policy.retry_queue(¤t_queue), current_queue); + } +} diff --git a/rust/hook-common/src/webhook.rs b/rust/hook-common/src/webhook.rs new file mode 100644 index 0000000000000..5286629978931 --- /dev/null +++ b/rust/hook-common/src/webhook.rs @@ -0,0 +1,225 @@ +use std::collections; +use std::convert::From; +use std::fmt; +use std::str::FromStr; + +use serde::{de::Visitor, Deserialize, Serialize}; + +use crate::kafka_messages::app_metrics; +use crate::pgqueue::ParseError; + +/// Supported HTTP methods for webhooks. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum HttpMethod { + DELETE, + GET, + PATCH, + POST, + PUT, +} + +/// Allow casting `HttpMethod` from strings. +impl FromStr for HttpMethod { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s.to_ascii_uppercase().as_ref() { + "DELETE" => Ok(HttpMethod::DELETE), + "GET" => Ok(HttpMethod::GET), + "PATCH" => Ok(HttpMethod::PATCH), + "POST" => Ok(HttpMethod::POST), + "PUT" => Ok(HttpMethod::PUT), + invalid => Err(ParseError::ParseHttpMethodError(invalid.to_owned())), + } + } +} + +/// Implement `std::fmt::Display` to convert HttpMethod to string. +impl fmt::Display for HttpMethod { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + HttpMethod::DELETE => write!(f, "DELETE"), + HttpMethod::GET => write!(f, "GET"), + HttpMethod::PATCH => write!(f, "PATCH"), + HttpMethod::POST => write!(f, "POST"), + HttpMethod::PUT => write!(f, "PUT"), + } + } +} + +struct HttpMethodVisitor; + +impl<'de> Visitor<'de> for HttpMethodVisitor { + type Value = HttpMethod; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + write!(formatter, "the string representation of HttpMethod") + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + match HttpMethod::from_str(s) { + Ok(method) => Ok(method), + Err(_) => Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(s), + &self, + )), + } + } +} + +/// Deserialize required to read `HttpMethod` from database. +impl<'de> Deserialize<'de> for HttpMethod { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(HttpMethodVisitor) + } +} + +/// Serialize required to write `HttpMethod` to database. +impl Serialize for HttpMethod { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +/// Convenience to cast `HttpMethod` to `http::Method`. +/// Not all `http::Method` variants are valid `HttpMethod` variants, hence why we +/// can't just use the former or implement `From`. +impl From for http::Method { + fn from(val: HttpMethod) -> Self { + match val { + HttpMethod::DELETE => http::Method::DELETE, + HttpMethod::GET => http::Method::GET, + HttpMethod::PATCH => http::Method::PATCH, + HttpMethod::POST => http::Method::POST, + HttpMethod::PUT => http::Method::PUT, + } + } +} + +impl From<&HttpMethod> for http::Method { + fn from(val: &HttpMethod) -> Self { + match val { + HttpMethod::DELETE => http::Method::DELETE, + HttpMethod::GET => http::Method::GET, + HttpMethod::PATCH => http::Method::PATCH, + HttpMethod::POST => http::Method::POST, + HttpMethod::PUT => http::Method::PUT, + } + } +} + +/// `JobParameters` required for the `WebhookWorker` to execute a webhook. +/// These parameters should match the exported Webhook interface that PostHog plugins. +/// implement. See: https://github.com/PostHog/plugin-scaffold/blob/main/src/types.ts#L15. +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct WebhookJobParameters { + pub body: String, + pub headers: collections::HashMap, + pub method: HttpMethod, + pub url: String, +} + +/// `JobMetadata` required for the `WebhookWorker` to execute a webhook. +/// These should be set if the Webhook is associated with a plugin `composeWebhook` invocation. +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] +pub struct WebhookJobMetadata { + pub team_id: u32, + pub plugin_id: i32, + pub plugin_config_id: i32, +} + +/// An error originating during a Webhook Job invocation. +/// This is to be serialized to be stored as an error whenever retrying or failing a webhook job. +#[derive(Deserialize, Serialize, Debug)] +pub struct WebhookJobError { + pub r#type: app_metrics::ErrorType, + pub details: app_metrics::ErrorDetails, +} + +/// Webhook jobs boil down to an HTTP request, so it's useful to have a way to convert from &reqwest::Error. +/// For the convertion we check all possible error types with the associated is_* methods provided by reqwest. +/// Some precision may be lost as our app_metrics::ErrorType does not support the same number of variants. +impl From<&reqwest::Error> for WebhookJobError { + fn from(error: &reqwest::Error) -> Self { + if error.is_timeout() { + WebhookJobError::new_timeout(&error.to_string()) + } else if error.is_status() { + WebhookJobError::new_http_status( + error.status().expect("status code is defined").into(), + &error.to_string(), + ) + } else { + // Catch all other errors as `app_metrics::ErrorType::Connection` errors. + // Not all of `reqwest::Error` may strictly be connection errors, so our supported error types may need an extension + // depending on how strict error reporting has to be. + WebhookJobError::new_connection(&error.to_string()) + } + } +} + +impl WebhookJobError { + pub fn new_timeout(message: &str) -> Self { + let error_details = app_metrics::Error { + name: "Timeout Error".to_owned(), + message: Some(message.to_owned()), + stack: None, + }; + Self { + r#type: app_metrics::ErrorType::TimeoutError, + details: app_metrics::ErrorDetails { + error: error_details, + }, + } + } + + pub fn new_connection(message: &str) -> Self { + let error_details = app_metrics::Error { + name: "Connection Error".to_owned(), + message: Some(message.to_owned()), + stack: None, + }; + Self { + r#type: app_metrics::ErrorType::ConnectionError, + details: app_metrics::ErrorDetails { + error: error_details, + }, + } + } + + pub fn new_http_status(status_code: u16, message: &str) -> Self { + let error_details = app_metrics::Error { + name: "Bad Http Status".to_owned(), + message: Some(message.to_owned()), + stack: None, + }; + Self { + r#type: app_metrics::ErrorType::BadHttpStatus(status_code), + details: app_metrics::ErrorDetails { + error: error_details, + }, + } + } + + pub fn new_parse(message: &str) -> Self { + let error_details = app_metrics::Error { + name: "Parse Error".to_owned(), + message: Some(message.to_owned()), + stack: None, + }; + Self { + r#type: app_metrics::ErrorType::ParseError, + details: app_metrics::ErrorDetails { + error: error_details, + }, + } + } +} diff --git a/rust/hook-janitor/Cargo.toml b/rust/hook-janitor/Cargo.toml new file mode 100644 index 0000000000000..741918e79385a --- /dev/null +++ b/rust/hook-janitor/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "hook-janitor" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +async-trait = { workspace = true } +axum = { workspace = true } +envconfig = { workspace = true } +eyre = { workspace = true } +futures = { workspace = true } +health = { path = "../common/health" } +hook-common = { path = "../hook-common" } +metrics = { workspace = true } +rdkafka = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/rust/hook-janitor/src/cleanup.rs b/rust/hook-janitor/src/cleanup.rs new file mode 100644 index 0000000000000..82b91303a721b --- /dev/null +++ b/rust/hook-janitor/src/cleanup.rs @@ -0,0 +1,34 @@ +use async_trait::async_trait; +use std::result::Result; +use std::str::FromStr; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CleanerError { + #[error("invalid cleaner mode")] + InvalidCleanerMode, +} + +// Mode names, used by config/environment parsing to verify the mode is supported. +#[derive(Debug)] +pub enum CleanerModeName { + Webhooks, +} + +impl FromStr for CleanerModeName { + type Err = CleanerError; + + fn from_str(s: &str) -> Result { + match s { + "webhooks" => Ok(CleanerModeName::Webhooks), + _ => Err(CleanerError::InvalidCleanerMode), + } + } +} + +// Right now, all this trait does is allow us to call `cleanup` in a loop in `main.rs`. There may +// be other benefits as we build this out, or we could remove it if it doesn't end up being useful. +#[async_trait] +pub trait Cleaner { + async fn cleanup(&self); +} diff --git a/rust/hook-janitor/src/config.rs b/rust/hook-janitor/src/config.rs new file mode 100644 index 0000000000000..389de0342e03a --- /dev/null +++ b/rust/hook-janitor/src/config.rs @@ -0,0 +1,57 @@ +use envconfig::Envconfig; + +#[derive(Envconfig)] +pub struct Config { + #[envconfig(from = "BIND_HOST", default = "0.0.0.0")] + pub host: String, + + #[envconfig(from = "BIND_PORT", default = "3302")] + pub port: u16, + + #[envconfig(default = "postgres://posthog:posthog@localhost:15432/test_database")] + pub database_url: String, + + #[envconfig(default = "30")] + pub cleanup_interval_secs: u64, + + // The cleanup task needs to have special knowledge of the queue it's cleaning up. This is so it + // can do things like flush the proper app_metrics or plugin_log_entries, and so it knows what + // to expect in the job's payload JSONB column. + #[envconfig(default = "webhooks")] + pub mode: String, + + #[envconfig(nested = true)] + pub kafka: KafkaConfig, +} + +#[derive(Envconfig, Clone)] +pub struct KafkaConfig { + #[envconfig(default = "20")] + pub kafka_producer_linger_ms: u32, // Maximum time between producer batches during low traffic + + #[envconfig(default = "400")] + pub kafka_producer_queue_mib: u32, // Size of the in-memory producer queue in mebibytes + + #[envconfig(default = "20000")] + pub kafka_message_timeout_ms: u32, // Time before we stop retrying producing a message: 20 seconds + + #[envconfig(default = "none")] + pub kafka_compression_codec: String, // none, gzip, snappy, lz4, zstd + + #[envconfig(default = "false")] + pub kafka_tls: bool, + + #[envconfig(default = "clickhouse_app_metrics")] + pub app_metrics_topic: String, + + #[envconfig(default = "plugin_log_entries")] + pub plugin_log_entries_topic: String, + + pub kafka_hosts: String, +} + +impl Config { + pub fn bind(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} diff --git a/rust/hook-janitor/src/fixtures/webhook_cleanup.sql b/rust/hook-janitor/src/fixtures/webhook_cleanup.sql new file mode 100644 index 0000000000000..e0b9a7a9ea4d8 --- /dev/null +++ b/rust/hook-janitor/src/fixtures/webhook_cleanup.sql @@ -0,0 +1,166 @@ +INSERT INTO + job_queue ( + errors, + metadata, + attempted_at, + last_attempt_finished_at, + parameters, + queue, + status, + target + ) +VALUES + -- team:1, plugin_config:2, completed in hour 20 + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, completed in hour 20 (purposeful duplicate) + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, completed in hour 21 (different hour) + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 21:01:18.799371+00', + '2023-12-19 21:01:18.799371+00', + '{}', + 'webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:3, completed in hour 20 (different plugin_config) + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 3}', + '2023-12-19 20:01:18.80335+00', + '2023-12-19 20:01:18.80335+00', + '{}', + 'webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, completed but in a different queue + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'not-webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:2, plugin_config:4, completed in hour 20 (different team) + ( + NULL, + '{"team_id": 2, "plugin_id": 99, "plugin_config_id": 4}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'completed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, failed in hour 20 + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, failed in hour 20 (purposeful duplicate) + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, failed in hour 20 (different error) + ( + ARRAY ['{"type":"ConnectionError","details":{"error":{"name":"Connection Error"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, failed in hour 21 (different hour) + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 21:01:18.799371+00', + '2023-12-19 21:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:3, failed in hour 20 (different plugin_config) + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 3}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, failed but in a different queue + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'not-webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:2, plugin_config:4, failed in hour 20 (purposeful duplicate) + ( + ARRAY ['{"type":"TimeoutError","details":{"error":{"name":"Timeout"}}}'::jsonb], + '{"team_id": 2, "plugin_id": 99, "plugin_config_id": 4}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{}', + 'webhooks', + 'failed', + 'https://myhost/endpoint' + ), + -- team:1, plugin_config:2, available + ( + NULL, + '{"team_id": 1, "plugin_id": 99, "plugin_config_id": 2}', + '2023-12-19 20:01:18.799371+00', + '2023-12-19 20:01:18.799371+00', + '{"body": "hello world", "headers": {}, "method": "POST", "url": "https://myhost/endpoint"}', + 'webhooks', + 'available', + 'https://myhost/endpoint' + ); \ No newline at end of file diff --git a/rust/hook-janitor/src/handlers/app.rs b/rust/hook-janitor/src/handlers/app.rs new file mode 100644 index 0000000000000..65692b14592a2 --- /dev/null +++ b/rust/hook-janitor/src/handlers/app.rs @@ -0,0 +1,14 @@ +use axum::{routing::get, Router}; +use health::HealthRegistry; +use std::future::ready; + +pub fn app(liveness: HealthRegistry) -> Router { + Router::new() + .route("/", get(index)) + .route("/_readiness", get(index)) + .route("/_liveness", get(move || ready(liveness.get_status()))) +} + +pub async fn index() -> &'static str { + "rusty-hook janitor" +} diff --git a/rust/hook-janitor/src/handlers/mod.rs b/rust/hook-janitor/src/handlers/mod.rs new file mode 100644 index 0000000000000..a884c04897bf9 --- /dev/null +++ b/rust/hook-janitor/src/handlers/mod.rs @@ -0,0 +1,3 @@ +mod app; + +pub use app::app; diff --git a/rust/hook-janitor/src/kafka_producer.rs b/rust/hook-janitor/src/kafka_producer.rs new file mode 100644 index 0000000000000..92608bcb999c8 --- /dev/null +++ b/rust/hook-janitor/src/kafka_producer.rs @@ -0,0 +1,57 @@ +use crate::config::KafkaConfig; + +use health::HealthHandle; +use rdkafka::error::KafkaError; +use rdkafka::producer::FutureProducer; +use rdkafka::ClientConfig; +use tracing::debug; + +pub struct KafkaContext { + liveness: HealthHandle, +} + +impl rdkafka::ClientContext for KafkaContext { + fn stats(&self, _: rdkafka::Statistics) { + // Signal liveness, as the main rdkafka loop is running and calling us + self.liveness.report_healthy_blocking(); + + // TODO: Take stats recording pieces that we want from `capture-rs`. + } +} + +pub async fn create_kafka_producer( + config: &KafkaConfig, + liveness: HealthHandle, +) -> Result, KafkaError> { + 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(), + ); + + 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_with_context(KafkaContext { liveness })?; + + // TODO: ping the kafka brokers to confirm configuration is OK (copy capture) + + Ok(api) +} diff --git a/rust/hook-janitor/src/main.rs b/rust/hook-janitor/src/main.rs new file mode 100644 index 0000000000000..325aa098ed6fe --- /dev/null +++ b/rust/hook-janitor/src/main.rs @@ -0,0 +1,97 @@ +use axum::Router; +use cleanup::{Cleaner, CleanerModeName}; +use config::Config; +use envconfig::Envconfig; +use eyre::Result; +use futures::future::{select, Either}; +use health::{HealthHandle, HealthRegistry}; +use kafka_producer::create_kafka_producer; +use std::{str::FromStr, time::Duration}; +use tokio::sync::Semaphore; +use webhooks::WebhookCleaner; + +use hook_common::metrics::setup_metrics_routes; + +mod cleanup; +mod config; +mod handlers; +mod kafka_producer; +mod webhooks; + +async fn listen(app: Router, bind: String) -> Result<()> { + let listener = tokio::net::TcpListener::bind(bind).await?; + + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn cleanup_loop(cleaner: Box, interval_secs: u64, liveness: HealthHandle) { + let semaphore = Semaphore::new(1); + let mut interval = tokio::time::interval(Duration::from_secs(interval_secs)); + + loop { + let _permit = semaphore.acquire().await; + interval.tick().await; + liveness.report_healthy().await; + cleaner.cleanup().await; + drop(_permit); + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let config = Config::init_from_env().expect("failed to load configuration from env"); + + let mode_name = CleanerModeName::from_str(&config.mode) + .unwrap_or_else(|_| panic!("invalid cleaner mode: {}", config.mode)); + + let liveness = HealthRegistry::new("liveness"); + + let cleaner = match mode_name { + CleanerModeName::Webhooks => { + let kafka_liveness = liveness + .register("rdkafka".to_string(), time::Duration::seconds(30)) + .await; + let kafka_producer = create_kafka_producer(&config.kafka, kafka_liveness) + .await + .expect("failed to create kafka producer"); + + Box::new( + WebhookCleaner::new( + &config.database_url, + kafka_producer, + config.kafka.app_metrics_topic.to_owned(), + ) + .expect("unable to create webhook cleaner"), + ) + } + }; + + let cleanup_liveness = liveness + .register( + "cleanup_loop".to_string(), + time::Duration::seconds(config.cleanup_interval_secs as i64 * 2), + ) + .await; + let cleanup_loop = Box::pin(cleanup_loop( + cleaner, + config.cleanup_interval_secs, + cleanup_liveness, + )); + + let app = setup_metrics_routes(handlers::app(liveness)); + let http_server = Box::pin(listen(app, config.bind())); + + match select(http_server, cleanup_loop).await { + Either::Left((listen_result, _)) => match listen_result { + Ok(_) => {} + Err(e) => tracing::error!("failed to start hook-janitor http server, {}", e), + }, + Either::Right((_, _)) => { + tracing::error!("hook-janitor cleanup task exited") + } + }; +} diff --git a/rust/hook-janitor/src/webhooks.rs b/rust/hook-janitor/src/webhooks.rs new file mode 100644 index 0000000000000..c1dfbba51aa35 --- /dev/null +++ b/rust/hook-janitor/src/webhooks.rs @@ -0,0 +1,899 @@ +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use futures::future::join_all; +use hook_common::webhook::WebhookJobError; +use rdkafka::error::KafkaError; +use rdkafka::producer::{FutureProducer, FutureRecord}; +use serde_json::error::Error as SerdeError; +use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions, Postgres}; +use sqlx::types::{chrono, Uuid}; +use sqlx::{Row, Transaction}; +use thiserror::Error; +use tracing::{debug, error, info}; + +use crate::cleanup::Cleaner; +use crate::kafka_producer::KafkaContext; + +use hook_common::kafka_messages::app_metrics::{AppMetric, AppMetricCategory}; +use hook_common::metrics::get_current_timestamp_seconds; + +#[derive(Error, Debug)] +pub enum WebhookCleanerError { + #[error("failed to create postgres pool: {error}")] + PoolCreationError { error: sqlx::Error }, + #[error("failed to acquire conn: {error}")] + AcquireConnError { error: sqlx::Error }, + #[error("failed to acquire conn and start txn: {error}")] + StartTxnError { error: sqlx::Error }, + #[error("failed to get queue depth: {error}")] + GetQueueDepthError { error: sqlx::Error }, + #[error("failed to get row count: {error}")] + GetRowCountError { error: sqlx::Error }, + #[error("failed to get completed rows: {error}")] + GetCompletedRowsError { error: sqlx::Error }, + #[error("failed to get failed rows: {error}")] + GetFailedRowsError { error: sqlx::Error }, + #[error("failed to serialize rows: {error}")] + SerializeRowsError { error: SerdeError }, + #[error("failed to produce to kafka: {error}")] + KafkaProduceError { error: KafkaError }, + #[error("failed to produce to kafka (timeout)")] + KafkaProduceCanceled, + #[error("failed to delete rows: {error}")] + DeleteRowsError { error: sqlx::Error }, + #[error("attempted to delete a different number of rows than expected")] + DeleteConsistencyError, + #[error("failed to rollback txn: {error}")] + RollbackTxnError { error: sqlx::Error }, + #[error("failed to commit txn: {error}")] + CommitTxnError { error: sqlx::Error }, +} + +type Result = std::result::Result; + +pub struct WebhookCleaner { + pg_pool: PgPool, + kafka_producer: FutureProducer, + app_metrics_topic: String, +} + +#[derive(sqlx::FromRow, Debug)] +struct CompletedRow { + // App Metrics truncates/aggregates rows on the hour, so we take advantage of that to GROUP BY + // and aggregate to select fewer rows. + hour: DateTime, + // A note about the `try_from`s: Postgres returns all of those types as `bigint` (i64), but + // we know their true sizes, and so we can convert them to the correct types here. If this + // ever fails then something has gone wrong. + #[sqlx(try_from = "i64")] + team_id: u32, + #[sqlx(try_from = "i64")] + plugin_config_id: i32, + #[sqlx(try_from = "i64")] + successes: u32, +} + +impl From for AppMetric { + fn from(row: CompletedRow) -> Self { + AppMetric { + timestamp: row.hour, + team_id: row.team_id, + plugin_config_id: row.plugin_config_id, + job_id: None, + category: AppMetricCategory::Webhook, + successes: row.successes, + successes_on_retry: 0, + failures: 0, + error_uuid: None, + error_type: None, + error_details: None, + } + } +} + +#[derive(sqlx::FromRow, Debug)] +struct FailedRow { + // App Metrics truncates/aggregates rows on the hour, so we take advantage of that to GROUP BY + // and aggregate to select fewer rows. + hour: DateTime, + // A note about the `try_from`s: Postgres returns all of those types as `bigint` (i64), but + // we know their true sizes, and so we can convert them to the correct types here. If this + // ever fails then something has gone wrong. + #[sqlx(try_from = "i64")] + team_id: u32, + #[sqlx(try_from = "i64")] + plugin_config_id: i32, + #[sqlx(json)] + last_error: WebhookJobError, + #[sqlx(try_from = "i64")] + failures: u32, +} + +#[derive(sqlx::FromRow, Debug)] +struct QueueDepth { + oldest_scheduled_at_untried: DateTime, + count_untried: i64, + oldest_scheduled_at_retries: DateTime, + count_retries: i64, +} + +impl From for AppMetric { + fn from(row: FailedRow) -> Self { + AppMetric { + timestamp: row.hour, + team_id: row.team_id, + plugin_config_id: row.plugin_config_id, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: row.failures, + error_uuid: Some(Uuid::now_v7()), + error_type: Some(row.last_error.r#type), + error_details: Some(row.last_error.details), + } + } +} + +// A simple wrapper type that ensures we don't use any old Transaction object when we need one +// that has set the isolation level to serializable. +struct SerializableTxn<'a>(Transaction<'a, Postgres>); + +struct CleanupStats { + rows_processed: u64, + completed_row_count: u64, + completed_agg_row_count: u64, + failed_row_count: u64, + failed_agg_row_count: u64, +} + +impl WebhookCleaner { + pub fn new( + database_url: &str, + kafka_producer: FutureProducer, + app_metrics_topic: String, + ) -> Result { + let options = PgConnectOptions::from_str(database_url) + .map_err(|error| WebhookCleanerError::PoolCreationError { error })? + .application_name("hook-janitor"); + let pg_pool = PgPoolOptions::new() + .acquire_timeout(Duration::from_secs(10)) + .connect_lazy_with(options); + + Ok(Self { + pg_pool, + kafka_producer, + app_metrics_topic, + }) + } + + #[allow(dead_code)] // This is used in tests. + pub fn new_from_pool( + pg_pool: PgPool, + kafka_producer: FutureProducer, + app_metrics_topic: String, + ) -> Result { + Ok(Self { + pg_pool, + kafka_producer, + app_metrics_topic, + }) + } + + async fn get_queue_depth(&self) -> Result { + let mut conn = self + .pg_pool + .acquire() + .await + .map_err(|e| WebhookCleanerError::AcquireConnError { error: e })?; + + let base_query = r#" + SELECT + COALESCE(MIN(CASE WHEN attempt = 0 THEN scheduled_at END), now()) AS oldest_scheduled_at_untried, + COALESCE(SUM(CASE WHEN attempt = 0 THEN 1 ELSE 0 END), 0) AS count_untried, + COALESCE(MIN(CASE WHEN attempt > 0 THEN scheduled_at END), now()) AS oldest_scheduled_at_retries, + COALESCE(SUM(CASE WHEN attempt > 0 THEN 1 ELSE 0 END), 0) AS count_retries + FROM job_queue + WHERE status = 'available'; + "#; + + let row = sqlx::query_as::<_, QueueDepth>(base_query) + .fetch_one(&mut *conn) + .await + .map_err(|e| WebhookCleanerError::GetQueueDepthError { error: e })?; + + Ok(row) + } + + async fn start_serializable_txn(&self) -> Result { + let mut tx = self + .pg_pool + .begin() + .await + .map_err(|e| WebhookCleanerError::StartTxnError { error: e })?; + + // We use serializable isolation so that we observe a snapshot of the DB at the time we + // start the cleanup process. This prevents us from accidentally deleting rows that are + // added (or become 'completed' or 'failed') after we start the cleanup process. + // + // If we find that this has a significant performance impact, we could instead move + // rows to a temporary table for processing and then deletion. + sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") + .execute(&mut *tx) + .await + .map_err(|e| WebhookCleanerError::StartTxnError { error: e })?; + + Ok(SerializableTxn(tx)) + } + + async fn get_row_count_for_status( + &self, + tx: &mut SerializableTxn<'_>, + status: &str, + ) -> Result { + let base_query = r#" + SELECT count(*) FROM job_queue + WHERE status = $1::job_status; + "#; + + let count: i64 = sqlx::query(base_query) + .bind(status) + .fetch_one(&mut *tx.0) + .await + .map_err(|e| WebhookCleanerError::GetRowCountError { error: e })? + .get(0); + + Ok(count as u64) + } + + async fn get_completed_agg_rows( + &self, + tx: &mut SerializableTxn<'_>, + ) -> Result> { + let base_query = r#" + SELECT DATE_TRUNC('hour', last_attempt_finished_at) AS hour, + (metadata->>'team_id')::bigint AS team_id, + (metadata->>'plugin_config_id')::bigint AS plugin_config_id, + count(*) as successes + FROM job_queue + WHERE status = 'completed' + GROUP BY hour, team_id, plugin_config_id + ORDER BY hour, team_id, plugin_config_id; + "#; + + let rows = sqlx::query_as::<_, CompletedRow>(base_query) + .fetch_all(&mut *tx.0) + .await + .map_err(|e| WebhookCleanerError::GetCompletedRowsError { error: e })?; + + Ok(rows) + } + + async fn get_failed_agg_rows(&self, tx: &mut SerializableTxn<'_>) -> Result> { + let base_query = r#" + SELECT DATE_TRUNC('hour', last_attempt_finished_at) AS hour, + (metadata->>'team_id')::bigint AS team_id, + (metadata->>'plugin_config_id')::bigint AS plugin_config_id, + errors[array_upper(errors, 1)] AS last_error, + count(*) as failures + FROM job_queue + WHERE status = 'failed' + GROUP BY hour, team_id, plugin_config_id, last_error + ORDER BY hour, team_id, plugin_config_id, last_error; + "#; + + let rows = sqlx::query_as::<_, FailedRow>(base_query) + .fetch_all(&mut *tx.0) + .await + .map_err(|e| WebhookCleanerError::GetFailedRowsError { error: e })?; + + Ok(rows) + } + + async fn send_metrics_to_kafka(&self, metrics: Vec) -> Result<()> { + if metrics.is_empty() { + return Ok(()); + } + + let payloads: Vec = metrics + .into_iter() + .map(|metric| serde_json::to_string(&metric)) + .collect::, SerdeError>>() + .map_err(|e| WebhookCleanerError::SerializeRowsError { error: e })?; + + let mut delivery_futures = Vec::new(); + + for payload in payloads { + match self.kafka_producer.send_result(FutureRecord { + topic: self.app_metrics_topic.as_str(), + payload: Some(&payload), + partition: None, + key: None::<&str>, + timestamp: None, + headers: None, + }) { + Ok(future) => delivery_futures.push(future), + Err((error, _)) => return Err(WebhookCleanerError::KafkaProduceError { error }), + } + } + + for result in join_all(delivery_futures).await { + match result { + Ok(Ok(_)) => {} + Ok(Err((error, _))) => { + return Err(WebhookCleanerError::KafkaProduceError { error }) + } + Err(_) => { + // Cancelled due to timeout while retrying + return Err(WebhookCleanerError::KafkaProduceCanceled); + } + } + } + + Ok(()) + } + + async fn delete_observed_rows(&self, tx: &mut SerializableTxn<'_>) -> Result { + // This DELETE is only safe because we are in serializable isolation mode, see the note + // in `start_serializable_txn`. + let base_query = r#" + DELETE FROM job_queue + WHERE status IN ('failed', 'completed') + "#; + + let result = sqlx::query(base_query) + .execute(&mut *tx.0) + .await + .map_err(|e| WebhookCleanerError::DeleteRowsError { error: e })?; + + Ok(result.rows_affected()) + } + + async fn rollback_txn(&self, tx: SerializableTxn<'_>) -> Result<()> { + tx.0.rollback() + .await + .map_err(|e| WebhookCleanerError::RollbackTxnError { error: e })?; + + Ok(()) + } + + async fn commit_txn(&self, tx: SerializableTxn<'_>) -> Result<()> { + tx.0.commit() + .await + .map_err(|e| WebhookCleanerError::CommitTxnError { error: e })?; + + Ok(()) + } + + async fn cleanup_impl(&self) -> Result { + debug!("WebhookCleaner starting cleanup"); + + // Note that we select all completed and failed rows without any pagination at the moment. + // We aggregrate as much as possible with GROUP BY, truncating the timestamp down to the + // hour just like App Metrics does. A completed row is 24 bytes (and aggregates an entire + // hour per `plugin_config_id`), and a failed row is 104 bytes + the error message length + // (and aggregates an entire hour per `plugin_config_id` per `error`), so we can fit a lot + // of rows in memory. It seems unlikely we'll need to paginate, but that can be added in the + // future if necessary. + + let untried_status = [("status", "untried")]; + let retries_status = [("status", "retries")]; + + let queue_depth = self.get_queue_depth().await?; + metrics::gauge!("queue_depth_oldest_scheduled", &untried_status) + .set(queue_depth.oldest_scheduled_at_untried.timestamp() as f64); + metrics::gauge!("queue_depth", &untried_status).set(queue_depth.count_untried as f64); + metrics::gauge!("queue_depth_oldest_scheduled", &retries_status) + .set(queue_depth.oldest_scheduled_at_retries.timestamp() as f64); + metrics::gauge!("queue_depth", &retries_status).set(queue_depth.count_retries as f64); + + let mut tx = self.start_serializable_txn().await?; + + let (completed_row_count, completed_agg_row_count) = { + let completed_row_count = self.get_row_count_for_status(&mut tx, "completed").await?; + let completed_agg_rows = self.get_completed_agg_rows(&mut tx).await?; + let agg_row_count = completed_agg_rows.len() as u64; + let completed_app_metrics: Vec = + completed_agg_rows.into_iter().map(Into::into).collect(); + self.send_metrics_to_kafka(completed_app_metrics).await?; + (completed_row_count, agg_row_count) + }; + + let (failed_row_count, failed_agg_row_count) = { + let failed_row_count = self.get_row_count_for_status(&mut tx, "failed").await?; + let failed_agg_rows = self.get_failed_agg_rows(&mut tx).await?; + let agg_row_count = failed_agg_rows.len() as u64; + let failed_app_metrics: Vec = + failed_agg_rows.into_iter().map(Into::into).collect(); + self.send_metrics_to_kafka(failed_app_metrics).await?; + (failed_row_count, agg_row_count) + }; + + let mut rows_deleted = 0; + if completed_agg_row_count + failed_agg_row_count != 0 { + rows_deleted = self.delete_observed_rows(&mut tx).await?; + + if rows_deleted != completed_row_count + failed_row_count { + // This should never happen, but if it does, we want to know about it (and abort the + // txn). + error!( + attempted_rows_deleted = rows_deleted, + completed_row_count = completed_row_count, + failed_row_count = failed_row_count, + "WebhookCleaner::cleanup attempted to delete a different number of rows than expected" + ); + + self.rollback_txn(tx).await?; + + return Err(WebhookCleanerError::DeleteConsistencyError); + } + + self.commit_txn(tx).await?; + } + + Ok(CleanupStats { + rows_processed: rows_deleted, + completed_row_count, + completed_agg_row_count, + failed_row_count, + failed_agg_row_count, + }) + } +} + +#[async_trait] +impl Cleaner for WebhookCleaner { + async fn cleanup(&self) { + let start_time = Instant::now(); + metrics::counter!("webhook_cleanup_attempts",).increment(1); + + match self.cleanup_impl().await { + Ok(stats) => { + metrics::counter!("webhook_cleanup_success",).increment(1); + metrics::gauge!("webhook_cleanup_last_success_timestamp",) + .set(get_current_timestamp_seconds()); + + if stats.rows_processed > 0 { + let elapsed_time = start_time.elapsed().as_secs_f64(); + metrics::histogram!("webhook_cleanup_duration").record(elapsed_time); + metrics::counter!("webhook_cleanup_rows_processed",) + .increment(stats.rows_processed); + metrics::counter!("webhook_cleanup_completed_row_count",) + .increment(stats.completed_row_count); + metrics::counter!("webhook_cleanup_completed_agg_row_count",) + .increment(stats.completed_agg_row_count); + metrics::counter!("webhook_cleanup_failed_row_count",) + .increment(stats.failed_row_count); + metrics::counter!("webhook_cleanup_failed_agg_row_count",) + .increment(stats.failed_agg_row_count); + + info!( + rows_processed = stats.rows_processed, + completed_row_count = stats.completed_row_count, + completed_agg_row_count = stats.completed_agg_row_count, + failed_row_count = stats.failed_row_count, + failed_agg_row_count = stats.failed_agg_row_count, + "WebhookCleaner::cleanup finished" + ); + } else { + debug!("WebhookCleaner finished cleanup, there were no rows to process"); + } + } + Err(error) => { + metrics::counter!("webhook_cleanup_failures",).increment(1); + error!(error = ?error, "WebhookCleaner::cleanup failed"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + use crate::kafka_producer::{create_kafka_producer, KafkaContext}; + use health::HealthRegistry; + use hook_common::kafka_messages::app_metrics::{ + Error as WebhookError, ErrorDetails, ErrorType, + }; + use hook_common::pgqueue::PgQueueJob; + use hook_common::pgqueue::{NewJob, PgQueue, PgTransactionBatch}; + use hook_common::webhook::{HttpMethod, WebhookJobMetadata, WebhookJobParameters}; + use rdkafka::consumer::{Consumer, StreamConsumer}; + use rdkafka::mocking::MockCluster; + use rdkafka::producer::{DefaultProducerContext, FutureProducer}; + use rdkafka::types::{RDKafkaApiKey, RDKafkaRespErr}; + use rdkafka::{ClientConfig, Message}; + use sqlx::{PgPool, Row}; + use std::collections::HashMap; + use std::str::FromStr; + + const APP_METRICS_TOPIC: &str = "app_metrics"; + + async fn create_mock_kafka() -> ( + MockCluster<'static, DefaultProducerContext>, + FutureProducer, + ) { + let registry = HealthRegistry::new("liveness"); + let handle = registry + .register("one".to_string(), time::Duration::seconds(30)) + .await; + let cluster = MockCluster::new(1).expect("failed to create mock brokers"); + + let config = config::KafkaConfig { + kafka_producer_linger_ms: 0, + kafka_producer_queue_mib: 50, + kafka_message_timeout_ms: 5000, + kafka_compression_codec: "none".to_string(), + kafka_hosts: cluster.bootstrap_servers(), + app_metrics_topic: APP_METRICS_TOPIC.to_string(), + plugin_log_entries_topic: "plugin_log_entries".to_string(), + kafka_tls: false, + }; + + ( + cluster, + create_kafka_producer(&config, handle) + .await + .expect("failed to create mocked kafka producer"), + ) + } + + fn check_app_metric_vector_equality(v1: &[AppMetric], v2: &[AppMetric]) { + // Ignores `error_uuid`s. + assert_eq!(v1.len(), v2.len()); + for (item1, item2) in v1.iter().zip(v2) { + let mut item1 = item1.clone(); + item1.error_uuid = None; + let mut item2 = item2.clone(); + item2.error_uuid = None; + assert_eq!(item1, item2); + } + } + + #[sqlx::test(migrations = "../migrations", fixtures("webhook_cleanup"))] + async fn test_cleanup_impl(db: PgPool) { + let (mock_cluster, mock_producer) = create_mock_kafka().await; + mock_cluster + .create_topic(APP_METRICS_TOPIC, 1, 1) + .expect("failed to create mock app_metrics topic"); + + let consumer: StreamConsumer = ClientConfig::new() + .set("bootstrap.servers", mock_cluster.bootstrap_servers()) + .set("group.id", "mock") + .set("auto.offset.reset", "earliest") + .create() + .expect("failed to create mock consumer"); + consumer.subscribe(&[APP_METRICS_TOPIC]).unwrap(); + + let webhook_cleaner = + WebhookCleaner::new_from_pool(db, mock_producer, APP_METRICS_TOPIC.to_owned()) + .expect("unable to create webhook cleaner"); + + let cleanup_stats = webhook_cleaner + .cleanup_impl() + .await + .expect("webbook cleanup_impl failed"); + + // Rows that are not 'completed' or 'failed' should not be processed. + assert_eq!(cleanup_stats.rows_processed, 13); + + let mut received_app_metrics = Vec::new(); + for _ in 0..(cleanup_stats.completed_agg_row_count + cleanup_stats.failed_agg_row_count) { + let kafka_msg = consumer.recv().await.unwrap(); + let payload_str = String::from_utf8(kafka_msg.payload().unwrap().to_vec()).unwrap(); + let app_metric: AppMetric = serde_json::from_str(&payload_str).unwrap(); + received_app_metrics.push(app_metric); + } + + let expected_app_metrics = vec![ + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 2, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 3, + successes_on_retry: 0, + failures: 0, + error_uuid: None, + error_type: None, + error_details: None, + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 3, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 1, + successes_on_retry: 0, + failures: 0, + error_uuid: None, + error_type: None, + error_details: None, + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 2, + plugin_config_id: 4, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 1, + successes_on_retry: 0, + failures: 0, + error_uuid: None, + error_type: None, + error_details: None, + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T21:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 2, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 1, + successes_on_retry: 0, + failures: 0, + error_uuid: None, + error_type: None, + error_details: None, + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 2, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: 1, + error_uuid: Some(Uuid::parse_str("018c8935-d038-714a-957c-0df43d42e377").unwrap()), + error_type: Some(ErrorType::ConnectionError), + error_details: Some(ErrorDetails { + error: WebhookError { + name: "Connection Error".to_owned(), + message: None, + stack: None, + }, + }), + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 2, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: 3, + error_uuid: Some(Uuid::parse_str("018c8935-d038-714a-957c-0df43d42e377").unwrap()), + error_type: Some(ErrorType::TimeoutError), + error_details: Some(ErrorDetails { + error: WebhookError { + name: "Timeout".to_owned(), + message: None, + stack: None, + }, + }), + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 3, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: 1, + error_uuid: Some(Uuid::parse_str("018c8935-d038-714a-957c-0df43d42e377").unwrap()), + error_type: Some(ErrorType::TimeoutError), + error_details: Some(ErrorDetails { + error: WebhookError { + name: "Timeout".to_owned(), + message: None, + stack: None, + }, + }), + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T20:00:00Z").unwrap(), + team_id: 2, + plugin_config_id: 4, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: 1, + error_uuid: Some(Uuid::parse_str("018c8935-d038-714a-957c-0df43d42e377").unwrap()), + error_type: Some(ErrorType::TimeoutError), + error_details: Some(ErrorDetails { + error: WebhookError { + name: "Timeout".to_owned(), + message: None, + stack: None, + }, + }), + }, + AppMetric { + timestamp: DateTime::::from_str("2023-12-19T21:00:00Z").unwrap(), + team_id: 1, + plugin_config_id: 2, + job_id: None, + category: AppMetricCategory::Webhook, + successes: 0, + successes_on_retry: 0, + failures: 1, + error_uuid: Some(Uuid::parse_str("018c8935-d038-714a-957c-0df43d42e377").unwrap()), + error_type: Some(ErrorType::TimeoutError), + error_details: Some(ErrorDetails { + error: WebhookError { + name: "Timeout".to_owned(), + message: None, + stack: None, + }, + }), + }, + ]; + + check_app_metric_vector_equality(&expected_app_metrics, &received_app_metrics); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_cleanup_impl_empty_queue(db: PgPool) { + let (mock_cluster, mock_producer) = create_mock_kafka().await; + mock_cluster + .create_topic(APP_METRICS_TOPIC, 1, 1) + .expect("failed to create mock app_metrics topic"); + + // No payload should be produced to kafka as the queue is empty. + // Set a non-retriable produce error that would bubble-up when cleanup_impl is called. + let err = [RDKafkaRespErr::RD_KAFKA_RESP_ERR_MSG_SIZE_TOO_LARGE; 1]; + mock_cluster.request_errors(RDKafkaApiKey::Produce, &err); + + let consumer: StreamConsumer = ClientConfig::new() + .set("bootstrap.servers", mock_cluster.bootstrap_servers()) + .set("group.id", "mock") + .set("auto.offset.reset", "earliest") + .create() + .expect("failed to create mock consumer"); + consumer.subscribe(&[APP_METRICS_TOPIC]).unwrap(); + + let webhook_cleaner = + WebhookCleaner::new_from_pool(db, mock_producer, APP_METRICS_TOPIC.to_owned()) + .expect("unable to create webhook cleaner"); + + let cleanup_stats = webhook_cleaner + .cleanup_impl() + .await + .expect("webbook cleanup_impl failed"); + + // Reported metrics are all zeroes + assert_eq!(cleanup_stats.rows_processed, 0); + assert_eq!(cleanup_stats.completed_row_count, 0); + assert_eq!(cleanup_stats.completed_agg_row_count, 0); + assert_eq!(cleanup_stats.failed_row_count, 0); + assert_eq!(cleanup_stats.failed_agg_row_count, 0); + } + + #[sqlx::test(migrations = "../migrations", fixtures("webhook_cleanup"))] + async fn test_serializable_isolation(db: PgPool) { + let (_, mock_producer) = create_mock_kafka().await; + let webhook_cleaner = + WebhookCleaner::new_from_pool(db.clone(), mock_producer, APP_METRICS_TOPIC.to_owned()) + .expect("unable to create webhook cleaner"); + + let queue = PgQueue::new_from_pool("webhooks", db.clone()).await; + + async fn get_count_from_new_conn(db: &PgPool, status: &str) -> i64 { + let mut conn = db.acquire().await.unwrap(); + let count: i64 = + sqlx::query("SELECT count(*) FROM job_queue WHERE status = $1::job_status") + .bind(&status) + .fetch_one(&mut *conn) + .await + .unwrap() + .get(0); + count + } + + // Important! Serializable txn is started here. + let mut tx = webhook_cleaner.start_serializable_txn().await.unwrap(); + webhook_cleaner + .get_completed_agg_rows(&mut tx) + .await + .unwrap(); + webhook_cleaner.get_failed_agg_rows(&mut tx).await.unwrap(); + + // All 15 rows in the DB are visible from outside the txn. + // The 13 the cleaner will process, plus 1 available and 1 running. + assert_eq!(get_count_from_new_conn(&db, "completed").await, 6); + assert_eq!(get_count_from_new_conn(&db, "failed").await, 7); + assert_eq!(get_count_from_new_conn(&db, "available").await, 1); + + { + // The fixtures include an available job, so let's complete it while the txn is open. + let mut batch: PgTransactionBatch<'_, WebhookJobParameters, WebhookJobMetadata> = queue + .dequeue_tx(&"worker_id", 1) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + let webhook_job = batch.jobs.pop().unwrap(); + webhook_job + .complete() + .await + .expect("failed to complete job"); + batch.commit().await.expect("failed to commit batch"); + } + + { + // Enqueue and complete another job while the txn is open. + let job_parameters = WebhookJobParameters { + body: "foo".to_owned(), + headers: HashMap::new(), + method: HttpMethod::POST, + url: "http://example.com".to_owned(), + }; + let job_metadata = WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }; + let new_job = NewJob::new(1, job_metadata, job_parameters, &"target"); + queue.enqueue(new_job).await.expect("failed to enqueue job"); + let mut batch: PgTransactionBatch<'_, WebhookJobParameters, WebhookJobMetadata> = queue + .dequeue_tx(&"worker_id", 1) + .await + .expect("failed to dequeue job") + .expect("didn't find a job to dequeue"); + let webhook_job = batch.jobs.pop().unwrap(); + webhook_job + .complete() + .await + .expect("failed to complete job"); + batch.commit().await.expect("failed to commit batch"); + } + + { + // Enqueue another available job while the txn is open. + let job_parameters = WebhookJobParameters { + body: "foo".to_owned(), + headers: HashMap::new(), + method: HttpMethod::POST, + url: "http://example.com".to_owned(), + }; + let job_metadata = WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }; + let new_job = NewJob::new(1, job_metadata, job_parameters, &"target"); + queue.enqueue(new_job).await.expect("failed to enqueue job"); + } + + // There are now 2 more completed rows (jobs added above) than before, visible from outside the txn. + assert_eq!(get_count_from_new_conn(&db, "completed").await, 8); + assert_eq!(get_count_from_new_conn(&db, "available").await, 1); + + let rows_processed = webhook_cleaner.delete_observed_rows(&mut tx).await.unwrap(); + // The 13 rows in the DB when the txn started should be deleted. + assert_eq!(rows_processed, 13); + + // We haven't committed, so the rows are still visible from outside the txn. + assert_eq!(get_count_from_new_conn(&db, "completed").await, 8); + assert_eq!(get_count_from_new_conn(&db, "available").await, 1); + + webhook_cleaner.commit_txn(tx).await.unwrap(); + + // We have committed, what remains are: + // * The 1 available job we completed while the txn was open. + // * The 2 brand new jobs we added while the txn was open. + // * The 1 running job that didn't change. + assert_eq!(get_count_from_new_conn(&db, "completed").await, 2); + assert_eq!(get_count_from_new_conn(&db, "failed").await, 0); + assert_eq!(get_count_from_new_conn(&db, "available").await, 1); + } +} diff --git a/rust/hook-worker/Cargo.toml b/rust/hook-worker/Cargo.toml new file mode 100644 index 0000000000000..79416f9004a10 --- /dev/null +++ b/rust/hook-worker/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "hook-worker" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +chrono = { workspace = true } +envconfig = { workspace = true } +futures = "0.3" +health = { path = "../common/health" } +hook-common = { path = "../hook-common" } +http = { workspace = true } +metrics = { workspace = true } +reqwest = { workspace = true } +sqlx = { workspace = true } +thiserror = { workspace = true } +time = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +url = { version = "2.2" } diff --git a/rust/hook-worker/README.md b/rust/hook-worker/README.md new file mode 100644 index 0000000000000..9b1884aab16b6 --- /dev/null +++ b/rust/hook-worker/README.md @@ -0,0 +1,2 @@ +# hook-worker +Consume and process webhook jobs diff --git a/rust/hook-worker/src/config.rs b/rust/hook-worker/src/config.rs new file mode 100644 index 0000000000000..51b23b7f273c5 --- /dev/null +++ b/rust/hook-worker/src/config.rs @@ -0,0 +1,104 @@ +use std::str::FromStr; +use std::time; + +use envconfig::Envconfig; + +#[derive(Envconfig, Clone)] +pub struct Config { + #[envconfig(from = "BIND_HOST", default = "0.0.0.0")] + pub host: String, + + #[envconfig(from = "BIND_PORT", default = "3301")] + pub port: u16, + + #[envconfig(default = "postgres://posthog:posthog@localhost:15432/test_database")] + pub database_url: String, + + #[envconfig(default = "worker")] + pub worker_name: String, + + #[envconfig(default = "default")] + pub queue_name: NonEmptyString, + + #[envconfig(default = "100")] + pub poll_interval: EnvMsDuration, + + #[envconfig(default = "5000")] + pub request_timeout: EnvMsDuration, + + #[envconfig(default = "1024")] + pub max_concurrent_jobs: usize, + + #[envconfig(default = "100")] + pub max_pg_connections: u32, + + #[envconfig(nested = true)] + pub retry_policy: RetryPolicyConfig, + + #[envconfig(default = "1")] + pub dequeue_batch_size: u32, + + #[envconfig(default = "false")] + pub allow_internal_ips: bool, +} + +impl Config { + /// Produce a host:port address for binding a TcpListener. + pub fn bind(&self) -> String { + format!("{}:{}", self.host, self.port) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct EnvMsDuration(pub time::Duration); + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseEnvMsDurationError; + +impl FromStr for EnvMsDuration { + type Err = ParseEnvMsDurationError; + + fn from_str(s: &str) -> Result { + let ms = s.parse::().map_err(|_| ParseEnvMsDurationError)?; + + Ok(EnvMsDuration(time::Duration::from_millis(ms))) + } +} + +#[derive(Envconfig, Clone)] +pub struct RetryPolicyConfig { + #[envconfig(default = "2")] + pub backoff_coefficient: u32, + + #[envconfig(default = "1000")] + pub initial_interval: EnvMsDuration, + + #[envconfig(default = "100000")] + pub maximum_interval: EnvMsDuration, + + pub retry_queue_name: Option, +} + +#[derive(Debug, Clone)] +pub struct NonEmptyString(pub String); + +impl NonEmptyString { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct StringIsEmptyError; + +impl FromStr for NonEmptyString { + type Err = StringIsEmptyError; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + Err(StringIsEmptyError) + } else { + Ok(NonEmptyString(s.to_owned())) + } + } +} diff --git a/rust/hook-worker/src/dns.rs b/rust/hook-worker/src/dns.rs new file mode 100644 index 0000000000000..36fd7a005398e --- /dev/null +++ b/rust/hook-worker/src/dns.rs @@ -0,0 +1,140 @@ +use std::error::Error as StdError; +use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; +use std::{fmt, io}; + +use futures::FutureExt; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use tokio::task::spawn_blocking; + +pub struct NoPublicIPv4Error; + +impl std::error::Error for NoPublicIPv4Error {} +impl fmt::Display for NoPublicIPv4Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "No public IPv4 found for specified host") + } +} +impl fmt::Debug for NoPublicIPv4Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "No public IPv4 found for specified host") + } +} + +/// Internal reqwest type, copied here as part of Resolving +pub(crate) type BoxError = Box; + +/// Returns [`true`] if the address appears to be a globally reachable IPv4. +/// +/// Trimmed down version of the unstable IpAddr::is_global, move to it when it's stable. +fn is_global_ipv4(addr: &SocketAddr) -> bool { + match addr.ip() { + IpAddr::V4(ip) => { + !(ip.octets()[0] == 0 // "This network" + || ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_broadcast()) + } + IpAddr::V6(_) => false, // Our network does not currently support ipv6, let's ignore for now + } +} + +/// DNS resolver using the stdlib resolver, but filtering results to only pass public IPv4 results. +/// +/// Private and broadcast addresses are filtered out, so are IPv6 results for now (as our infra +/// does not currently support IPv6 routing anyway). +/// This is adapted from the GaiResolver in hyper and reqwest. +pub struct PublicIPv4Resolver {} + +impl Resolve for PublicIPv4Resolver { + fn resolve(&self, name: Name) -> Resolving { + // Closure to call the system's resolver (blocking call) through the ToSocketAddrs trait. + let resolve_host = move || (name.as_str(), 0).to_socket_addrs(); + + // Execute the blocking call in a separate worker thread then process its result asynchronously. + // spawn_blocking returns a JoinHandle that implements Future>. + let future_result = spawn_blocking(resolve_host).map(|result| match result { + Ok(Ok(all_addrs)) => { + // Resolution succeeded, filter the results + let filtered_addr: Vec = all_addrs.filter(is_global_ipv4).collect(); + if filtered_addr.is_empty() { + // No public IPs found, error out with PermissionDenied + let err: BoxError = Box::new(NoPublicIPv4Error); + Err(err) + } else { + // Pass remaining IPs in a boxed iterator for request to use. + let addrs: Addrs = Box::new(filtered_addr.into_iter()); + Ok(addrs) + } + } + Ok(Err(err)) => { + // Resolution failed, pass error through in a Box + let err: BoxError = Box::new(err); + Err(err) + } + Err(join_err) => { + // The tokio task failed, pass as io::Error in a Box + let err: BoxError = Box::new(io::Error::from(join_err)); + Err(err) + } + }); + + // Box the Future to satisfy the Resolving interface. + Box::pin(future_result) + } +} + +#[cfg(test)] +mod tests { + use crate::dns::{NoPublicIPv4Error, PublicIPv4Resolver}; + use reqwest::dns::{Name, Resolve}; + use std::str::FromStr; + + #[tokio::test] + async fn it_resolves_google_com() { + let resolver: PublicIPv4Resolver = PublicIPv4Resolver {}; + let addrs = resolver + .resolve(Name::from_str("google.com").unwrap()) + .await + .expect("lookup has failed"); + assert!(addrs.count() > 0, "empty address list") + } + + #[tokio::test] + async fn it_denies_ipv6_google_com() { + let resolver: PublicIPv4Resolver = PublicIPv4Resolver {}; + match resolver + .resolve(Name::from_str("ipv6.google.com").unwrap()) + .await + { + Ok(_) => panic!("should have failed"), + Err(err) => assert!(err.is::()), + } + } + + #[tokio::test] + async fn it_denies_localhost() { + let resolver: PublicIPv4Resolver = PublicIPv4Resolver {}; + match resolver.resolve(Name::from_str("localhost").unwrap()).await { + Ok(_) => panic!("should have failed"), + Err(err) => assert!(err.is::()), + } + } + + #[tokio::test] + async fn it_bubbles_up_resolution_error() { + let resolver: PublicIPv4Resolver = PublicIPv4Resolver {}; + match resolver + .resolve(Name::from_str("invalid.domain.unknown").unwrap()) + .await + { + Ok(_) => panic!("should have failed"), + Err(err) => { + assert!(!err.is::()); + assert!(err + .to_string() + .contains("failed to lookup address information")) + } + } + } +} diff --git a/rust/hook-worker/src/error.rs b/rust/hook-worker/src/error.rs new file mode 100644 index 0000000000000..764e8d973499c --- /dev/null +++ b/rust/hook-worker/src/error.rs @@ -0,0 +1,152 @@ +use std::error::Error; +use std::fmt; +use std::time; + +use crate::dns::NoPublicIPv4Error; +use hook_common::{pgqueue, webhook::WebhookJobError}; +use thiserror::Error; + +/// Enumeration of error classes handled by `WebhookWorker`. +#[derive(Error, Debug)] +pub enum WebhookError { + #[error(transparent)] + Parse(#[from] WebhookParseError), + #[error(transparent)] + Request(#[from] WebhookRequestError), +} + +/// Enumeration of parsing errors that can occur as `WebhookWorker` sets up a webhook. +#[derive(Error, Debug)] +pub enum WebhookParseError { + #[error("{0} is not a valid HttpMethod")] + ParseHttpMethodError(String), + #[error("error parsing webhook headers")] + ParseHeadersError(http::Error), + #[error("error parsing webhook url")] + ParseUrlError(url::ParseError), +} + +/// Enumeration of request errors that can occur as `WebhookWorker` sends a request. +#[derive(Error, Debug)] +pub enum WebhookRequestError { + RetryableRequestError { + error: reqwest::Error, + response: Option, + retry_after: Option, + }, + NonRetryableRetryableRequestError { + error: reqwest::Error, + response: Option, + }, +} + +/// Enumeration of errors that can occur while handling a `reqwest::Response`. +/// Currently, not consumed anywhere. Grouped here to support a common error type for +/// `utils::first_n_bytes_of_response`. +#[derive(Error, Debug)] +pub enum WebhookResponseError { + #[error("failed to parse a response as UTF8")] + ParseUTF8StringError(#[from] std::str::Utf8Error), + #[error("error while iterating over response body chunks")] + StreamIterationError(#[from] reqwest::Error), + #[error("attempted to slice a chunk of length {0} with an out of bounds index of {1}")] + ChunkOutOfBoundsError(usize, usize), +} + +/// Implement display of `WebhookRequestError` by appending to the underlying `reqwest::Error` +/// any response message if available. +impl fmt::Display for WebhookRequestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WebhookRequestError::RetryableRequestError { + error, response, .. + } + | WebhookRequestError::NonRetryableRetryableRequestError { error, response } => { + let response_message = match response { + Some(m) => m.to_string(), + None => "No response from the server".to_string(), + }; + if is_error_source::(error) { + writeln!(f, "{}: {}", error, NoPublicIPv4Error)?; + } else { + writeln!(f, "{}", error)?; + } + write!(f, "{}", response_message)?; + + Ok(()) + } + } + } +} + +/// Implementation of `WebhookRequestError` designed to further describe the error. +/// In particular, we pass some calls to underyling `reqwest::Error` to provide more details. +impl WebhookRequestError { + pub fn is_timeout(&self) -> bool { + match self { + WebhookRequestError::RetryableRequestError { error, .. } + | WebhookRequestError::NonRetryableRetryableRequestError { error, .. } => { + error.is_timeout() + } + } + } + + pub fn is_status(&self) -> bool { + match self { + WebhookRequestError::RetryableRequestError { error, .. } + | WebhookRequestError::NonRetryableRetryableRequestError { error, .. } => { + error.is_status() + } + } + } + + pub fn status(&self) -> Option { + match self { + WebhookRequestError::RetryableRequestError { error, .. } + | WebhookRequestError::NonRetryableRetryableRequestError { error, .. } => { + error.status() + } + } + } +} + +impl From<&WebhookRequestError> for WebhookJobError { + fn from(error: &WebhookRequestError) -> Self { + if error.is_timeout() { + WebhookJobError::new_timeout(&error.to_string()) + } else if error.is_status() { + WebhookJobError::new_http_status( + error.status().expect("status code is defined").into(), + &error.to_string(), + ) + } else { + // Catch all other errors as `app_metrics::ErrorType::Connection` errors. + // Not all of `reqwest::Error` may strictly be connection errors, so our supported error types may need an extension + // depending on how strict error reporting has to be. + WebhookJobError::new_connection(&error.to_string()) + } + } +} + +/// Enumeration of errors related to initialization and consumption of webhook jobs. +#[derive(Error, Debug)] +pub enum WorkerError { + #[error("a database error occurred when executing a job")] + DatabaseError(#[from] pgqueue::DatabaseError), + #[error("a parsing error occurred in the underlying queue")] + QueueParseError(#[from] pgqueue::ParseError), + #[error("timed out while waiting for jobs to be available")] + TimeoutError, +} + +/// Check the error and it's sources (recursively) to return true if an error of the given type is found. +/// TODO: use Error::sources() when stable +pub fn is_error_source(err: &(dyn std::error::Error + 'static)) -> bool { + if err.is::() { + return true; + } + match err.source() { + None => false, + Some(source) => is_error_source::(source), + } +} diff --git a/rust/hook-worker/src/lib.rs b/rust/hook-worker/src/lib.rs new file mode 100644 index 0000000000000..94a07584f1da5 --- /dev/null +++ b/rust/hook-worker/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod dns; +pub mod error; +pub mod util; +pub mod worker; diff --git a/rust/hook-worker/src/main.rs b/rust/hook-worker/src/main.rs new file mode 100644 index 0000000000000..050e2b947c780 --- /dev/null +++ b/rust/hook-worker/src/main.rs @@ -0,0 +1,78 @@ +//! Consume `PgQueue` jobs to run webhook calls. +use axum::routing::get; +use axum::Router; +use envconfig::Envconfig; +use std::future::ready; + +use health::HealthRegistry; +use hook_common::{ + metrics::serve, metrics::setup_metrics_routes, pgqueue::PgQueue, retry::RetryPolicy, +}; +use hook_worker::config::Config; +use hook_worker::error::WorkerError; +use hook_worker::worker::WebhookWorker; + +#[tokio::main] +async fn main() -> Result<(), WorkerError> { + tracing_subscriber::fmt::init(); + + let config = Config::init_from_env().expect("Invalid configuration:"); + + let liveness = HealthRegistry::new("liveness"); + let worker_liveness = liveness + .register("worker".to_string(), time::Duration::seconds(60)) // TODO: compute the value from worker params + .await; + + let mut retry_policy_builder = RetryPolicy::build( + config.retry_policy.backoff_coefficient, + config.retry_policy.initial_interval.0, + ) + .maximum_interval(config.retry_policy.maximum_interval.0); + + retry_policy_builder = if let Some(retry_queue_name) = &config.retry_policy.retry_queue_name { + retry_policy_builder.queue(retry_queue_name.as_str()) + } else { + retry_policy_builder + }; + + let queue = PgQueue::new( + config.queue_name.as_str(), + &config.database_url, + config.max_pg_connections, + "hook-worker", + ) + .await + .expect("failed to initialize queue"); + + let worker = WebhookWorker::new( + &config.worker_name, + &queue, + config.dequeue_batch_size, + config.poll_interval.0, + config.request_timeout.0, + config.max_concurrent_jobs, + retry_policy_builder.provide(), + config.allow_internal_ips, + worker_liveness, + ); + + let router = Router::new() + .route("/", get(index)) + .route("/_readiness", get(index)) + .route("/_liveness", get(move || ready(liveness.get_status()))); + let router = setup_metrics_routes(router); + let bind = config.bind(); + tokio::task::spawn(async move { + serve(router, &bind) + .await + .expect("failed to start serving metrics"); + }); + + worker.run().await; + + Ok(()) +} + +pub async fn index() -> &'static str { + "rusty-hook worker" +} diff --git a/rust/hook-worker/src/util.rs b/rust/hook-worker/src/util.rs new file mode 100644 index 0000000000000..00c5432168645 --- /dev/null +++ b/rust/hook-worker/src/util.rs @@ -0,0 +1,35 @@ +use crate::error::WebhookResponseError; +use futures::StreamExt; +use reqwest::Response; + +pub async fn first_n_bytes_of_response( + response: Response, + n: usize, +) -> Result { + let mut body = response.bytes_stream(); + let mut buffer = String::with_capacity(n); + + while let Some(chunk) = body.next().await { + if buffer.len() >= n { + break; + } + + let chunk = chunk?; + let chunk_str = std::str::from_utf8(&chunk)?; + let upper_bound = std::cmp::min(n - buffer.len(), chunk_str.len()); + + if let Some(partial_chunk_str) = chunk_str.get(0..upper_bound) { + buffer.push_str(partial_chunk_str); + } else { + // For whatever reason we are out of bounds. We should never land here + // given the `std::cmp::min` usage, but I am being extra careful by not + // using a slice index that would panic instead. + return Err(WebhookResponseError::ChunkOutOfBoundsError( + chunk_str.len(), + upper_bound, + )); + } + } + + Ok(buffer) +} diff --git a/rust/hook-worker/src/worker.rs b/rust/hook-worker/src/worker.rs new file mode 100644 index 0000000000000..9dcc4a2f4b7b0 --- /dev/null +++ b/rust/hook-worker/src/worker.rs @@ -0,0 +1,717 @@ +use std::collections; +use std::sync::Arc; +use std::time; + +use chrono::Utc; +use futures::future::join_all; +use health::HealthHandle; +use hook_common::pgqueue::PgTransactionBatch; +use hook_common::{ + pgqueue::{Job, PgQueue, PgQueueJob, PgTransactionJob, RetryError, RetryInvalidError}, + retry::RetryPolicy, + webhook::{HttpMethod, WebhookJobError, WebhookJobMetadata, WebhookJobParameters}, +}; +use http::StatusCode; +use reqwest::{header, Client}; +use tokio::sync; +use tracing::error; + +use crate::dns::{NoPublicIPv4Error, PublicIPv4Resolver}; +use crate::error::{ + is_error_source, WebhookError, WebhookParseError, WebhookRequestError, WorkerError, +}; +use crate::util::first_n_bytes_of_response; + +/// A WebhookJob is any `PgQueueJob` with `WebhookJobParameters` and `WebhookJobMetadata`. +trait WebhookJob: PgQueueJob + std::marker::Send { + fn parameters(&self) -> &WebhookJobParameters; + fn metadata(&self) -> &WebhookJobMetadata; + fn job(&self) -> &Job; + + fn attempt(&self) -> i32 { + self.job().attempt + } + + fn queue(&self) -> String { + self.job().queue.to_owned() + } + + fn target(&self) -> String { + self.job().target.to_owned() + } +} + +impl WebhookJob for PgTransactionJob<'_, WebhookJobParameters, WebhookJobMetadata> { + fn parameters(&self) -> &WebhookJobParameters { + &self.job.parameters + } + + fn metadata(&self) -> &WebhookJobMetadata { + &self.job.metadata + } + + fn job(&self) -> &Job { + &self.job + } +} + +/// A worker to poll `PgQueue` and spawn tasks to process webhooks when a job becomes available. +pub struct WebhookWorker<'p> { + /// An identifier for this worker. Used to mark jobs we have consumed. + name: String, + /// The queue we will be dequeuing jobs from. + queue: &'p PgQueue, + /// The maximum number of jobs to dequeue in one query. + dequeue_batch_size: u32, + /// The interval for polling the queue. + poll_interval: time::Duration, + /// The client used for HTTP requests. + client: reqwest::Client, + /// Maximum number of concurrent jobs being processed. + max_concurrent_jobs: usize, + /// The retry policy used to calculate retry intervals when a job fails with a retryable error. + retry_policy: RetryPolicy, + /// The liveness check handle, to call on a schedule to report healthy + liveness: HealthHandle, +} + +pub fn build_http_client( + request_timeout: time::Duration, + allow_internal_ips: bool, +) -> reqwest::Result { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + let mut client_builder = reqwest::Client::builder() + .default_headers(headers) + .user_agent("PostHog Webhook Worker") + .timeout(request_timeout); + if !allow_internal_ips { + client_builder = client_builder.dns_resolver(Arc::new(PublicIPv4Resolver {})) + } + client_builder.build() +} + +impl<'p> WebhookWorker<'p> { + #[allow(clippy::too_many_arguments)] + pub fn new( + name: &str, + queue: &'p PgQueue, + dequeue_batch_size: u32, + poll_interval: time::Duration, + request_timeout: time::Duration, + max_concurrent_jobs: usize, + retry_policy: RetryPolicy, + allow_internal_ips: bool, + liveness: HealthHandle, + ) -> Self { + let client = build_http_client(request_timeout, allow_internal_ips) + .expect("failed to construct reqwest client for webhook worker"); + + Self { + name: name.to_owned(), + queue, + dequeue_batch_size, + poll_interval, + client, + max_concurrent_jobs, + retry_policy, + liveness, + } + } + + /// Wait until at least one job becomes available in our queue in transactional mode. + async fn wait_for_jobs_tx<'a>( + &self, + ) -> PgTransactionBatch<'a, WebhookJobParameters, WebhookJobMetadata> { + let mut interval = tokio::time::interval(self.poll_interval); + + loop { + interval.tick().await; + self.liveness.report_healthy().await; + + match self + .queue + .dequeue_tx(&self.name, self.dequeue_batch_size) + .await + { + Ok(Some(batch)) => return batch, + Ok(None) => continue, + Err(error) => { + error!("error while trying to dequeue_tx job: {}", error); + continue; + } + } + } + } + + /// Run this worker to continuously process any jobs that become available. + pub async fn run(&self) { + let semaphore = Arc::new(sync::Semaphore::new(self.max_concurrent_jobs)); + let report_semaphore_utilization = || { + metrics::gauge!("webhook_worker_saturation_percent") + .set(1f64 - semaphore.available_permits() as f64 / self.max_concurrent_jobs as f64); + }; + + let dequeue_batch_size_histogram = metrics::histogram!("webhook_dequeue_batch_size"); + + loop { + report_semaphore_utilization(); + // TODO: We could grab semaphore permits here using something like: + // `min(semaphore.available_permits(), dequeue_batch_size)` + // And then dequeue only up to that many jobs. We'd then need to hand back the + // difference in permits based on how many jobs were dequeued. + let mut batch = self.wait_for_jobs_tx().await; + dequeue_batch_size_histogram.record(batch.jobs.len() as f64); + + // Get enough permits for the jobs before spawning a task. + let permits = semaphore + .clone() + .acquire_many_owned(batch.jobs.len() as u32) + .await + .expect("semaphore has been closed"); + + let client = self.client.clone(); + let retry_policy = self.retry_policy.clone(); + + tokio::spawn(async move { + let mut futures = Vec::new(); + + // We have to `take` the Vec of jobs from the batch to avoid a borrow checker + // error below when we commit. + for job in std::mem::take(&mut batch.jobs) { + let client = client.clone(); + let retry_policy = retry_policy.clone(); + + let future = + async move { process_webhook_job(client, job, &retry_policy).await }; + + futures.push(future); + } + + let results = join_all(futures).await; + for result in results { + if let Err(e) = result { + error!("error processing webhook job: {}", e); + } + } + + let _ = batch.commit().await.map_err(|e| { + error!("error committing transactional batch: {}", e); + }); + + drop(permits); + }); + } + } +} + +/// Process a webhook job by transitioning it to its appropriate state after its request is sent. +/// After we finish, the webhook job will be set as completed (if the request was successful), retryable (if the request +/// was unsuccessful but we can still attempt a retry), or failed (if the request was unsuccessful and no more retries +/// may be attempted). +/// +/// A webhook job is considered retryable after a failing request if: +/// 1. The job has attempts remaining (i.e. hasn't reached `max_attempts`), and... +/// 2. The status code indicates retrying at a later point could resolve the issue. This means: 429 and any 5XX. +/// +/// # Arguments +/// +/// * `client`: An HTTP client to execute the webhook job request. +/// * `webhook_job`: The webhook job to process as dequeued from `hook_common::pgqueue::PgQueue`. +/// * `retry_policy`: The retry policy used to set retry parameters if a job fails and has remaining attempts. +async fn process_webhook_job( + client: reqwest::Client, + webhook_job: W, + retry_policy: &RetryPolicy, +) -> Result<(), WorkerError> { + let parameters = webhook_job.parameters(); + + let labels = [("queue", webhook_job.queue())]; + metrics::counter!("webhook_jobs_total", &labels).increment(1); + + let now = tokio::time::Instant::now(); + + let send_result = send_webhook( + client, + ¶meters.method, + ¶meters.url, + ¶meters.headers, + parameters.body.clone(), + ) + .await; + + let elapsed = now.elapsed().as_secs_f64(); + + match send_result { + Ok(_) => { + let created_at = webhook_job.job().created_at; + let retries = webhook_job.job().attempt - 1; + let labels_with_retries = [ + ("queue", webhook_job.queue()), + ("retries", retries.to_string()), + ]; + + webhook_job.complete().await.map_err(|error| { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + error + })?; + + let insert_to_complete_duration = Utc::now() - created_at; + metrics::histogram!( + "webhook_jobs_insert_to_complete_duration_seconds", + &labels_with_retries + ) + .record((insert_to_complete_duration.num_milliseconds() as f64) / 1_000_f64); + metrics::counter!("webhook_jobs_completed", &labels).increment(1); + metrics::histogram!("webhook_jobs_processing_duration_seconds", &labels) + .record(elapsed); + + Ok(()) + } + Err(WebhookError::Parse(WebhookParseError::ParseHeadersError(e))) => { + webhook_job + .fail(WebhookJobError::new_parse(&e.to_string())) + .await + .map_err(|job_error| { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + job_error + })?; + + metrics::counter!("webhook_jobs_failed", &labels).increment(1); + + Ok(()) + } + Err(WebhookError::Parse(WebhookParseError::ParseHttpMethodError(e))) => { + webhook_job + .fail(WebhookJobError::new_parse(&e)) + .await + .map_err(|job_error| { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + job_error + })?; + + metrics::counter!("webhook_jobs_failed", &labels).increment(1); + + Ok(()) + } + Err(WebhookError::Parse(WebhookParseError::ParseUrlError(e))) => { + webhook_job + .fail(WebhookJobError::new_parse(&e.to_string())) + .await + .map_err(|job_error| { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + job_error + })?; + + metrics::counter!("webhook_jobs_failed", &labels).increment(1); + + Ok(()) + } + Err(WebhookError::Request(request_error)) => { + let webhook_job_error = WebhookJobError::from(&request_error); + + match request_error { + WebhookRequestError::RetryableRequestError { + error, retry_after, .. + } => { + let retry_interval = + retry_policy.retry_interval(webhook_job.attempt() as u32, retry_after); + let current_queue = webhook_job.queue(); + let retry_queue = retry_policy.retry_queue(¤t_queue); + + match webhook_job + .retry(webhook_job_error, retry_interval, retry_queue) + .await + { + Ok(_) => { + metrics::counter!("webhook_jobs_retried", &labels).increment(1); + + Ok(()) + } + Err(RetryError::RetryInvalidError(RetryInvalidError { + job: webhook_job, + .. + })) => { + webhook_job + .fail(WebhookJobError::from(&error)) + .await + .map_err(|job_error| { + metrics::counter!("webhook_jobs_database_error", &labels) + .increment(1); + job_error + })?; + + metrics::counter!("webhook_jobs_failed", &labels).increment(1); + + Ok(()) + } + Err(RetryError::DatabaseError(job_error)) => { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + Err(WorkerError::from(job_error)) + } + } + } + WebhookRequestError::NonRetryableRetryableRequestError { .. } => { + webhook_job + .fail(webhook_job_error) + .await + .map_err(|job_error| { + metrics::counter!("webhook_jobs_database_error", &labels).increment(1); + job_error + })?; + + metrics::counter!("webhook_jobs_failed", &labels).increment(1); + + Ok(()) + } + } + } + } +} + +/// Make an HTTP request to a webhook endpoint. +/// +/// # Arguments +/// +/// * `client`: An HTTP client to execute the HTTP request. +/// * `method`: The HTTP method to use in the HTTP request. +/// * `url`: The URL we are targetting with our request. Parsing this URL fail. +/// * `headers`: Key, value pairs of HTTP headers in a `std::collections::HashMap`. Can fail if headers are not valid. +/// * `body`: The body of the request. Ownership is required. +async fn send_webhook( + client: reqwest::Client, + method: &HttpMethod, + url: &str, + headers: &collections::HashMap, + body: String, +) -> Result { + let method: http::Method = method.into(); + let url: reqwest::Url = (url).parse().map_err(WebhookParseError::ParseUrlError)?; + let headers: reqwest::header::HeaderMap = (headers) + .try_into() + .map_err(WebhookParseError::ParseHeadersError)?; + let body = reqwest::Body::from(body); + + let response = client + .request(method, url) + .headers(headers) + .body(body) + .send() + .await + .map_err(|e| { + if is_error_source::(&e) { + WebhookRequestError::NonRetryableRetryableRequestError { + error: e, + response: None, + } + } else { + WebhookRequestError::RetryableRequestError { + error: e, + response: None, + retry_after: None, + } + } + })?; + + let retry_after = parse_retry_after_header(response.headers()); + + match response.error_for_status_ref() { + Ok(_) => Ok(response), + Err(err) => { + if is_retryable_status( + err.status() + .expect("status code is set as error is generated from a response"), + ) { + Err(WebhookError::Request( + WebhookRequestError::RetryableRequestError { + error: err, + // TODO: Make amount of bytes configurable. + response: first_n_bytes_of_response(response, 10 * 1024).await.ok(), + retry_after, + }, + )) + } else { + Err(WebhookError::Request( + WebhookRequestError::NonRetryableRetryableRequestError { + error: err, + response: first_n_bytes_of_response(response, 10 * 1024).await.ok(), + }, + )) + } + } + } +} + +fn is_retryable_status(status: StatusCode) -> bool { + status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + +/// Attempt to parse a chrono::Duration from a Retry-After header, returning None if not possible. +/// Retry-After header can specify a date in RFC2822 or a number of seconds; we try to parse both. +/// If a Retry-After header is not present in the provided `header_map`, `None` is returned. +/// +/// # Arguments +/// +/// * `header_map`: A `&reqwest::HeaderMap` of response headers that could contain Retry-After. +fn parse_retry_after_header(header_map: &reqwest::header::HeaderMap) -> Option { + let retry_after_header = header_map.get(reqwest::header::RETRY_AFTER); + + let retry_after = match retry_after_header { + Some(header_value) => match header_value.to_str() { + Ok(s) => s, + Err(_) => { + return None; + } + }, + None => { + return None; + } + }; + + if let Ok(u) = retry_after.parse::() { + let duration = time::Duration::from_secs(u); + return Some(duration); + } + + if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(retry_after) { + let duration = + chrono::DateTime::::from(dt) - chrono::offset::Utc::now(); + + // This can only fail when negative, in which case we return None. + return duration.to_std().ok(); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + // Note we are ignoring some warnings in this module. + // This is due to a long-standing cargo bug that reports imports and helper functions as unused. + // See: https://github.com/rust-lang/rust/issues/46379. + use health::HealthRegistry; + use hook_common::pgqueue::{DatabaseError, NewJob}; + use sqlx::PgPool; + + /// Use process id as a worker id for tests. + fn worker_id() -> String { + std::process::id().to_string() + } + + /// Get a request client or panic + fn localhost_client() -> Client { + build_http_client(Duration::from_secs(1), true).expect("failed to create client") + } + + async fn enqueue_job( + queue: &PgQueue, + max_attempts: i32, + job_parameters: WebhookJobParameters, + job_metadata: WebhookJobMetadata, + ) -> Result<(), DatabaseError> { + let job_target = job_parameters.url.to_owned(); + let new_job = NewJob::new(max_attempts, job_metadata, job_parameters, &job_target); + queue.enqueue(new_job).await?; + Ok(()) + } + + #[test] + fn test_is_retryable_status() { + assert!(!is_retryable_status(http::StatusCode::FORBIDDEN)); + assert!(!is_retryable_status(http::StatusCode::BAD_REQUEST)); + assert!(is_retryable_status(http::StatusCode::TOO_MANY_REQUESTS)); + assert!(is_retryable_status(http::StatusCode::INTERNAL_SERVER_ERROR)); + } + + #[test] + fn test_parse_retry_after_header() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "120".parse().unwrap()); + + let duration = parse_retry_after_header(&headers).unwrap(); + assert_eq!(duration, time::Duration::from_secs(120)); + + headers.remove(reqwest::header::RETRY_AFTER); + + let duration = parse_retry_after_header(&headers); + assert_eq!(duration, None); + + headers.insert( + reqwest::header::RETRY_AFTER, + "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(), + ); + + let duration = parse_retry_after_header(&headers); + assert_eq!(duration, None); + } + + #[sqlx::test(migrations = "../migrations")] + async fn test_wait_for_job(db: PgPool) { + let worker_id = worker_id(); + let queue_name = "test_wait_for_job".to_string(); + let queue = PgQueue::new_from_pool(&queue_name, db).await; + + let webhook_job_parameters = WebhookJobParameters { + body: "a webhook job body. much wow.".to_owned(), + headers: collections::HashMap::new(), + method: HttpMethod::POST, + url: "localhost".to_owned(), + }; + let webhook_job_metadata = WebhookJobMetadata { + team_id: 1, + plugin_id: 2, + plugin_config_id: 3, + }; + let registry = HealthRegistry::new("liveness"); + let liveness = registry + .register("worker".to_string(), ::time::Duration::seconds(30)) + .await; + // enqueue takes ownership of the job enqueued to avoid bugs that can cause duplicate jobs. + // Normally, a separate application would be enqueueing jobs for us to consume, so no ownership + // conflicts would arise. However, in this test we need to do the enqueueing ourselves. + // So, we clone the job to keep it around and assert the values returned by wait_for_job. + enqueue_job( + &queue, + 1, + webhook_job_parameters.clone(), + webhook_job_metadata, + ) + .await + .expect("failed to enqueue job"); + let worker = WebhookWorker::new( + &worker_id, + &queue, + 1, + time::Duration::from_millis(100), + time::Duration::from_millis(5000), + 10, + RetryPolicy::default(), + false, + liveness, + ); + + let mut batch = worker.wait_for_jobs_tx().await; + let consumed_job = batch.jobs.pop().unwrap(); + + assert_eq!(consumed_job.job.attempt, 1); + assert!(consumed_job.job.attempted_by.contains(&worker_id)); + assert_eq!(consumed_job.job.attempted_by.len(), 1); + assert_eq!(consumed_job.job.max_attempts, 1); + assert_eq!( + *consumed_job.job.parameters.as_ref(), + webhook_job_parameters + ); + assert_eq!(consumed_job.job.target, webhook_job_parameters.url); + + consumed_job + .complete() + .await + .expect("job not successfully completed"); + batch.commit().await.expect("failed to commit batch"); + + assert!(registry.get_status().healthy) + } + + #[tokio::test] + async fn test_send_webhook() { + let method = HttpMethod::POST; + let url = "http://localhost:18081/echo"; + let headers = collections::HashMap::new(); + let body = "a very relevant request body"; + + let response = send_webhook(localhost_client(), &method, url, &headers, body.to_owned()) + .await + .expect("send_webhook failed"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.text().await.expect("failed to read response body"), + body.to_owned(), + ); + } + + #[tokio::test] + async fn test_error_message_contains_response_body() { + let method = HttpMethod::POST; + let url = "http://localhost:18081/fail"; + let headers = collections::HashMap::new(); + let body = "this is an error message"; + + let err = send_webhook(localhost_client(), &method, url, &headers, body.to_owned()) + .await + .err() + .expect("request didn't fail when it should have failed"); + + assert!(matches!(err, WebhookError::Request(..))); + if let WebhookError::Request(request_error) = err { + assert_eq!(request_error.status(), Some(StatusCode::BAD_REQUEST)); + assert!(request_error.to_string().contains(body)); + // This is the display implementation of reqwest. Just checking it is still there. + // See: https://github.com/seanmonstar/reqwest/blob/master/src/error.rs + assert!(request_error.to_string().contains( + "HTTP status client error (400 Bad Request) for url (http://localhost:18081/fail)" + )); + } + } + + #[tokio::test] + async fn test_error_message_contains_up_to_n_bytes_of_response_body() { + let method = HttpMethod::POST; + let url = "http://localhost:18081/fail"; + let headers = collections::HashMap::new(); + // This is double the current hardcoded amount of bytes. + // TODO: Make this configurable and change it here too. + let body = (0..20 * 1024).map(|_| "a").collect::>().concat(); + + let err = send_webhook(localhost_client(), &method, url, &headers, body.to_owned()) + .await + .err() + .expect("request didn't fail when it should have failed"); + + assert!(matches!(err, WebhookError::Request(..))); + if let WebhookError::Request(request_error) = err { + assert_eq!(request_error.status(), Some(StatusCode::BAD_REQUEST)); + assert!(request_error.to_string().contains(&body[0..10 * 1024])); + // The 81 bytes account for the reqwest erorr message as described below. + assert_eq!(request_error.to_string().len(), 10 * 1024 + 81); + // This is the display implementation of reqwest. Just checking it is still there. + // See: https://github.com/seanmonstar/reqwest/blob/master/src/error.rs + assert!(request_error.to_string().contains( + "HTTP status client error (400 Bad Request) for url (http://localhost:18081/fail)" + )); + } + } + + #[tokio::test] + async fn test_private_ips_denied() { + let method = HttpMethod::POST; + let url = "http://localhost:18081/echo"; + let headers = collections::HashMap::new(); + let body = "a very relevant request body"; + let filtering_client = + build_http_client(Duration::from_secs(1), false).expect("failed to create client"); + + let err = send_webhook(filtering_client, &method, url, &headers, body.to_owned()) + .await + .err() + .expect("request didn't fail when it should have failed"); + + assert!(matches!(err, WebhookError::Request(..))); + if let WebhookError::Request(request_error) = err { + assert_eq!(request_error.status(), None); + assert!(request_error + .to_string() + .contains("No public IPv4 found for specified host")); + if let WebhookRequestError::RetryableRequestError { .. } = request_error { + panic!("error should not be retryable") + } + } else { + panic!("unexpected error type {:?}", err) + } + } +} diff --git a/rust/migrations/20231129172339_job_queue_table.sql b/rust/migrations/20231129172339_job_queue_table.sql new file mode 100644 index 0000000000000..bf8c3df0a3706 --- /dev/null +++ b/rust/migrations/20231129172339_job_queue_table.sql @@ -0,0 +1,29 @@ +CREATE TYPE job_status AS ENUM( + 'available', + 'completed', + 'failed', + 'running' +); + +CREATE TABLE job_queue( + id BIGSERIAL PRIMARY KEY, + attempt INT NOT NULL DEFAULT 0, + attempted_at TIMESTAMPTZ DEFAULT NULL, + attempted_by TEXT [] DEFAULT ARRAY [] :: TEXT [], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + errors JSONB [], + max_attempts INT NOT NULL DEFAULT 1, + metadata JSONB, + last_attempt_finished_at TIMESTAMPTZ DEFAULT NULL, + parameters JSONB, + queue TEXT NOT NULL DEFAULT 'default' :: text, + scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status job_status NOT NULL DEFAULT 'available' :: job_status, + target TEXT NOT NULL +); + +-- Needed for `dequeue` queries +CREATE INDEX idx_queue_scheduled_at ON job_queue(queue, status, scheduled_at, attempt); + +-- Needed for UPDATE-ing incomplete jobs with a specific target (i.e. slow destinations) +CREATE INDEX idx_queue_target ON job_queue(queue, status, target); diff --git a/rust/migrations/20240202003133_better_dequeue_index.sql b/rust/migrations/20240202003133_better_dequeue_index.sql new file mode 100644 index 0000000000000..a619fb1ac8c9d --- /dev/null +++ b/rust/migrations/20240202003133_better_dequeue_index.sql @@ -0,0 +1,10 @@ +-- Dequeue is not hitting this index, so dropping is safe this time. +DROP INDEX idx_queue_scheduled_at; + +/* +Partial index used for dequeuing from job_queue. + +Dequeue only looks at available jobs so a partial index serves us well. +Moreover, dequeue sorts jobs by attempt and scheduled_at, which matches this index. +*/ +CREATE INDEX idx_queue_dequeue_partial ON job_queue(queue, attempt, scheduled_at) WHERE status = 'available' :: job_status;