Repository: azat/chdig Branch: main Commit: 7394b22c63a3 Files: 89 Total size: 747.5 KB Directory structure: gitextract_a1a8yrqt/ ├── .cargo/ │ ├── audit.toml │ └── config.toml ├── .exrc ├── .github/ │ └── workflows/ │ ├── build.yml │ ├── pre_release.yml │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .yamllint ├── Cargo.toml ├── Documentation/ │ ├── Actions.md │ ├── Bugs.md │ ├── Developers.md │ └── FAQ.md ├── LICENSE ├── Makefile ├── README.md ├── chdig-nfpm.yaml ├── rustfmt.toml ├── src/ │ ├── actions.rs │ ├── bin.rs │ ├── common/ │ │ ├── mod.rs │ │ ├── relative_date_time.rs │ │ ├── sparkline.rs │ │ └── stopwatch.rs │ ├── interpreter/ │ │ ├── background_runner.rs │ │ ├── clickhouse.rs │ │ ├── clickhouse_quirks.rs │ │ ├── context.rs │ │ ├── debug_metrics.rs │ │ ├── flamegraph.rs │ │ ├── mod.rs │ │ ├── options.rs │ │ ├── perfetto.rs │ │ ├── query.rs │ │ └── worker.rs │ ├── lib.rs │ ├── main.rs │ ├── pastila.rs │ ├── utils.rs │ └── view/ │ ├── log_view.rs │ ├── mod.rs │ ├── navigation.rs │ ├── provider.rs │ ├── providers/ │ │ ├── asynchronous_inserts.rs │ │ ├── background_schedule_pool.rs │ │ ├── background_schedule_pool_log.rs │ │ ├── backups.rs │ │ ├── client.rs │ │ ├── dictionaries.rs │ │ ├── errors.rs │ │ ├── logger_names.rs │ │ ├── merges.rs │ │ ├── mod.rs │ │ ├── mutations.rs │ │ ├── object_storage_queue.rs │ │ ├── part_log.rs │ │ ├── queries.rs │ │ ├── replicas.rs │ │ ├── replicated_fetches.rs │ │ ├── replication_queue.rs │ │ ├── server_logs.rs │ │ ├── table_parts.rs │ │ └── tables.rs │ ├── queries_view.rs │ ├── query_view.rs │ ├── registry.rs │ ├── search_history.rs │ ├── settings_view.rs │ ├── sql_query_view.rs │ ├── summary_view.rs │ ├── table_view.rs │ ├── text_log_view.rs │ └── utils.rs ├── tests/ │ └── configs/ │ ├── accept_invalid_certificate.yaml │ ├── basic.xml │ ├── basic.yaml │ ├── chdig_basic.yaml │ ├── chdig_empty.yaml │ ├── chdig_partial.yaml │ ├── connections.yaml │ ├── empty.xml │ ├── empty.yaml │ ├── tls.xml │ ├── tls.yaml │ ├── unknown_directives.xml │ └── unknown_directives.yaml └── typos.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/audit.toml ================================================ # https://docs.rs/crate/cargo-audit/0.10.0/source/audit.toml.example [advisories] ignore = [ # time: Potential segfault in the time crate # chdig should not be affected by this, waiting for upstream. "RUSTSEC-2020-0071", # ansi_term is Unmaintained "RUSTSEC-2021-0139", # term_size is Unmaintained "RUSTSEC-2020-0163", # stdweb is unmaintained "RUSTSEC-2020-0056", # Waiting for upstream # owning_ref: Multiple soundness issues in `owning_ref` "RUSTSEC-2022-0040", # nix: Out-of-bounds write in nix::unistd::getgrouplist "RUSTSEC-2021-0119", # rustc-serialize: Stack overflow in rustc_serialize when parsing deeply nested JSON "RUSTSEC-2022-0004", # atty: Potential unaligned read "RUSTSEC-2021-0145", ] ================================================ FILE: .cargo/config.toml ================================================ [build] rustflags = ["--cfg", "tokio_unstable"] ================================================ FILE: .exrc ================================================ " " Add this into your .vimrc, to allow vim handle this file. " " set exrc " set secure " even after this this is kind of dangerous " set tabstop=4 set softtabstop=4 set shiftwidth=4 set expandtab let detectindent_preferred_indent=4 let g:detectindent_preferred_expandtab=1 ================================================ FILE: .github/workflows/build.yml ================================================ --- name: Build chdig on: workflow_call: inputs: {} env: CARGO_TERM_COLOR: always jobs: lint: name: Run linters runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: cargo check run: cargo check - name: cargo clippy run: cargo clippy build-linux: name: Build Linux (x86_64) runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 with: # To fetch tags, but can this be improved using blobless checkout? # [1]. But anyway right it is not important, and unlikely will be, # since the repository is small. # # [1]: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 persist-credentials: false # Workaround for https://github.com/actions/checkout/issues/882 - name: Fix tags for release # will break on a lightweight tag run: git fetch origin +refs/tags/*:refs/tags/* - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Install dependencies run: | # nfpm curl -sS -Lo /tmp/nfpm.deb "https://github.com/goreleaser/nfpm/releases/download/v2.43.4/nfpm_2.43.4_amd64.deb" sudo dpkg -i /tmp/nfpm.deb # for building cityhash for clickhouse-rs sudo apt-get install -y musl-tools # gcc cannot do cross compile, and there is no musl-g++ in musl-tools sudo ln -srf /usr/bin/clang /usr/bin/musl-g++ # musl for static binaries rustup target add x86_64-unknown-linux-musl - name: Run tests run: make test - name: Build run: | set -x make packages target=x86_64-unknown-linux-musl ls -l declare -A mapping mapping[chdig*.x86_64.rpm]=chdig-latest.x86_64.rpm mapping[chdig*-x86_64.pkg.tar.zst]=chdig-latest-x86_64.pkg.tar.zst mapping[chdig*-x86_64.tar.gz]=chdig-latest-x86_64.tar.gz mapping[chdig*_amd64.deb]=chdig-latest_amd64.deb mapping[target/chdig]=chdig-amd64 for pattern in "${!mapping[@]}"; do cp $pattern ${mapping[$pattern]} done - name: Check package run: | sudo dpkg -i chdig-latest_amd64.deb chdig --help - name: Archive Packages uses: actions/upload-artifact@v4 with: name: linux-packages-amd64 path: | chdig-amd64 *.deb *.rpm *.tar.* build-linux-no-features: name: Build Linux (no features) runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 with: persist-credentials: false - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Run tests run: make test - name: Build run: | cargo build --no-default-features - name: Check package run: | cargo run --no-default-features -- --help build-macos-x86_64: name: Build MacOS (x86_64) runs-on: macos-15-intel steps: - uses: actions/checkout@v3 with: # To fetch tags, but can this be improved using blobless checkout? # [1]. But anyway right it is not important, and unlikely will be, # since the repository is small. # # [1]: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 persist-credentials: false # Workaround for https://github.com/actions/checkout/issues/882 - name: Fix tags for release # will break on a lightweight tag run: git fetch origin +refs/tags/*:refs/tags/* - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Worker info run: | # SDKs versions ls -al /Library/Developer/CommandLineTools/SDKs/ - name: Build run: | set -x make deploy-binary cp target/chdig chdig-macos-x86_64 - name: Check package run: | ./chdig-macos-x86_64 --help - name: Archive Packages uses: actions/upload-artifact@v4 with: name: macos-packages-x86_64 path: | chdig-macos-x86_64 build-macos-arm64: name: Build MacOS (arm64) runs-on: macos-26 steps: - uses: actions/checkout@v3 with: # To fetch tags, but can this be improved using blobless checkout? # [1]. But anyway right it is not important, and unlikely will be, # since the repository is small. # # [1]: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 persist-credentials: false # Workaround for https://github.com/actions/checkout/issues/882 - name: Fix tags for release # will break on a lightweight tag run: git fetch origin +refs/tags/*:refs/tags/* - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Worker info run: | # SDKs versions ls -al /Library/Developer/CommandLineTools/SDKs/ - name: Build run: | set -x make deploy-binary cp target/chdig chdig-macos-arm64 - name: Check package run: | ./chdig-macos-arm64 --help - name: Archive Packages uses: actions/upload-artifact@v4 with: name: macos-packages-arm64 path: | chdig-macos-arm64 build-windows: name: Build Windows runs-on: windows-latest steps: - uses: actions/checkout@v3 with: # To fetch tags, but can this be improved using blobless checkout? # [1]. But anyway right it is not important, and unlikely will be, # since the repository is small. # # [1]: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 persist-credentials: false # Workaround for https://github.com/actions/checkout/issues/882 - name: Fix tags for release # will break on a lightweight tag run: git fetch origin +refs/tags/*:refs/tags/* - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Build run: | make deploy-binary cp target/chdig.exe chdig-windows-x86_64.exe - name: Archive Packages uses: actions/upload-artifact@v4 with: name: windows-packages-x86_64 path: | chdig-windows-x86_64.exe build-linux-aarch64: name: Build Linux (aarch64) runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v3 with: # To fetch tags, but can this be improved using blobless checkout? # [1]. But anyway right it is not important, and unlikely will be, # since the repository is small. # # [1]: https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 persist-credentials: false # Workaround for https://github.com/actions/checkout/issues/882 - name: Fix tags for release # will break on a lightweight tag run: git fetch origin +refs/tags/*:refs/tags/* - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true - name: Install dependencies run: | # nfpm curl -sS -Lo /tmp/nfpm.deb "https://github.com/goreleaser/nfpm/releases/download/v2.43.4/nfpm_2.43.4_arm64.deb" sudo dpkg -i /tmp/nfpm.deb # for building cityhash for clickhouse-rs sudo apt-get install -y musl-tools # gcc cannot do cross compile, and there is no musl-g++ in musl-tools sudo ln -srf /usr/bin/clang /usr/bin/musl-g++ # "Compiler family detection failed due to error: ToolNotFound: failed to find tool "aarch64-linux-musl-g++": No such file or directory" sudo ln -srf /usr/bin/clang /usr/bin/aarch64-linux-musl-g++ # musl for static binaries rustup target add aarch64-unknown-linux-musl - name: Run tests run: make test - name: Build run: | set -x make packages target=aarch64-unknown-linux-musl ls -l declare -A mapping mapping[chdig*.aarch64.rpm]=chdig-latest.aarch64.rpm mapping[chdig*-aarch64.pkg.tar.zst]=chdig-latest-aarch64.pkg.tar.zst mapping[chdig*-aarch64.tar.gz]=chdig-latest-aarch64.tar.gz mapping[chdig*_arm64.deb]=chdig-latest_arm64.deb mapping[target/chdig]=chdig-aarch64 for pattern in "${!mapping[@]}"; do cp $pattern ${mapping[$pattern]} done - name: Check package run: | sudo dpkg -i chdig-latest_arm64.deb chdig --help - name: Archive Packages uses: actions/upload-artifact@v4 with: name: linux-packages-aarch64 path: | chdig-aarch64 *.deb *.rpm *.tar.* ================================================ FILE: .github/workflows/pre_release.yml ================================================ --- name: pre-release on: push: branches: - main jobs: build: uses: ./.github/workflows/build.yml publish-pre-release: name: Publish Pre Release runs-on: ubuntu-22.04 permissions: contents: write needs: - build steps: - name: Download artifacts uses: actions/download-artifact@v4 - uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: true automatic_release_tag: "latest" title: "Development Build" files: | macos-packages-x86_64/* macos-packages-arm64/* windows-packages-x86_64/* linux-packages-amd64/* linux-packages-aarch64/* ================================================ FILE: .github/workflows/pull_request.yml ================================================ --- name: pull_request on: pull_request: types: - synchronize - reopened - opened branches: - main paths-ignore: - '**.md' - 'Documentation/**' jobs: spellcheck: name: Spell Check with Typos runs-on: ubuntu-latest steps: - name: Checkout Actions Repository uses: actions/checkout@v4 - name: Spell Check Repo uses: crate-ci/typos@v1.31.1 with: config: typos.toml build: needs: spellcheck uses: ./.github/workflows/build.yml ================================================ FILE: .github/workflows/release.yml ================================================ --- name: release on: push: tags: - "v*" jobs: build: uses: ./.github/workflows/build.yml publish-release: name: Publish Release runs-on: ubuntu-22.04 permissions: contents: write needs: - build steps: - name: Download artifacts uses: actions/download-artifact@v4 - uses: "marvinpinto/action-automatic-releases@latest" with: repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: false files: | macos-packages-x86_64/* macos-packages-arm64/* windows-packages-x86_64/* linux-packages-amd64/* linux-packages-aarch64/* - name: Generate PKGBUILD run: | set -x VERSION="${GITHUB_REF##*/}" VERSION="${VERSION#v}" SHA256_x86_64=$(sha256sum linux-packages-amd64/chdig-$VERSION-1-x86_64.pkg.tar.zst | cut -d' ' -f1) SHA256_aarch64=$(sha256sum linux-packages-aarch64/chdig-$VERSION-1-aarch64.pkg.tar.zst | cut -d' ' -f1) cat > PKGBUILD < pkgname=chdig-bin pkgver=$VERSION pkgrel=1 pkgdesc="Dig into ClickHouse with TUI interface (binaries for latest stable version)" arch=('x86_64' 'aarch64') conflicts=("chdig") provides=("chdig") url="https://github.com/azat/chdig" license=('MIT') source_x86_64=("https://github.com/azat/chdig/releases/download/v\$pkgver/chdig-\$pkgver-1-x86_64.pkg.tar.zst") source_aarch64=("https://github.com/azat/chdig/releases/download/v\$pkgver/chdig-\$pkgver-1-aarch64.pkg.tar.zst") sha256sums_x86_64=('$SHA256_x86_64') sha256sums_aarch64=('$SHA256_aarch64') package() { tar -C "\$pkgdir" -xvf chdig-\$pkgver-1-\$(uname -m).pkg.tar.zst rm -f "\$pkgdir/.PKGINFO" rm -f "\$pkgdir/.MTREE" } # vim set: ts=4 sw=4 et EOL cat PKGBUILD - name: Publish to the AUR uses: KSXGitHub/github-actions-deploy-aur@v4.1.3 if: ${{ github.event.repository.fork == false }} with: pkgname: chdig-bin pkgbuild: PKGBUILD commit_username: Azat Khuzhin commit_email: a3at.mail@gmail.com ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} commit_message: Release ${{ github.ref_name }} # force_push: 'true' ================================================ FILE: .gitignore ================================================ # cargo target /vendor # distribution dist # packages *.deb *.tar.* *.tar *.rpm # intellij .idea/ ================================================ FILE: .pre-commit-config.yaml ================================================ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: check-byte-order-marker - id: check-yaml - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/pre-commit/pre-commit rev: v3.6.0 hooks: - id: validate_manifest - repo: https://github.com/doublify/pre-commit-rust rev: v1.0 hooks: - id: fmt pass_filenames: false - id: cargo-check - id: clippy - repo: https://github.com/adrienverge/yamllint.git rev: v1.35.1 hooks: - id: yamllint ================================================ FILE: .yamllint ================================================ # vi: ft=yaml --- extends: default rules: indentation: spaces: 2 level: error indent-sequences: false line-length: max: 250 braces: max-spaces-inside: 1 truthy: allowed-values: ['true', 'false', 'yes', 'no'] check-keys: true comments: # this is useful to distinguish commented code from comments require-starting-space: false ================================================ FILE: Cargo.toml ================================================ [package] name = "chdig" authors = ["Azat Khuzhin "] homepage = "https://github.com/azat/chdig" repository = "https://github.com/azat/chdig" readme = "README.md" description = "Dig into ClickHouse with TUI interface" license = "MIT" version = "26.4.3" edition = "2024" [lib] name = "chdig" crate-type = ["staticlib", "lib"] path = "src/lib.rs" [[bin]] name = "chdig" path = "src/main.rs" [features] default = ["tls"] tls = ["clickhouse-rs/tls-rustls"] tokio-console = ["dep:console-subscriber", "tokio/tracing"] [patch.crates-io] cursive = { git = "https://github.com/azat-rust/cursive", branch = "chdig-next" } cursive_core = { git = "https://github.com/azat-rust/cursive", branch = "chdig-next" } [dependencies] # Basic anyhow = { version = "*", default-features = false, features = ["std"] } libc = { version = "*", default-features = false } size = { version = "*", default-features = false, features = ["std"] } tempfile = { version = "*", default-features = false } url = { version = "*", default-features = false } humantime = { version = "*", default-features = false } backtrace = { version = "*", default-features = false, features = ["std"] } futures = { version = "*", default-features = false, features = ["std"] } strfmt = { version = "*", default-features = false } fuzzy-matcher = { version = "*", default-features = false } # chrono/chrono-tz should match clickhouse-rs chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } chrono-tz = { version = "0.8", default-features = false } flexi_logger = { version = "0.27", default-features = false } log = { version = "0.4", default-features = false } futures-util = { version = "*", default-features = false } semver = { version = "*", default-features = false } serde = { version = "*", features = ["derive"] } serde_json = { version = "*", default-features = false, features = ["std"] } serde_yaml = { version = "*", default-features = false } quick-xml = { version = "*", features = ["serialize"] } percent-encoding = { version = "*", default-features = false } regex = { version = "*", default-features = false, features = ["std"] } # CLI clap = { version = "*", default-features = false, features = ["derive", "env", "help", "usage", "std", "color", "error-context", "suggestions"] } clap_complete = { version = "*", default-features = false } # UI cursive = { version = "*", default-features = false, features = ["crossterm-backend"] } cursive-syntect = { version = "*", default-features = true } unicode-width = "0.1" cursive-flexi-logger-view = { git = "https://github.com/azat-rust/cursive-flexi-logger-view", branch = "next", default-features = false } syntect = { version = "*", default-features = false, features = ["default-syntaxes", "default-themes"] } arboard = { version = "*", default-features = false } clickhouse-rs = { git = "https://github.com/azat-rust/clickhouse-rs", branch = "next", default-features = false, features = ["tokio_io"] } tokio = { version = "*", default-features = false, features = ["macros"] } console-subscriber = { version = "*", default-features = false, optional = true } # Flamegraphs flamelens = { git = "https://github.com/azat-rust/flamelens", branch = "diff-mode", default-features = false } ratatui = { version = "0.29.0", features = ["unstable-rendered-line-info"] } # Should **only** with the flamelens, since cursive re-export it, while flamelens does not crossterm = { version = "0.28.1", features = ["use-dev-tty"] } # Perfetto perfetto_protos = { version = "*", default-features = false } protobuf = { version = "3", default-features = false } tiny_http = { version = "*", default-features = false } # Sharing aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"] } rand = { version = "0.8", default-features = false, features = ["std", "std_rng"] } base64 = { version = "0.22", default-features = false, features = ["std"] } [dev-dependencies] pretty_assertions = { version= "*", default-features = false, features = ["alloc"] } [profile.release] # Too slow and does not worth it lto = false [lints.clippy] needless_return = "allow" type_complexity = "allow" uninlined_format_args = "allow" [lints.rust] elided_lifetimes_in_paths = "deny" ================================================ FILE: Documentation/Actions.md ================================================ ### Actions `chdig` supports lots of actions, some has shortcut, others available only in `Ctlr-P` (fuzzy search by all actions) (also there is `F8` for query actions and `F2` for global actions, if you prefer old school). ### Shortcuts Here is a list of available shortcuts | Category | Shortcut | Description | |-----------------|---------------|-----------------------------------------------| | Global Shortcuts| **F1** | Show help | | | **F2** | Views | | | **F8** | Show actions | | | **Ctrl-p** | Fuzzy actions | | | **F** | CPU Server Flamegraph | | | | Real Server Flamegraph | | | | Memory Server Flamegraph | | | | Memory Sample Server Flamegraph | | | | Jemalloc Sample Server Flamegraph | | | | Events Server Flamegraph | | | | Live Server Flamegraph | | | | CPU Server Flamegraph in speedscope | | | | Real Server Flamegraph in speedscope | | | | Memory Server Flamegraph in speedscope | | | | Memory Sample Server Flamegraph in speedscope | | | | Jemalloc Sample Server Flamegraph in speedscope| | | | Events Server Flamegraph in speedscope | | | | Live Server Flamegraph in speedscope | | Actions | **** | Select | | | **-** | Show all queries | | | **+** | Show queries on shards | | | **/** | Filter | | | | Query details | | | | Query profile events | | | **P** | Query processors | | | **v** | Query views | | | **C** | Show CPU flamegraph | | | **R** | Show Real flamegraph | | | **M** | Show memory flamegraph | | | | Show memory sample flamegraph | | | | Show jemalloc sample flamegraph | | | | Show events flamegraph | | | **L** | Show live flamegraph | | | | Show CPU flamegraph in speedscope | | | | Show Real flamegraph in speedscope | | | | Show memory flamegraph in speedscope | | | | Show memory sample flamegraph in speedscope | | | | Show jemalloc sample flamegraph in speedscope | | | | Show events flamegraph in speedscope | | | | Show live flamegraph in speedscope | | | **Alt+E** | Edit query and execute | | | **S** | Show query | | | **y** | Copy query to clipboard | | | **s** | `EXPLAIN SYNTAX` | | | **e** | `EXPLAIN PLAN` | | | **E** | `EXPLAIN PIPELINE` | | | **G** | `EXPLAIN PIPELINE graph=1` (open in browser) | | | **I** | `EXPLAIN INDEXES` | | | **K** | `KILL` query | | | **l** | Show query logs | | | **(** | Increase number of queries to render to 20 | | | **)** | Decrease number of queries to render to 20 | | Logs | **-** | Turn ON/OFF options: | | | | - `S` - toggle wrap mode | | | **/** | Forward search | | | **?** | Reverse search | | | **s** | Save logs to file | | | **n**/**N** | Move to next/previous match | | Basic navigation| **j**/**k** | Down/Up | | | **G**/**g** | Move to the end/Move to the beginning | | | **PageDown**/**PageUp**| Move to the end/Move to the beginning| | | **Home** | Reset selection/follow item in table | | chdig controls | **Esc** | Back/Quit | | | **q** | Back/Quit | | | **Q** | Quit forcefully | | | **Backspace** | Back | | | **p** | Toggle pause | | | **r** | Refresh | | | **T** | Seek 10 mins backward | | | **t** | Seek 10 mins forward | | | **Alt+t** | Set time interval | | | **~** | chdig debug console | ================================================ FILE: Documentation/Bugs.md ================================================ ### `--history` is broken in some versions The reason is that in some ClickHouse versions merge() function ignore aliases. ================================================ FILE: Documentation/Developers.md ================================================ ## Developer Documentation ### Debugging async code with tokio-console chdig supports [tokio-console](https://github.com/tokio-rs/console) for debugging async tasks and runtime behavior. To enable tokio console support: 1. Build with the `tokio-console` feature: ```bash cargo build --features tokio-console ``` 2. Run chdig: ```bash cargo run --features tokio-console ``` 3. In a separate terminal, start tokio-console: ```bash # Install if needed cargo install tokio-console # Connect to the running application tokio-console ``` ================================================ FILE: Documentation/FAQ.md ================================================ ### What is format of the URL accepted by `chdig`? The simplest form is just - **`localhost`** For a secure connections with user and password _(note: passing the password on the command line is not safe)_, use: ```sh chdig -u 'user:password@clickhouse-host.com/?secure=true' ``` A full list of supported connection options is available [here](https://github.com/azat-rust/clickhouse-rs/?tab=readme-ov-file#dns). _Note: This link currently points to my fork, as some changes have not yet been accepted upstream._ ### Environment variables A safer way to pass the password is via environment variables: ```sh export CLICKHOUSE_USER='user' export CLICKHOUSE_PASSWORD='password' chdig -u 'clickhouse-host.com/?secure=true' # or specify the port explicitly chdig -u 'clickhouse-host.com:9440/?secure=true' ``` ### What is --config (`CLICKHOUSE_CONFIG`)? This is standard config for [ClickHouse client](https://clickhouse.com/docs/interfaces/cli#configuration_files), i.e. ```yaml user: foo password: bar host: play secure: true ``` _See also some examples and possible advanced use cases [here](/tests/configs)_ ### What is --connection? `--connection` allows you to use predefined connections, that is supported by `clickhouse-client` ([1], [2]). Here is an example in `XML` format: ```xml prod prod default secret ``` Or in `YAML`: ```yaml --- connections_credentials: prod: name: prod hostname: prod user: default password: secret # secure: false # skip_verify: false # ca_certificate: # client_certificate: # client_private_key: ``` And later, instead of specifying `--url` (with password in plain-text, which is highly not recommended), you can use `chdig --connection prod`. [1]: https://github.com/ClickHouse/ClickHouse/pull/45715 [2]: https://github.com/ClickHouse/ClickHouse/pull/46480 ### What is Perfetto export? Pressing `X` in the queries view exports a timeline visualization to [Perfetto UI](https://ui.perfetto.dev) — an open-source trace viewer that provides a zoomable timeline, flamegraph visualization, and SQL-queryable trace data. It runs entirely in the browser. An embedded HTTP server starts on port 9001 (lazily, on first export) and serves the binary protobuf trace. The browser opens automatically. The export includes data from multiple ClickHouse system tables (when available): | Source table | What it shows | |---|---| | In-memory queries | Query duration slices grouped by host/user | | `system.opentelemetry_span_log` | Processor pipeline spans | | `system.trace_log` (ProfileEvent) | Per-thread counter increments | | `system.trace_log` (CPU/Real/Memory) | Stack trace samples (flamegraph in Perfetto) | | `system.text_log` | Query log messages grouped by level | | `system.query_metric_log` | Per-query metric snapshots | | `system.part_log` | Part lifecycle events (NewPart, MergeParts, etc.) | | `system.query_thread_log` | Per-thread execution with ProfileEvents | Tables that don't exist are silently skipped — the export works with whatever data is available. When queries are selected with `Space`, only those queries are exported. To get the richest traces, enable these ClickHouse settings for the queries you want to analyze: ```sql SET opentelemetry_start_trace_probability = 1, opentelemetry_trace_processors = 1, opentelemetry_trace_cpu_scheduling = 1, log_query_threads = 1, trace_profile_events = 1, query_metric_log_interval = 0 ``` - `opentelemetry_start_trace_probability` / `opentelemetry_trace_processors` / `opentelemetry_trace_cpu_scheduling` — enable OpenTelemetry spans for the query execution pipeline (populates `system.opentelemetry_span_log`) - `log_query_threads` — log per-thread execution info (populates `system.query_thread_log`) - `trace_profile_events` — record ProfileEvent counter increments with timestamps into `system.trace_log`, giving precise per-event timelines - `query_metric_log_interval` — controls periodic metric snapshots in `system.query_metric_log` (sampled every N milliseconds). Set to `0` to disable if you prefer the more accurate `trace_profile_events`. Set to e.g. `1000` (1 second) if you want periodic snapshots — note that these are sampled and less precise than `trace_profile_events`, but lighter on overhead ### What is flamegraph? It is best to start with [Brendan Gregg's site](https://www.brendangregg.com/flamegraphs.html) for a solid introduction to flamegraphs. Below is a description of the various types of flamegraphs available in `chdig`: - `Real` - Traces are captured at regular intervals (defined by [`query_profiler_real_time_period_ns`](https://clickhouse.com/docs/operations/settings/settings#query_profiler_real_time_period_ns)/[`global_profiler_real_time_period_ns`](https://clickhouse.com/docs/operations/server-configuration-parameters/settings#global_profiler_real_time_period_ns)) for each thread, regardless of whether the thread is actively running on the CPU - `CPU` - Traces are captured only when a thread is actively executing on the CPU, based on the interval specified in [`query_profiler_cpu_time_period_ns`](https://clickhouse.com/docs/operations/settings/settings#query_profiler_cpu_time_period_ns)/[`global_profiler_cpu_time_period_ns`](https://clickhouse.com/docs/operations/server-configuration-parameters/settings#global_profiler_cpu_time_period_ns) - `Memory` - Traces are captured after each [`memory_profiler_step`](https://clickhouse.com/docs/operations/settings/settings#memory_profiler_step)/[`total_memory_profiler_step`](https://clickhouse.com/docs/operations/server-configuration-parameters/settings#total_memory_profiler_step) bytes are allocated by the query or server - `Live` - Real-time visualization of what server is doing now from [`system.stack_trace`](https://clickhouse.com/docs/operations/system-tables/stack_trace) See also: - [Sampling Query Profiler](https://clickhouse.com/docs/operations/optimizing-performance/sampling-query-profiler) _Note: for `Memory` `chdig` uses `memory_profiler_step` over `memory_profiler_sample_probability`, since the later is disabled by default_ ### Why I see IO wait reported as zero? - You should ensure that ClickHouse uses one of taskstat gathering methods: - procfs - netlink - And also for linux 5.14 you should enable `kernel.task_delayacct` sysctl as well. ### How to copy text from `chdig` By default `chdig` is started with mouse mode enabled in terminal, you cannot copy with this mode enabled. But, terminals provide a way to disable it temporary by pressing some key (usually it is some combination of `Alt`, `Shift` or/and `Ctrl`), so you can find yours press them, and copy. --- See also [bugs list](Bugs.md) ================================================ FILE: LICENSE ================================================ Copyright 2023 Azat Khuzhin 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. ================================================ FILE: Makefile ================================================ debug ?= target ?= $(shell rustc -vV | sed -n 's|host: ||p') # Parse the target (i.e. aarch64-unknown-linux-musl) target_os := $(shell echo $(target) | cut -d'-' -f3) target_libc := $(shell echo $(target) | cut -d'-' -f4) target_arch := $(shell echo $(target) | cut -d'-' -f1) host_arch := $(shell uname -m) # Version normalization for deb/rpm: # - trim "v" prefix # - first "-" replace with "+" # - second "-" replace with "~" # # Refs: https://www.debian.org/doc/debian-policy/ch-controlfields.html CHDIG_VERSION=$(shell git describe | sed -e 's/^v//' -e 's/-/+/' -e 's/-/~/') # Refs: https://wiki.archlinux.org/title/Arch_package_guidelines#Package_versioning CHDIG_VERSION_ARCH=$(shell git describe | sed -e 's/^v//' -e 's/-/./g') $(info DESTDIR = $(DESTDIR)) $(info CHDIG_VERSION = $(CHDIG_VERSION)) $(info CHDIG_VERSION_ARCH = $(CHDIG_VERSION_ARCH)) $(info debug = $(debug)) $(info target = $(target)) $(info host_arch = $(host_arch)) ifdef debug cargo_build_opts := target_type := debug else cargo_build_opts := --release target_type = release endif ifneq ($(target),) cargo_build_opts += --target $(target) endif # Normalize architecture names norm_target_arch := $(shell echo $(target_arch) | sed -e 's/^aarch64$$/arm64/' -e 's/^x86_64$$/amd64/') norm_host_arch := $(shell echo $(host_arch) | sed -e 's/^aarch64$$/arm64/' -e 's/^x86_64$$/amd64/') $(info Normalized target arch: $(norm_target_arch)) $(info Normalized host arch: $(norm_host_arch)) # Cross compilation requires some tricks: # - use lld linker # - explicitly specify path for libstdc++ # (Also some packages, that you can found in github actions manifests) # # TODO: allow to use clang/gcc from PATH ifneq ($(norm_host_arch),$(norm_target_arch)) $(info Cross compilation for $(target_arch)) # Detect the latest lld LLD := $(shell ls /usr/bin/ld.lld /usr/bin/ld.lld-* 2>/dev/null | sort -V | tail -n1) $(info LLD = $(LLD)) # Detect the latest clang CLANG := $(shell ls /usr/bin/clang /usr/bin/clang-* 2>/dev/null | grep -e '/clang$$' -e '/clang-[0-9]\+$$' | sort -V | tail -n1) $(info CLANG = $(CLANG)) CLANG_CXX := $(shell ls /usr/bin/clang++ /usr/bin/clang++-* 2>/dev/null | grep -e '/clang++$$' -e '/clang++-[0-9]\+$$' | sort -V | tail -n1) $(info CLANG_CXX = $(CLANG_CXX)) export CC := $(CLANG) export CXX := $(CLANG_CXX) export RUSTFLAGS := -C linker=$(LLD) # /usr/aarch64-linux-gnu/lib64/ (archlinux aarch64-linux-gnu-gcc) prefix := /usr/$(target_arch)-$(target_os)-gnu/lib ifneq ($(wildcard $(prefix)),) export RUSTFLAGS := $(RUSTFLAGS) -C link-args=-L$(prefix) endif prefix := /usr/$(target_arch)-$(target_os)-gnu/lib64 ifneq ($(wildcard $(prefix)),) export RUSTFLAGS := $(RUSTFLAGS) -C link-args=-L$(prefix) endif # /usr/lib/gcc-cross/aarch64-linux-gnu/$gcc (ubuntu) latest_gcc_cross_version := $(shell ls -d /usr/lib/gcc-cross/$(target_arch)-$(target_os)-gnu/* 2>/dev/null | sort -V | tail -n1 | xargs -I{} basename {}) prefix := /usr/lib/gcc-cross/$(target_arch)-$(target_os)-gnu/$(latest_gcc_cross_version) ifneq ($(wildcard $(prefix)),) export RUSTFLAGS := $(RUSTFLAGS) -C link-args=-L$(prefix) endif # NOTE: there is also https://musl.cc/aarch64-linux-musl-cross.tgz $(info RUSTFLAGS = $(RUSTFLAGS)) endif .PHONY: build build_completion deploy-binary chdig install run \ deb rpm archlinux tar packages # This should be the first target (since ".DEFAULT_GOAL" is supported only since 3.80+) default: build .DEFAULT_GOAL: default chdig: cargo build $(cargo_build_opts) run: chdig cargo run $(cargo_build_opts) build: chdig deploy-binary test: @if command -v cargo-nextest >/dev/null 2>&1; then \ cargo nextest run $(cargo_build_opts); \ else \ cargo test $(cargo_build_opts); \ fi build_completion: chdig cargo run $(cargo_build_opts) -- --completion bash > target/chdig.bash-completion install: chdig build_completion install -m755 -D -t $(DESTDIR)/bin target/$(target)/$(target_type)/chdig install -m644 -D -t $(DESTDIR)/share/bash-completion/completions target/chdig.bash-completion deploy-binary: chdig cp target/$(target)/$(target_type)/chdig target/chdig packages: build build_completion deb rpm archlinux tar deb: build CHDIG_VERSION=${CHDIG_VERSION} CHDIG_ARCH=${norm_target_arch} nfpm package --config chdig-nfpm.yaml --packager deb rpm: build CHDIG_VERSION=${CHDIG_VERSION} CHDIG_ARCH=${target_arch} nfpm package --config chdig-nfpm.yaml --packager rpm archlinux: build CHDIG_VERSION=${CHDIG_VERSION_ARCH} CHDIG_ARCH=${target_arch} nfpm package --config chdig-nfpm.yaml --packager archlinux .ONESHELL: tar: archlinux CHDIG_VERSION=${CHDIG_VERSION_ARCH} CHDIG_ARCH=${target_arch} nfpm package --config chdig-nfpm.yaml --packager archlinux tmp_dir=$(shell mktemp -d /tmp/chdig-${CHDIG_VERSION}.XXXXXX) echo "Temporary directory for tar package: $$tmp_dir" tar -C $$tmp_dir -vxf chdig-${CHDIG_VERSION_ARCH}-1-${target_arch}.pkg.tar.zst usr # Strip /tmp/chdig-${CHDIG_VERSION}.XXXXXX and replace it with chdig-${CHDIG_VERSION} # (and we need to remove leading slash) tar --show-transformed-names --transform "s#^$${tmp_dir#/}#chdig-${CHDIG_VERSION}-${target_arch}#" -vczf chdig-${CHDIG_VERSION}-${target_arch}.tar.gz $$tmp_dir echo rm -fr $$tmp_dir help: @echo "Usage: make [debug=1] [target=]" ================================================ FILE: README.md ================================================ ### chdig Dig into [ClickHouse](https://github.com/ClickHouse/ClickHouse/) with TUI interface. ### Installation `chdig` is also available as part of `clickhouse` - `clickhouse chdig`, but that version may be slightly outdated. Pre-built packages (`.deb`, `.rpm`, `archlinux`, `.tar.gz`) and standalone binaries for `Linux` and `macOS` are available for both `x86_64` and `aarch64` architectures. The latest [unstable release can be found on GitHub](https://github.com/azat/chdig/releases/tag/latest). *See also the complete list of [releases](https://github.com/azat/chdig/releases).*
Package repositories (AUR, Scoop, Homebrew) #### archlinux user repository (aur) And also for archlinux there is an aur package: - [**chdig-latest-bin**](https://aur.archlinux.org/packages/chdig-latest-bin) - binary artifact of the upstream - [chdig-git](https://aur.archlinux.org/packages/chdig-git) - build from sources - [chdig-bin](https://aur.archlinux.org/packages/chdig-bin) - binary of the latest stable version *Note: `chdig-latest-bin` is recommended because it is latest available version and you don't need toolchain to compile* #### scoop (windows) ``` scoop bucket add extras scoop install extras/chdig ``` #### brew (macos) ``` brew install chdig ```
### Demo [![asciicast](https://github.com/azat/chdig/releases/download/v26.1.1/chdig-v26.1.1.gif)](https://asciinema.org/a/OvQIBpQCAtFU8AyF) ### Motivation The idea is came from everyday digging into various ClickHouse issues. ClickHouse has a approximately universe of introspection tools, and it is easy to forget some of them. At first I came with some [slides](https://azat.sh/presentations/2022-know-your-clickhouse/) and a picture (to attract your attention) by analogy to what [Brendan Gregg](https://www.brendangregg.com/linuxperf.html) did for Linux: [![Know Your ClickHouse](https://azat.sh/presentations/2022-know-your-clickhouse/Know-Your-ClickHouse.png)](https://azat.sh/presentations/2022-know-your-clickhouse/Know-Your-ClickHouse.png) *Note, the picture and the presentation had been made in the beginning of 2022, so it may not include some new introspection tools*. But this requires you to dig into lots of places, and even though during this process you will learn a lot, it does not solves the problem of forgetfulness. So I came up with this simple TUI interface that tries to make this process simpler. `chdig` can be used not only to debug some problems, but also just as a regular introspection, like `top` for Linux. ### Features - `top` like interface (or [`csysdig`](https://github.com/draios/sysdig) to be more precise) - [Flamegraphs](Documentation/FAQ.md#what-is-flamegraph) (CPU/Real/Memory/Live) in TUI (thanks to [flamelens](https://github.com/ys-l/flamelens)) - [Perfetto support](Documentation/FAQ.md#what-is-perfetto-export) - Share flamegraphs (using [pastila.nl](https://pastila.nl/) and [speedscope](https://www.speedscope.app/)) - Share logs via [pastila.nl](https://pastila.nl/) - Share query pipelines (using [viz.js](https://github.com/mdaines/viz-js) and [pastila.nl](https://pastila.nl/)) - Cluster support (`--cluster`) - aggregate data from all hosts in the cluster - Historical support (`--history`) - includes rotated `system.*_log_*` tables - `clickhouse-client` compatibility (including `--connection`) for options and configuration files And there is a huge bunch of [ideas](https://github.com/azat/chdig/issues). **Note, this it is in a pre-alpha stage, so everything can be changed (keyboard shortcuts, views, color schema and of course features)** ### Requirements If something does not work, like you have too old version of `ClickHouse`, consider upgrading. *Note: the oldest version that had been tested was 21.2 (at some point in time)* ### Build from sources ``` cargo build ``` > [!NOTE] > If you see an error like `failed to authenticate when downloading repository: git@github.com:azat-rust/cursive`, > it is likely because your local Git config is rewriting `https://github.com/` to `git@github.com:`: > > ``` > [url "git@github.com:"] > insteadOf = https://github.com/ > ``` > > Cargo's built-in Git library does not handle this case gracefully. > You can either remove that config entry or tell Cargo to use the system Git client instead: > > ```toml > # ~/.cargo/config.toml > [net] > git-fetch-with-cli = true > ``` For development and debugging information, see [Documentation/Developers.md](Documentation/Developers.md). ## References - [FAQ](Documentation/FAQ.md) - [Bugs list](Documentation/Bugs.md) - [Shortcuts](Documentation/Actions.md#shortcuts) - [Developers](Documentation/Developers.md) ================================================ FILE: chdig-nfpm.yaml ================================================ --- name: "chdig" arch: "${CHDIG_ARCH}" platform: "linux" version: "${CHDIG_VERSION}" homepage: "https://github.com/azat/chdig" license: "Apache" priority: "optional" maintainer: "Azat Khuzhin " description: | Dig into ClickHouse queries with TUI interface. contents: - src: target/chdig dst: /usr/bin/chdig file_info: mode: 0755 - src: target/chdig.bash-completion dst: /usr/share/bash-completion/completions/chdig file_info: mode: 0644 - src: README.md dst: /usr/share/doc/chdig/README.md file_info: mode: 0644 ================================================ FILE: rustfmt.toml ================================================ edition = "2018" ================================================ FILE: src/actions.rs ================================================ use cursive::{event::Event, theme::Effect, utils::markup::StyledString}; #[derive(Clone)] pub struct ActionDescription { pub text: &'static str, pub event: Event, } impl ActionDescription { pub fn event_string(&self) -> String { match self.event { Event::Char(c) => { // - It is hard to understand that nothing is a space // - And it overlaps with no shortcut actions if c == ' ' { return "".to_string(); } else { return c.to_string(); } } Event::CtrlChar(c) => { return format!("Ctrl+{}", c); } Event::AltChar(c) => { return format!("Alt+{}", c); } Event::Key(k) => { return format!("{:?}", k); } Event::Unknown(_) => { return "".to_string(); } _ => panic!("{:?} is not supported", self.event), } } pub fn preview_styled(&self) -> StyledString { let mut text = StyledString::default(); text.append_styled(format!("{:>10}", self.event_string()), Effect::Bold); text.append_plain(format!(" - {}\n", self.text)); return text; } } ================================================ FILE: src/bin.rs ================================================ use anyhow::{Result, anyhow}; use backtrace::Backtrace; use flexi_logger::{FileSpec, LogSpecification, Logger}; use std::ffi::OsString; use std::panic::{self, PanicHookInfo}; use std::sync::Arc; use cursive::view::Resizable; use crate::{ interpreter::{ClickHouse, Context, ContextArc, options}, view::Navigation, }; // NOTE: hyper also has trace_span() which will not be overwritten // // FIXME: should be initialize before options, but options prints completion that should be // done before terminal switched to raw mode. const DEFAULT_RUST_LOG: &str = "trace,cursive=info,clickhouse_rs=info,hyper=info,rustls=info"; fn panic_hook(info: &PanicHookInfo<'_>) { let location = info.location().unwrap(); let msg = if let Some(s) = info.payload().downcast_ref::<&'static str>() { *s } else if let Some(s) = info.payload().downcast_ref::() { &s[..] } else { "Box" }; // NOTE: we need to add \r since the terminal is in raw mode. // (another option is to restore the terminal state with termios) let stacktrace: String = format!("{:?}", Backtrace::new()).replace('\n', "\n\r"); print!( "\n\rthread '' panicked at '{}', {}\n\r{}", msg, location, stacktrace ); } pub async fn chdig_main_async(itr: I) -> Result<()> where I: IntoIterator, T: Into + Clone, { let options = options::parse_from(itr)?; let mut logger_handle = None; // We start logging to file earlier for better introspection. if let Some(log) = &options.service.log { logger_handle = Some( Logger::try_with_env_or_str(DEFAULT_RUST_LOG)? .log_to_file(FileSpec::try_from(log)?) .format(flexi_logger::with_thread) .start()?, ); } // Initialize it before any backends (otherwise backend will prepare terminal for TUI app, and // panic hook will clear the screen). let clickhouse = Arc::new(ClickHouse::new(options.clickhouse.clone()).await?); let server_warnings = match clickhouse.get_warnings().await { Ok(w) => w, Err(e) => { log::warn!("Failed to fetch system.warnings: {}", e); Vec::new() } }; panic::set_hook(Box::new(|info| { panic_hook(info); })); let backend = cursive::backends::try_default().map_err(|e| anyhow!(e.to_string()))?; let mut siv = cursive::CursiveRunner::new(cursive::Cursive::new(), backend); if options.service.log.is_none() { logger_handle = Some( Logger::try_with_env_or_str(DEFAULT_RUST_LOG)? .log_to_writer(cursive_flexi_logger_view::cursive_flexi_logger(&siv)) .format(flexi_logger::colored_with_thread) .start()?, ); } // FIXME: should be initialized before cursive, otherwise on error it clears the terminal. let context: ContextArc = Context::new(options, clickhouse, siv.cb_sink().clone()).await?; siv.chdig(context.clone()); if !server_warnings.is_empty() { let text = server_warnings.join("\n"); siv.add_layer( cursive::views::Dialog::around(cursive::views::ScrollView::new( cursive::views::TextView::new(text), )) .title("Server warnings") .button("OK", |s| { s.pop_layer(); }) .max_width(80), ); } log::info!("chdig started"); siv.run(); if let Some(logger_handle) = logger_handle { // Suppress error from the cursive_flexi_logger_view - "cursive callback sink is closed!" // Note, cursive_flexi_logger_view does not implements shutdown() so it will not help. logger_handle.set_new_spec(LogSpecification::parse("none")?); } return Ok(()); } fn collect_args(argc: c_int, argv: *const *const c_char) -> Vec { use std::ffi::CStr; unsafe { std::slice::from_raw_parts(argv, argc as usize) .iter() .map(|&ptr| { let c_str = CStr::from_ptr(ptr); let string = c_str.to_string_lossy().into_owned(); OsString::from(string) }) .collect() } } use std::os::raw::{c_char, c_int}; #[unsafe(no_mangle)] pub extern "C" fn chdig_main(argc: c_int, argv: *const *const c_char) -> c_int { #[cfg(feature = "tokio-console")] console_subscriber::init(); tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(chdig_main_async(collect_args(argc, argv))) .unwrap_or_else(|e| { eprintln!("{}", e); std::process::exit(1); }); return 0; } ================================================ FILE: src/common/mod.rs ================================================ mod relative_date_time; pub mod sparkline; mod stopwatch; pub use relative_date_time::RelativeDateTime; pub use relative_date_time::parse_datetime_or_date; pub use stopwatch::Stopwatch; ================================================ FILE: src/common/relative_date_time.rs ================================================ use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeDelta}; use std::{ fmt::Display, ops::{AddAssign, SubAssign}, str::FromStr, }; pub fn parse_datetime_or_date(value: &str) -> Result, String> { let mut errors = Vec::new(); // Parse without timezone match value.parse::() { Ok(datetime) => return Ok(datetime.and_local_timezone(Local).unwrap()), Err(err) => errors.push(err), } // Parse *with* timezone match value.parse::>() { Ok(datetime) => return Ok(datetime), Err(err) => errors.push(err), } // Parse as date match value.parse::() { Ok(date) => { return Ok(date .and_hms_opt(0, 0, 0) .unwrap() .and_local_timezone(Local) .unwrap()); } Err(err) => errors.push(err), } return Err(format!( "Valid RFC3339-formatted (YYYY-MM-DDTHH:MM:SS[.ssssss][±hh:mm|Z]) datetime or date while parsing '{}':\n{}", value, errors .iter() .map(|e| e.to_string()) .collect::>() .join("\n") )); } #[derive(Clone, Debug)] pub struct RelativeDateTime { date_time: Option>, // Always subtracted offset: Option, } impl RelativeDateTime { pub fn new(offset: Option) -> Self { Self { date_time: None, offset, } } pub fn get_date_time(&self) -> Option> { self.date_time } pub fn to_editable_string(&self) -> String { match (&self.date_time, &self.offset) { (None, Some(offset)) => { humantime::format_duration(offset.to_std().unwrap_or_default()).to_string() } (Some(dt), _) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(), (None, None) => String::new(), } } pub fn to_sql_datetime_64(&self) -> Option { match (self.date_time, self.offset) { (Some(date_time), Some(offset)) => Some(format!( "fromUnixTimestamp64Nano({}) - INTERVAL {} NANOSECOND", date_time.timestamp_nanos_opt()?, offset.num_nanoseconds()? )), (None, Some(offset)) => Some(format!( "now() - INTERVAL {} NANOSECOND", offset.num_nanoseconds()? )), (Some(date_time), None) => Some(format!( "fromUnixTimestamp64Nano({})", date_time.timestamp_nanos_opt()? )), (None, None) => Some("now()".to_string()), } } } impl From> for RelativeDateTime { fn from(value: DateTime) -> Self { RelativeDateTime { date_time: Some(value), offset: None, } } } impl From>> for RelativeDateTime { fn from(value: Option>) -> Self { RelativeDateTime { date_time: value, offset: None, } } } impl FromStr for RelativeDateTime { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { // Empty string is a special case for relative "now" // (i.e. it will be always calculated from current time) if s.is_empty() { Ok(RelativeDateTime { date_time: None, offset: None, }) } else if let Ok(datetime) = parse_datetime_or_date(s) { Ok(RelativeDateTime { date_time: Some(datetime), offset: None, }) } else { Ok(RelativeDateTime { date_time: None, offset: Some(TimeDelta::from_std( s.parse::()?.into(), )?), }) } } } impl From for DateTime { fn from(value: RelativeDateTime) -> Self { let mut date_time = value.date_time.unwrap_or(Local::now()); if let Some(offset) = value.offset { date_time -= offset; } return date_time; } } impl Display for RelativeDateTime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "{:?} (offset={:?})", self.date_time, self.offset )) } } impl AddAssign for RelativeDateTime { fn add_assign(&mut self, rhs: TimeDelta) { self.offset = Some(rhs); } } impl SubAssign for RelativeDateTime { fn sub_assign(&mut self, rhs: TimeDelta) { self.offset = Some(rhs); } } ================================================ FILE: src/common/sparkline.rs ================================================ use std::collections::VecDeque; const BLOCKS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; pub struct SparklineBuffer { data: VecDeque, capacity: usize, } impl SparklineBuffer { pub fn new(capacity: usize) -> Self { Self { data: VecDeque::with_capacity(capacity), capacity, } } pub fn push(&mut self, value: f64) { if self.data.len() == self.capacity { self.data.pop_front(); } self.data.push_back(value); } pub fn render(&self, width: usize) -> String { if self.data.is_empty() { return String::new(); } let samples: Vec = self .data .iter() .rev() .take(width) .copied() .collect::>() .into_iter() .rev() .collect(); let min = samples.iter().copied().fold(f64::INFINITY, f64::min); let max = samples.iter().copied().fold(f64::NEG_INFINITY, f64::max); let range = max - min; samples .iter() .map(|&v| { if range == 0.0 { BLOCKS[BLOCKS.len() / 2] } else { let idx = ((v - min) / range * (BLOCKS.len() - 1) as f64).round() as usize; BLOCKS[idx.min(BLOCKS.len() - 1)] } }) .collect() } } ================================================ FILE: src/common/stopwatch.rs ================================================ /// Stupid and simple implementation of stopwatch. use std::time::{Duration, Instant}; pub struct Stopwatch { start_time: Instant, } impl Stopwatch { pub fn start_new() -> Stopwatch { Stopwatch { start_time: Instant::now(), } } pub fn elapsed_ms(&self) -> u64 { return self.elapsed().as_millis() as u64; } pub fn elapsed(&self) -> Duration { return self.start_time.elapsed(); } } ================================================ FILE: src/interpreter/background_runner.rs ================================================ use std::sync::{Arc, Condvar, Mutex, atomic}; use std::thread; use std::time::Duration; /// Runs periodic tasks in background thread. /// /// It is OK to suppress unused warning for this code, since it join the thread in drop() /// correctly, example: /// /// ``rust /// pub struct SomeView { /// #[allow(unused)] /// bg_runner: BackgroundRunner, /// } /// `` /// pub struct BackgroundRunner { interval: Duration, thread: Option>, force: Arc, exit: Arc>, cv: Arc<(Mutex<()>, Condvar)>, } impl Drop for BackgroundRunner { fn drop(&mut self) { log::debug!("Stopping updates"); *self.exit.lock().unwrap() = true; self.cv.1.notify_all(); self.thread.take().unwrap().join().unwrap(); log::debug!("Updates stopped"); } } impl BackgroundRunner { pub fn new( interval: Duration, cv: Arc<(Mutex<()>, Condvar)>, force: Arc, ) -> Self { return Self { interval, thread: None, force, exit: Arc::new(Mutex::new(false)), cv, }; } pub fn start(&mut self, callback: C) { let interval = self.interval; let cv = self.cv.clone(); let exit = self.exit.clone(); let force = self.force.clone(); self.thread = Some(std::thread::spawn(move || { loop { let was_force = force.swap(false, atomic::Ordering::SeqCst); callback(was_force); if *exit.lock().unwrap() { break; } let _ = cv.1.wait_timeout(cv.0.lock().unwrap(), interval).unwrap(); if *exit.lock().unwrap() { break; } } })); // Explicitly trigger at least one update with force self.schedule(); } pub fn schedule(&mut self) { self.force.store(true, atomic::Ordering::SeqCst); self.cv.1.notify_all(); } } ================================================ FILE: src/interpreter/clickhouse.rs ================================================ use crate::{ common::RelativeDateTime, interpreter::{ ClickHouseAvailableQuirks, ClickHouseQuirks, options::{ClickHouseOptions, LogsOrder}, }, }; use anyhow::{Error, Result}; use chrono::{DateTime, Local}; use chrono_tz::Tz; use clickhouse_rs::{ Block, Options, Pool, types::{Complex, FromSql}, }; use futures_util::StreamExt; use std::collections::HashMap; use std::str::FromStr; // TODO: // - implement parsing using serde // - replace clickhouse_rs::client_info::write() (with extend crate) to change the client name // - escape parameters pub type Columns = Block; pub struct ClickHouse { pub quirks: ClickHouseQuirks, // Server has use_shared_merge_tree_log_pipeline enabled (SharedMergeTree-backed system.*_log). // When true, system.*_log reads do not need clusterAllReplicas(): one replica sees all rows. shared_log_pipeline: bool, options: ClickHouseOptions, pool: Pool, } #[derive(Debug, PartialEq, Clone)] #[allow(clippy::upper_case_acronyms)] pub enum TraceType { CPU, Real, Memory, MemorySample, JemallocSample, ProfileEvent, MemoryAllocatedWithoutCheck, } #[derive(Debug, Clone)] pub struct TextLogArguments { pub query_ids: Option>, pub logger_names: Option>, pub hostname: Option, pub message_filter: Option, pub max_level: Option, pub start: DateTime, pub end: RelativeDateTime, } #[derive(Default)] pub struct ClickHouseServerCPU { pub count: u64, pub user: u64, pub system: u64, } /// NOTE: Likely misses threads for IO #[derive(Default)] pub struct ClickHouseServerThreadPools { pub merges_mutations: u64, pub fetches: u64, pub common: u64, pub moves: u64, pub schedule: u64, pub buffer_flush: u64, pub distributed: u64, pub message_broker: u64, pub backups: u64, pub io: u64, pub remote_io: u64, pub queries: u64, } #[derive(Default)] pub struct ClickHouseServerThreads { pub os_total: u64, pub os_runnable: u64, pub tcp: u64, pub http: u64, pub interserver: u64, pub pools: ClickHouseServerThreadPools, } #[derive(Default)] pub struct ClickHouseServerMemory { pub os_total: u64, pub resident: u64, pub tracked: u64, pub tables: u64, pub caches: u64, pub queries: u64, pub merges_mutations: u64, pub active_merges: u64, pub async_inserts: u64, pub dictionaries: u64, pub primary_keys: u64, pub fragmentation: u64, pub index_granularity: u64, pub io: u64, } /// May have duplicated accounting (due to bridges and stuff) #[derive(Default)] pub struct ClickHouseServerNetwork { pub send_bytes: u64, pub receive_bytes: u64, } #[derive(Default)] pub struct ClickHouseServerUptime { pub _os: u64, pub server: u64, } /// May does not take into account some block devices (due to filter by sd*/nvme*/vd*) #[derive(Default)] pub struct ClickHouseServerBlockDevices { pub read_bytes: u64, pub write_bytes: u64, } #[derive(Default)] pub struct ClickHouseServerStorages { pub buffer_bytes: u64, // Replace with bytes once [1] will be merged. // // [1]: https://github.com/ClickHouse/ClickHouse/pull/50238 pub distributed_insert_files: u64, pub total_rows: u64, pub total_bytes: u64, } #[derive(Default)] pub struct ClickHouseServerRows { pub selected: u64, pub inserted: u64, } #[derive(Default)] pub struct ClickHouseServerSummary { pub queries: u64, pub merges: u64, pub mutations: u64, pub replication_queue: u64, pub replication_queue_tries: u64, pub fetches: u64, pub servers: u64, pub rows: ClickHouseServerRows, pub storages: ClickHouseServerStorages, pub uptime: ClickHouseServerUptime, pub memory: ClickHouseServerMemory, pub cpu: ClickHouseServerCPU, pub threads: ClickHouseServerThreads, pub network: ClickHouseServerNetwork, pub blkdev: ClickHouseServerBlockDevices, pub update_interval: u64, } pub struct QueryMetricRow { pub host_name: String, pub timestamp_ns: u64, pub memory_usage: i64, pub peak_memory_usage: i64, pub profile_events: HashMap, } pub struct MetricLogRow { pub timestamp_ns: u64, pub profile_events: HashMap, pub current_metrics: HashMap, } fn collect_values<'b, T: FromSql<'b>>(block: &'b Columns, column: &str) -> Vec { return (0..block.row_count()) .map(|i| block.get(i, column).unwrap()) .collect(); } const CHDIG_CLIENT_NAME: [&str; 2] = ["chdig", env!("CARGO_PKG_VERSION")]; fn get_client_name() -> String { return CHDIG_CLIENT_NAME.join("-"); } impl ClickHouse { pub async fn new(options: ClickHouseOptions) -> Result { let url = format!( "{}&client_name={}", options.url.clone().unwrap(), get_client_name() ); let connect_options: Options = Options::from_str(&url)? .with_setting( "storage_system_stack_trace_pipe_read_timeout_ms", 1000, /* is_important= */ false, ) // FIXME: ClickHouse's analyzer does not handle ProfileEvents.Names (and similar), it throws: // // Invalid column type for ColumnUnique::insertRangeFrom. Expected String, got LowCardinality(String) // .with_setting("allow_experimental_analyzer", false, true) // TODO: add support of Map type for LowCardinality in the driver .with_setting("low_cardinality_allow_in_native_format", false, true); let pool = Pool::new(connect_options); let mut handle = pool.get_handle().await.map_err(|e| { Error::msg(format!( "Cannot connect to ClickHouse at {} ({})", options.url_safe, e )) })?; let version = if let Some(override_version) = &options.server_version { override_version.clone() } else { let version = handle .query("SELECT version()") .fetch_all() .await? .get::(0, 0)?; // Get VERSION_DESCRIBE from system.build_options for full version info (only build_options // include version prefix, i.e. -stable/-testing) handle .query("SELECT value FROM system.build_options WHERE name = 'VERSION_DESCRIBE'") .fetch_all() .await? .get::(0, 0) .unwrap_or_else(|_| version.clone()) }; let quirks = ClickHouseQuirks::new(version); // SMT-backed system.*_log (ClickHouse Cloud) exposes all replicas' rows through any single // replica, so clusterAllReplicas() is pure overhead there. The setting is off by default // and on self-hosted clusters, so we silently fall back to the cluster-wrapped path. let shared_log_pipeline = handle .query( "SELECT value FROM system.server_settings \ WHERE name = 'use_shared_merge_tree_log_pipeline'", ) .fetch_all() .await .ok() .filter(|block| block.row_count() > 0) .and_then(|block| block.get::(0, 0).ok()) .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if shared_log_pipeline { log::info!( "SharedMergeTree log pipeline detected, skipping clusterAllReplicas() for system.*_log" ); } return Ok(ClickHouse { quirks, shared_log_pipeline, options, pool, }); } pub fn version(&self) -> String { return self.quirks.get_version(); } pub async fn get_slow_query_log( &self, filter: &String, start: RelativeDateTime, end: RelativeDateTime, limit: u64, selected_host: Option<&String>, ) -> Result { let dbtable = self.get_log_table_name("system", "query_log"); let host_filter = self.get_log_host_filter_clause(selected_host); return self .execute( format!( r#" WITH {start} AS start_, {end} AS end_, slow_queries_ids AS ( SELECT DISTINCT initial_query_id FROM {db_table} WHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND is_initial_query AND /* To make query faster */ query_duration_ms > 1e3 {filter} {internal} {host_filter} ORDER BY query_duration_ms DESC LIMIT {limit} ) SELECT ProfileEvents.Names, ProfileEvents.Values, Settings.Names, Settings.Values, {peak_threads_usage} AS peak_threads_usage, // Compatibility with system.processlist memory_usage::Int64 AS peak_memory_usage, query_duration_ms/1e3 AS elapsed, user, is_initial_query, (exception_code = 394)::UInt8 AS is_cancelled, initial_query_id, query_id, hostname as host_name, current_database, query_start_time_microseconds, event_time_microseconds AS query_end_time_microseconds, toValidUTF8(query) AS original_query, normalizeQuery(query) AS normalized_query FROM {db_table} PREWHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND type != 'QueryStart' AND initial_query_id GLOBAL IN slow_queries_ids "#, start = start.to_sql_datetime_64().ok_or(Error::msg("Invalid start"))?, end = end.to_sql_datetime_64().ok_or(Error::msg("Invalid end"))?, db_table = dbtable, peak_threads_usage = if self.quirks.has(ClickHouseAvailableQuirks::QueryLogPeakThreadsUsage) { "peak_threads_usage" } else { "length(thread_ids)" }, internal = if self.options.internal_queries { "".to_string() } else { format!("AND client_name != '{}'", get_client_name()) }, filter = if !filter.is_empty() { format!("AND (client_hostname LIKE '{0}' OR log_comment LIKE '{0}' OR os_user LIKE '{0}' OR user LIKE '{0}' OR initial_user LIKE '{0}' OR client_name LIKE '{0}' OR query_id LIKE '{0}' OR query LIKE '{0}')", &filter) } else { "".to_string() }, host_filter = host_filter, ) .as_str(), ) .await; } pub async fn get_last_query_log( &self, filter: &String, start: RelativeDateTime, end: RelativeDateTime, limit: u64, selected_host: Option<&String>, ) -> Result { // TODO: // - propagate sort order from the table // - distributed_group_by_no_merge=2 is broken for this query with WINDOW function let dbtable = self.get_log_table_name("system", "query_log"); let host_filter = self.get_log_host_filter_clause(selected_host); return self .execute( format!( r#" WITH {start} AS start_, {end} AS end_, last_queries_ids AS ( SELECT DISTINCT initial_query_id FROM {db_table} WHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND is_initial_query {filter} {internal} {host_filter} ORDER BY event_date DESC, event_time DESC LIMIT {limit} ) SELECT ProfileEvents.Names, ProfileEvents.Values, Settings.Names, Settings.Values, {peak_threads_usage} AS peak_threads_usage, // Compatibility with system.processlist memory_usage::Int64 AS peak_memory_usage, query_duration_ms/1e3 AS elapsed, user, is_initial_query, (exception_code = 394)::UInt8 AS is_cancelled, initial_query_id, query_id, hostname as host_name, current_database, query_start_time_microseconds, event_time_microseconds AS query_end_time_microseconds, toValidUTF8(query) AS original_query, normalizeQuery(query) AS normalized_query FROM {db_table} PREWHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND type != 'QueryStart' AND initial_query_id GLOBAL IN last_queries_ids "#, start = start.to_sql_datetime_64().ok_or(Error::msg("Invalid start"))?, end = end.to_sql_datetime_64().ok_or(Error::msg("Invalid end"))?, db_table = dbtable, peak_threads_usage = if self.quirks.has(ClickHouseAvailableQuirks::QueryLogPeakThreadsUsage) { "peak_threads_usage" } else { "length(thread_ids)" }, internal = if self.options.internal_queries { "".to_string() } else { format!("AND client_name != '{}'", get_client_name()) }, filter = if !filter.is_empty() { format!("AND (client_hostname LIKE '{0}' OR log_comment LIKE '{0}' OR os_user LIKE '{0}' OR user LIKE '{0}' OR initial_user LIKE '{0}' OR client_name LIKE '{0}' OR query_id LIKE '{0}' OR query LIKE '{0}')", &filter) } else { "".to_string() }, host_filter = host_filter, ) .as_str(), ) .await; } pub async fn get_processlist( &self, filter: String, limit: u64, selected_host: Option<&String>, ) -> Result { let dbtable = self.get_table_name_no_history("system", "processes"); let host_filter = self.get_host_filter_clause(selected_host); return self .execute( format!( r#" SELECT ProfileEvents.Names, ProfileEvents.Values, Settings.Names, Settings.Values, {peak_threads_usage} AS peak_threads_usage, peak_memory_usage, elapsed / {q} AS elapsed, user, is_initial_query, is_cancelled, initial_query_id, query_id, hostName() AS host_name, {current_database} AS current_database, /* NOTE: now64()/elapsed does not have enough precision to handle starting * time properly, while this column is used for querying system.text_log, * and it should be the smallest time that we are looking for */ (now64(6) - elapsed - 1) AS query_start_time_microseconds, now64(6) AS query_end_time_microseconds, toValidUTF8(query) AS original_query, normalizeQuery(query) AS normalized_query FROM {} WHERE 1 {filter} {internal} {host_filter} LIMIT {limit} "#, dbtable, q = if self.quirks.has(ClickHouseAvailableQuirks::ProcessesElapsed) { 10 } else { 1 }, current_database = if self.quirks.has(ClickHouseAvailableQuirks::ProcessesCurrentDatabase) { // This is required for EXPLAIN (available since 20.6), // so EXPLAIN with non-default current_database will be broken from processes view. "'default'" } else { "current_database" }, internal = if self.options.internal_queries { "".to_string() } else { format!("AND client_name != '{}'", get_client_name()) }, filter = if !filter.is_empty() { format!("AND (client_hostname LIKE '{0}' OR Settings['log_comment'] LIKE '{0}' OR os_user LIKE '{0}' OR user LIKE '{0}' OR initial_user LIKE '{0}' OR client_name LIKE '{0}' OR query_id LIKE '{0}' OR query LIKE '{0}')", &filter) } else { "".to_string() }, peak_threads_usage = if self.quirks.has(ClickHouseAvailableQuirks::ProcessesPeakThreadsUsage) { "peak_threads_usage" } else { "length(thread_ids)" }, host_filter = host_filter, ) .as_str(), ) .await; } pub async fn get_summary( &self, selected_host: Option<&String>, ) -> Result { let host_filter = self.get_host_filter_clause(selected_host); let host_where = if host_filter.is_empty() { String::new() } else { format!(" WHERE {}", &host_filter[4..]) // Remove leading "AND " }; let memory_index_granularity_trait = if self.quirks.has(ClickHouseAvailableQuirks::AsynchronousMetricsTotalIndexGranularityBytesInMemoryAllocated) { format!("(SELECT sum(index_granularity_bytes_in_memory_allocated) FROM {}{}) AS memory_index_granularity_", self.get_table_name_no_history("system", "parts"), host_where) } else { "0::UInt64 AS memory_index_granularity_".to_string() }; // NOTE: metrics (but not all of them) are deltas, so chdig do not need to reimplement this logic by itself. let block = self .execute( &format!( r#" WITH -- memory detalization (SELECT sum(CAST(value AS UInt64)) FROM {metrics} WHERE metric = 'MemoryTracking' {host_filter_and}) AS memory_tracked_, (SELECT sum(CAST(value AS UInt64)) FROM {metrics} WHERE metric = 'MergesMutationsMemoryTracking' {host_filter_and}) AS memory_merges_mutations_, (SELECT sum(total_bytes) FROM {tables} WHERE engine IN ('Join','Memory','Buffer','Set') {host_filter_and}) AS memory_tables_, (SELECT sum(CAST(value AS UInt64)) FROM {asynchronous_metrics} WHERE metric LIKE '%CacheBytes' AND metric NOT LIKE '%Filesystem%' {host_filter_and}) AS memory_async_metrics_caches_, (SELECT sum(CAST(value AS UInt64)) FROM {metrics} WHERE metric NOT LIKE '%Filesystem%' AND (metric LIKE '%CacheBytes' OR metric IN ('IcebergMetadataFilesCacheSize', 'VectorSimilarityIndexCacheSize')) {host_filter_and} ) AS memory_metrics_caches_, (SELECT sum(CAST(memory_usage AS UInt64)) FROM {processes} {host_filter_where}) AS memory_queries_, (SELECT sum(CAST(memory_usage AS UInt64)) FROM {merges} {host_filter_where}) AS memory_active_merges_, (SELECT sum(bytes_allocated) FROM {dictionaries} {host_filter_where}) AS memory_dictionaries_, (SELECT sum(total_bytes) FROM {async_inserts} {host_filter_where}) AS memory_async_inserts_, {memory_index_granularity_trait}, (SELECT count() FROM {one} {host_filter_where}) AS servers_, (SELECT count() FROM {replication_queue} {host_filter_where}) AS replication_queue_, (SELECT sum(num_tries) FROM {replication_queue} {host_filter_where}) AS replication_queue_tries_, (SELECT [sum(total_rows), sum(total_bytes)] FROM ( SELECT if(engine LIKE 'Shared%', max(total_rows), sum(total_rows)) AS total_rows, if(engine LIKE 'Shared%', max(total_bytes), sum(total_bytes)) AS total_bytes FROM {tables} WHERE has_own_data = 1 {host_filter_and} GROUP BY database, name, engine )) AS storage_totals_ SELECT assumeNotNull(memory_tracked_) AS memory_tracked, assumeNotNull(memory_merges_mutations_) AS memory_merges_mutations, assumeNotNull(memory_tables_) AS memory_tables, assumeNotNull(memory_async_metrics_caches_) + assumeNotNull(memory_metrics_caches_) AS memory_caches, assumeNotNull(memory_queries_) AS memory_queries, assumeNotNull(memory_active_merges_) AS memory_active_merges, assumeNotNull(memory_dictionaries_) AS memory_dictionaries, assumeNotNull(memory_async_inserts_) AS memory_async_inserts, assumeNotNull(servers_) AS servers, assumeNotNull(replication_queue_) AS replication_queue, assumeNotNull(replication_queue_tries_) AS replication_queue_tries, assumeNotNull(storage_totals_[1])::UInt64 AS storage_total_rows, assumeNotNull(storage_totals_[2])::UInt64 AS storage_total_bytes, max2(assumeNotNull(memory_index_granularity_), asynchronous_metrics.memory_index_granularity)::UInt64 AS memory_index_granularity, asynchronous_metrics.*, events.*, metrics.* FROM ( WITH -- exclude MD/LVM metric LIKE '%_sd%' OR metric LIKE '%_nvme%' OR metric LIKE '%_vd%' AS is_disk, metric LIKE '%vlan%' AS is_vlan -- NOTE: cast should be after aggregation function since the type is Float64 SELECT CAST(minIf(value, metric == 'OSUptime') AS UInt64) AS os_uptime, CAST(min(uptime()) AS UInt64) AS uptime, -- memory CAST(coalesce(sumIfOrNull(value, metric == 'CGroupMemoryTotal' and value > 0), sumIf(value, metric == 'OSMemoryTotal')) AS UInt64) AS os_memory_total, CAST(sumIf(value, metric == 'MemoryResident') AS UInt64) AS memory_resident, -- May differs from primary_key_bytes_in_memory_allocated from -- system.parts, since it takes into account only active parts CAST(sumIf(value, metric == 'TotalPrimaryKeyBytesInMemoryAllocated' OR metric == 'TotalProjectionPrimaryKeyBytesInMemoryAllocated' ) AS UInt64) AS memory_primary_keys, CAST(sumIf(value, metric == 'TotalIndexGranularityBytesInMemoryAllocated' OR metric == 'TotalProjectionIndexGranularityBytesInMemoryAllocated' ) AS UInt64) AS memory_index_granularity, CAST(( sumIf(value, metric == 'jemalloc.resident') - sumIf(value, metric == 'jemalloc.allocated') ) AS UInt64) AS memory_fragmentation, -- cpu CAST( max2( countIf(metric LIKE 'CPUFrequencyMHz%'), sumIf(value, metric = 'CGroupMaxCPU') ) AS UInt64) AS cpu_count, CAST( max2( sumIf(value, metric LIKE 'OSUserTimeCPU%'), sumIf(value, metric = 'OSUserTime') ) AS UInt64) AS cpu_user, CAST( max2( sumIf(value, metric LIKE 'OSSystemTimeCPU%'), sumIf(value, metric = 'OSSystemTime') ) AS UInt64) AS cpu_system, -- threads detalization CAST(sumIf(value, metric = 'HTTPThreads') AS UInt64) AS threads_http, CAST(sumIf(value, metric = 'TCPThreads') AS UInt64) AS threads_tcp, CAST(sumIf(value, metric = 'OSThreadsTotal') AS UInt64) AS threads_os_total, CAST(sumIf(value, metric = 'OSThreadsRunnable') AS UInt64) AS threads_os_runnable, CAST(sumIf(value, metric = 'InterserverThreads') AS UInt64) AS threads_interserver, -- network CAST(sumIf(value, metric LIKE 'NetworkSendBytes%' AND NOT is_vlan) AS UInt64) AS net_send_bytes, CAST(sumIf(value, metric LIKE 'NetworkReceiveBytes%' AND NOT is_vlan) AS UInt64) AS net_receive_bytes, -- block devices CAST(sumIf(value, metric LIKE 'BlockReadBytes%' AND is_disk) AS UInt64) AS block_read_bytes, CAST(sumIf(value, metric LIKE 'BlockWriteBytes%' AND is_disk) AS UInt64) AS block_write_bytes, -- update intervals CAST(anyLastIf(value, metric == 'AsynchronousMetricsUpdateInterval') AS UInt64) AS metrics_update_interval FROM {asynchronous_metrics} {host_filter_where} ) as asynchronous_metrics, ( SELECT sumIf(CAST(value AS UInt64), event == 'IOBufferAllocBytes') AS memory_io, sumIf(CAST(value AS UInt64), event == 'SelectedRows') AS selected_rows, sumIf(CAST(value AS UInt64), event == 'InsertedRows') AS inserted_rows FROM {events} {host_filter_where} ) as events, ( SELECT sumIf(CAST(value AS UInt64), metric == 'Query') AS queries, sumIf(CAST(value AS UInt64), metric == 'Merge') AS merges, sumIf(CAST(value AS UInt64), metric == 'PartMutation') AS mutations, sumIf(CAST(value AS UInt64), metric == 'ReplicatedFetch') AS fetches, sumIf(CAST(value AS UInt64), metric == 'StorageBufferBytes') AS storage_buffer_bytes, sumIf(CAST(value AS UInt64), metric == 'DistributedFilesToInsert') AS storage_distributed_insert_files, sumIf(CAST(value AS UInt64), metric == 'BackgroundMergesAndMutationsPoolTask') AS threads_merges_mutations, sumIf(CAST(value AS UInt64), metric == 'BackgroundFetchesPoolTask') AS threads_fetches, sumIf(CAST(value AS UInt64), metric == 'BackgroundCommonPoolTask') AS threads_common, sumIf(CAST(value AS UInt64), metric == 'BackgroundMovePoolTask') AS threads_moves, sumIf(CAST(value AS UInt64), metric == 'BackgroundSchedulePoolTask') AS threads_schedule, sumIf(CAST(value AS UInt64), metric == 'BackgroundBufferFlushSchedulePoolTask') AS threads_buffer_flush, sumIf(CAST(value AS UInt64), metric == 'BackgroundDistributedSchedulePoolTask') AS threads_distributed, sumIf(CAST(value AS UInt64), metric == 'BackgroundMessageBrokerSchedulePoolTask') AS threads_message_broker, sumIf(CAST(value AS UInt64), metric IN ( 'BackupThreadsActive', 'RestoreThreadsActive', 'BackupsIOThreadsActive' )) AS threads_backups, sumIf(CAST(value AS UInt64), metric IN ( 'DiskObjectStorageAsyncThreadsActive', 'ThreadPoolRemoteFSReaderThreadsActive', 'StorageS3ThreadsActive' )) AS threads_remote_io, sumIf(CAST(value AS UInt64), metric IN ( 'IOThreadsActive', 'IOWriterThreadsActive', 'IOPrefetchThreadsActive', 'MarksLoaderThreadsActive' )) AS threads_io, sumIf(CAST(value AS UInt64), metric IN ( 'QueryPipelineExecutorThreadsActive', 'QueryThread', 'AggregatorThreadsActive', 'StorageDistributedThreadsActive', 'DestroyAggregatesThreadsActive' )) AS threads_queries FROM {metrics} {host_filter_where} ) as metrics SETTINGS enable_global_with_statement=0 "#, metrics=self.get_table_name_no_history("system", "metrics"), events=self.get_table_name_no_history("system", "events"), tables=self.get_table_name_no_history("system", "tables"), processes=self.get_table_name_no_history("system", "processes"), merges=self.get_table_name_no_history("system", "merges"), async_inserts=self.get_table_name_no_history("system", "asynchronous_inserts"), replication_queue=self.get_table_name_no_history("system", "replication_queue"), dictionaries=self.get_table_name_no_history("system", "dictionaries"), asynchronous_metrics=self.get_table_name_no_history("system", "asynchronous_metrics"), one=self.get_table_name_no_history("system", "one"), memory_index_granularity_trait=memory_index_granularity_trait, host_filter_where=host_where, host_filter_and=host_filter, ) ) .await?; let get = |key: &str| { // By subquery.column if let Ok(value) = block.get::(0, key) { return value; } let parts = key.split(".").collect::>(); assert!(parts.len() <= 2); // By column return block.get::(0, parts[parts.len() - 1]).expect(key); }; return Ok(ClickHouseServerSummary { queries: get("metrics.queries"), merges: get("metrics.merges"), mutations: get("metrics.mutations"), replication_queue: get("replication_queue"), replication_queue_tries: get("replication_queue_tries"), fetches: get("metrics.fetches"), servers: get("servers"), uptime: ClickHouseServerUptime { _os: get("asynchronous_metrics.os_uptime"), server: get("asynchronous_metrics.uptime"), }, rows: ClickHouseServerRows { selected: get("events.selected_rows"), inserted: get("events.inserted_rows"), }, storages: ClickHouseServerStorages { buffer_bytes: get("metrics.storage_buffer_bytes"), distributed_insert_files: get("metrics.storage_distributed_insert_files"), total_rows: get("storage_total_rows"), total_bytes: get("storage_total_bytes"), }, memory: ClickHouseServerMemory { os_total: get("asynchronous_metrics.os_memory_total"), resident: get("asynchronous_metrics.memory_resident"), tracked: get("memory_tracked"), merges_mutations: get("memory_merges_mutations"), tables: get("memory_tables"), caches: get("memory_caches"), queries: get("memory_queries"), active_merges: get("memory_active_merges"), async_inserts: get("memory_async_inserts"), dictionaries: get("memory_dictionaries"), primary_keys: get("asynchronous_metrics.memory_primary_keys"), fragmentation: get("asynchronous_metrics.memory_fragmentation"), index_granularity: get("memory_index_granularity"), io: get("events.memory_io"), }, cpu: ClickHouseServerCPU { count: get("asynchronous_metrics.cpu_count"), user: get("asynchronous_metrics.cpu_user"), system: get("asynchronous_metrics.cpu_system"), }, threads: ClickHouseServerThreads { os_total: get("asynchronous_metrics.threads_os_total"), os_runnable: get("asynchronous_metrics.threads_os_runnable"), http: get("asynchronous_metrics.threads_http"), tcp: get("asynchronous_metrics.threads_tcp"), interserver: get("asynchronous_metrics.threads_interserver"), pools: ClickHouseServerThreadPools { merges_mutations: get("metrics.threads_merges_mutations"), fetches: get("metrics.threads_fetches"), common: get("metrics.threads_common"), moves: get("metrics.threads_moves"), schedule: get("metrics.threads_schedule"), buffer_flush: get("metrics.threads_buffer_flush"), distributed: get("metrics.threads_distributed"), message_broker: get("metrics.threads_message_broker"), backups: get("metrics.threads_backups"), io: get("metrics.threads_io"), remote_io: get("metrics.threads_remote_io"), queries: get("metrics.threads_queries"), }, }, network: ClickHouseServerNetwork { send_bytes: get("asynchronous_metrics.net_send_bytes"), receive_bytes: get("asynchronous_metrics.net_receive_bytes"), }, blkdev: ClickHouseServerBlockDevices { read_bytes: get("asynchronous_metrics.block_read_bytes"), write_bytes: get("asynchronous_metrics.block_write_bytes"), }, update_interval: get("asynchronous_metrics.metrics_update_interval"), }); } pub async fn kill_query(&self, query_id: &str) -> Result<()> { let query = if let Some(cluster) = &self.options.cluster { format!( "KILL QUERY ON CLUSTER {} WHERE query_id = '{}' SYNC", cluster, query_id ) } else { format!("KILL QUERY WHERE query_id = '{}' SYNC", query_id) }; return self.execute_simple(&query).await; } pub async fn execute_query(&self, database: &str, query: &str) -> Result<()> { self.execute_simple(&format!("USE {}", database)).await?; return self.execute_simple(query).await; } pub async fn explain_syntax( &self, database: &str, query: &str, settings: &HashMap, ) -> Result> { return self .explain("SYNTAX", database, query, Some(settings)) .await; } pub async fn explain_plan(&self, database: &str, query: &str) -> Result> { return self.explain("PLAN actions=1", database, query, None).await; } pub async fn explain_pipeline(&self, database: &str, query: &str) -> Result> { return self.explain("PIPELINE", database, query, None).await; } pub async fn explain_pipeline_graph(&self, database: &str, query: &str) -> Result> { return self .explain("PIPELINE graph=1", database, query, None) .await; } // NOTE: can we benefit from json=1? pub async fn explain_plan_indexes(&self, database: &str, query: &str) -> Result> { return self.explain("PLAN indexes=1", database, query, None).await; } pub async fn show_create_table(&self, database: &str, table: &str) -> Result { let result = self .execute(&format!("SHOW CREATE TABLE {}.{}", database, table)) .await?; let statement: String = collect_values(&result, "statement") .into_iter() .next() .unwrap_or_default(); return Ok(statement); } // TODO: copy all settings from the query async fn explain( &self, what: &str, database: &str, query: &str, settings: Option<&HashMap>, ) -> Result> { self.execute_simple(&format!("USE {}", database)).await?; if let Some(settings) = settings { // NOTE: it handles queries with SETTINGS incorrectly, i.e.: // // SELECT 1 SETTINGS max_threads=1 // // EXPLAIN SYNTAX SELECT 1 SETTINGS max_threads=1 SETTINGS max_threads=1, max_insert_threads=1 -> // SELECT 1 SETTINGS max_threads=1 // // This can be fixed two ways: // - in ClickHouse // - by passing settings in the protocol if !settings.is_empty() { return Ok(collect_values( &self .execute(&format!( "EXPLAIN {} {} SETTINGS {}", what, query, settings .iter() .map(|kv| format!("{}='{}'", kv.0, kv.1.replace('\'', "\\\'"))) .collect::>() .join(",") )) .await?, "explain", )); } } return Ok(collect_values( &self.execute(&format!("EXPLAIN {} {}", what, query)).await?, "explain", )); } pub async fn get_query_logs(&self, args: &TextLogArguments) -> Result { // TODO: // - optional flush, but right now it gives "blocks should not be empty." error // self.execute("SYSTEM FLUSH LOGS").await; // - configure time interval // // NOTE: // - we cannot use LIVE VIEW, since // a) they are pretty complex // b) it does not work in case we monitor the whole cluster let dbtable = self.get_log_table_name("system", "text_log"); let order = if self.options.logs_order == LogsOrder::Desc { "DESC" } else { "ASC" }; return self .execute( format!( r#" WITH fromUnixTimestamp64Nano({}) AS start_time_, {} AS end_time_ SELECT hostname AS host_name, event_time, event_time_microseconds, thread_id, level::String AS level, logger_name::String AS logger_name, query_id::String AS query_id, message FROM {} WHERE event_date >= toDate(start_time_) AND event_time >= toDateTime(start_time_) AND event_time_microseconds > start_time_ AND event_date <= toDate(end_time_) AND event_time <= toDateTime(end_time_) AND event_time_microseconds <= end_time_ {} {} {} {} {} ORDER BY event_date {order}, event_time {order}, event_time_microseconds {order} LIMIT {} "#, args.start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start time"))?, args.end.to_sql_datetime_64().ok_or(Error::msg("Invalid end time"))?, dbtable, if let Some(query_ids) = &args.query_ids { format!("AND query_id IN ('{}')", query_ids.join("','")) } else { "".into() }, if let Some(logger_names) = &args.logger_names { format!("AND ({})", logger_names.iter().map(|l| format!("logger_name LIKE '{}'", l)).collect::>().join(" OR ")) } else { "".into() }, if let Some(hostname) = &args.hostname { format!("AND (hostName() = '{0}' OR hostname = '{0}')", hostname.replace('\'', "''")) } else { "".into() }, if let Some(message_filter) = &args.message_filter { format!("AND message LIKE '%{}%'", message_filter) } else { "".into() }, if let Some(max_level) = &args.max_level { format!("AND level <= '{}'", max_level) } else { "".into() }, self.options.limit, ) .as_str(), ) .await; } /// Return query flamegraph in pyspy format for flameshow. /// It is the same format as TSV, but with ' ' delimiter between symbols and weight. pub async fn get_flamegraph( &self, trace_type: TraceType, query_ids: Option<&[String]>, start_microseconds: Option>, end_microseconds: Option>, selected_host: Option<&String>, ) -> Result { let dbtable = self.get_log_table_name("system", "trace_log"); let host_filter = self.get_log_host_filter_clause(selected_host); return self .execute(&format!( r#" WITH {} AS start_time_, {} AS end_time_ SELECT {} AS human_trace, {} weight FROM {} WHERE event_date >= toDate(start_time_) AND event_time >= toDateTime(start_time_) AND event_time_microseconds > start_time_ AND event_date <= toDate(end_time_) AND event_time <= toDateTime(end_time_) AND event_time_microseconds <= end_time_ AND trace_type = '{:?}' {} {} GROUP BY human_trace SETTINGS allow_introspection_functions=1 "#, match start_microseconds { Some(time) => format!( "fromUnixTimestamp64Nano({})", time.timestamp_nanos_opt() .ok_or(Error::msg("Invalid start time"))? ), None => "toDateTime64(now() - INTERVAL 1 HOUR, 6)".to_string(), }, match end_microseconds { Some(time) => format!( "fromUnixTimestamp64Nano({})", time.timestamp_nanos_opt() .ok_or(Error::msg("Invalid end time"))? ), None => "toDateTime64(now(), 6)".to_string(), }, if self.quirks.has(ClickHouseAvailableQuirks::TraceLogHasSymbols) { r#" if(empty(symbols), arrayStringConcat(arrayMap( addr -> demangle(addressToSymbol(addr)), arrayReverse(trace) ), ';'), arrayStringConcat(arrayReverse(symbols), ';') ) "# } else { r#" arrayStringConcat(arrayMap( addr -> demangle(addressToSymbol(addr)), arrayReverse(trace) ), ';') "# }, match trace_type { TraceType::Memory => "abs(sum(size))", TraceType::MemorySample => "abs(sum(size))", TraceType::JemallocSample => "abs(sum(size))", TraceType::MemoryAllocatedWithoutCheck => "abs(sum(size))", _ => "count()", }, dbtable, trace_type, if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }, host_filter, )) .await; } /// Return jemalloc flamegraph in pyspy format. /// It is the same format as TSV, but with ' ' delimiter between symbols and weight. pub async fn get_jemalloc_flamegraph(&self, selected_host: Option<&String>) -> Result { let dbtable = self.get_table_name("system", "jemalloc_profile_text"); let host_filter = if let Some(host) = selected_host { if !host.is_empty() && self.options.cluster.is_some() { format!("AND hostName() = '{}'", host.replace('\'', "''")) } else { String::new() } } else { String::new() }; return self .execute(&format!( r#" WITH splitByChar(' ', line) AS parts SELECT arrayStringConcat(arraySlice(parts, 1, -1), ' ') AS symbols, parts[-1]::UInt64 AS bytes FROM {} WHERE 1 {} SETTINGS jemalloc_profile_text_output_format='collapsed' "#, dbtable, host_filter, )) .await; } pub async fn get_live_query_flamegraph( &self, query_ids: &Option>, selected_host: Option<&String>, ) -> Result { let dbtable = self.get_table_name_no_history("system", "stack_trace"); let host_filter = self.get_host_filter_clause(selected_host); let where_clause = match (query_ids.as_ref(), host_filter.is_empty()) { (Some(v), true) => format!("query_id IN ('{}')", v.join("','")), (Some(v), false) => format!("query_id IN ('{}') {}", v.join("','"), host_filter), (None, false) => format!("1 {}", host_filter), (None, true) => "1".to_string(), }; return self .execute(&format!( r#" SELECT arrayStringConcat(arrayMap( addr -> demangle(addressToSymbol(addr)), arrayReverse(trace) ), ';') AS human_trace, count() weight FROM {} WHERE {} GROUP BY human_trace SETTINGS allow_introspection_functions=1 "#, dbtable, where_clause )) .await; } pub async fn get_background_schedule_pool_query_ids( &self, log_name: Option, database: String, table: String, start: RelativeDateTime, end: RelativeDateTime, selected_host: Option<&String>, ) -> Result> { let dbtable = self.get_log_table_name("system", "background_schedule_pool_log"); let start_sql = start .to_sql_datetime_64() .ok_or_else(|| Error::msg("Invalid start"))?; let end_sql = end .to_sql_datetime_64() .ok_or_else(|| Error::msg("Invalid end"))?; let host_filter = self.get_log_host_filter_clause(selected_host); let query = if let Some(ref log_name) = log_name { format!( r#" WITH {start} AS start_, {end} AS end_ SELECT DISTINCT query_id FROM {dbtable} WHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND log_name = '{log_name}' AND database = '{database}' AND table = '{table}' {host_filter} LIMIT 1000 "#, start = start_sql, end = end_sql, dbtable = dbtable, log_name = log_name.replace('\'', "''"), database = database.replace('\'', "''"), table = table.replace('\'', "''"), host_filter = host_filter, ) } else { format!( r#" WITH {start} AS start_, {end} AS end_ SELECT DISTINCT query_id FROM {dbtable} WHERE event_date BETWEEN toDate(start_) AND toDate(end_) AND event_time BETWEEN toDateTime(start_) AND toDateTime(end_) AND database = '{database}' AND table = '{table}' {host_filter} LIMIT 1000 "#, start = start_sql, end = end_sql, dbtable = dbtable, database = database.replace('\'', "''"), table = table.replace('\'', "''"), host_filter = host_filter, ) }; let columns = self.execute(&query).await?; let mut query_ids = Vec::new(); for i in 0..columns.row_count() { if let Ok(query_id) = columns.get::(i, "query_id") { query_ids.push(query_id); } } Ok(query_ids) } pub async fn get_otel_spans_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "opentelemetry_span_log"); let start_us = start.timestamp_micros(); let end_us = end.timestamp_micros(); let query_id_filter = if let Some(ids) = query_ids { format!( "AND attribute['clickhouse.query_id'] IN ('{}')", ids.join("','") ) } else { String::new() }; return self .execute(&format!( r#" SELECT operation_name, start_time_us, finish_time_us, attribute['clickhouse.query_id'] AS query_id, {host_expr} AS host_name FROM {dbtable} WHERE start_time_us BETWEEN {start_us} AND {end_us} {query_id_filter} ORDER BY start_time_us "#, dbtable = dbtable, start_us = start_us, end_us = end_us, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_trace_log_counters_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "trace_log"); let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT query_id, event, increment, event_time_microseconds, {host_expr} AS host_name FROM {dbtable} WHERE trace_type = 'ProfileEvent' AND increment != 0 {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_query_metrics_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result> { let dbtable = self.get_log_table_name("system", "query_metric_log"); let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; let block = self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT query_id, event_time_microseconds, memory_usage, peak_memory_usage, {host_expr} AS host_name, COLUMNS('ProfileEvent_') FROM {dbtable} WHERE 1 {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await?; let pe_columns: Vec = block .columns() .iter() .map(|c| c.name().to_string()) .filter(|name| name.starts_with("ProfileEvent_")) .collect(); let mut rows = Vec::with_capacity(block.row_count()); for i in 0..block.row_count() { let mut profile_events = HashMap::new(); for col in &pe_columns { let value: u64 = block.get(i, col.as_str()).unwrap_or(0); if value != 0 { let name = col.strip_prefix("ProfileEvent_").unwrap(); profile_events.insert(name.to_string(), value); } } let ts_ns = match block.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: query_metric_log row {} event_time_microseconds: {}", i, e ); continue; } }; rows.push(QueryMetricRow { host_name: block.get(i, "host_name").unwrap_or_default(), timestamp_ns: ts_ns, memory_usage: block.get(i, "memory_usage").unwrap_or(0), peak_memory_usage: block.get(i, "peak_memory_usage").unwrap_or(0), profile_events, }); } Ok(rows) } pub async fn get_part_log_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "part_log"); let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT event_type, event_time_microseconds, duration_ms, database, table, part_name, query_id, rows, size_in_bytes, {host_expr} AS host_name FROM {dbtable} WHERE event_type NOT IN ('MergePartsStart', 'MutatePartStart') {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_stack_traces_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "trace_log"); let symbol_expr = if self .quirks .has(ClickHouseAvailableQuirks::TraceLogHasSymbols) { r#"arrayReverse(if(empty(symbols), arrayMap(addr -> demangle(addressToSymbol(addr)), trace), symbols))"# } else { "arrayReverse(arrayMap(addr -> demangle(addressToSymbol(addr)), trace))" }; let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT event_time_microseconds, thread_id, trace_type::String AS trace_type, {symbol_expr} AS stack, size, query_id, {host_expr} AS host_name FROM {dbtable} WHERE trace_type IN ('CPU', 'Real', 'Memory') {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds SETTINGS allow_introspection_functions=1 "#, dbtable = dbtable, symbol_expr = symbol_expr, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_text_log_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "text_log"); let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT event_time_microseconds, level::String AS level, logger_name::String AS logger_name, message, query_id, {host_expr} AS host_name FROM {dbtable} WHERE 1 {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_query_thread_log_for_perfetto( &self, query_ids: Option<&[String]>, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "query_thread_log"); let query_id_filter = if let Some(ids) = query_ids { format!("AND query_id IN ('{}')", ids.join("','")) } else { String::new() }; return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT query_id, thread_name, event_time_microseconds, query_duration_ms, ProfileEvents.Names, ProfileEvents.Values, peak_memory_usage, {host_expr} AS host_name FROM {dbtable} WHERE 1 {query_id_filter} AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, query_id_filter = query_id_filter, host_expr = self.get_log_hostname_column(), )) .await; } pub async fn get_queries_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "query_log"); return self .execute( format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT ProfileEvents.Names, ProfileEvents.Values, Settings.Names, Settings.Values, {peak_threads_usage} AS peak_threads_usage, memory_usage::Int64 AS peak_memory_usage, query_duration_ms/1e3 AS elapsed, user, is_initial_query, initial_query_id, query_id, hostname as host_name, current_database, query_start_time_microseconds, event_time_microseconds AS query_end_time_microseconds, toValidUTF8(query) AS original_query, normalizeQuery(query) AS normalized_query FROM {dbtable} WHERE type != 'QueryStart' AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) "#, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, dbtable = dbtable, peak_threads_usage = if self .quirks .has(ClickHouseAvailableQuirks::QueryLogPeakThreadsUsage) { "peak_threads_usage" } else { "length(thread_ids)" }, ) .as_str(), ) .await; } pub async fn get_metric_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result> { let dbtable = self.get_log_table_name("system", "metric_log"); let block = self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT event_time_microseconds, COLUMNS('ProfileEvent_'), COLUMNS('CurrentMetric_') FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await?; let pe_columns: Vec = block .columns() .iter() .map(|c| c.name().to_string()) .filter(|name| name.starts_with("ProfileEvent_")) .collect(); let cm_columns: Vec = block .columns() .iter() .map(|c| c.name().to_string()) .filter(|name| name.starts_with("CurrentMetric_")) .collect(); let mut rows = Vec::with_capacity(block.row_count()); for i in 0..block.row_count() { let ts_ns = match block.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: metric_log row {} event_time_microseconds: {}", i, e ); continue; } }; let mut profile_events = HashMap::new(); for col in &pe_columns { let value: u64 = block.get(i, col.as_str()).unwrap_or(0); if value != 0 { let name = col.strip_prefix("ProfileEvent_").unwrap(); profile_events.insert(name.to_string(), value); } } let mut current_metrics = HashMap::new(); for col in &cm_columns { let value: i64 = block.get(i, col.as_str()).unwrap_or(0); if value != 0 { let name = col.strip_prefix("CurrentMetric_").unwrap(); current_metrics.insert(name.to_string(), value); } } rows.push(MetricLogRow { timestamp_ns: ts_ns, profile_events, current_metrics, }); } Ok(rows) } pub async fn get_asynchronous_metric_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "asynchronous_metric_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT metric, value, event_time_microseconds FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_asynchronous_insert_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "asynchronous_insert_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT database, table, format, status, bytes, exception, event_time_microseconds, flush_time_microseconds, query_id FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_error_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "error_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT error, code, value, remote, last_error_message, event_time FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_s3_queue_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "s3queue_log"); return self .execute(&format!( r#" SELECT file_name, rows_processed, status, processing_start_time, processing_end_time, exception FROM {dbtable} WHERE processing_start_time >= toDateTime(fromUnixTimestamp64Nano({start})) AND processing_start_time <= toDateTime(fromUnixTimestamp64Nano({end})) ORDER BY processing_start_time "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_azure_queue_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "azure_queue_log"); return self .execute(&format!( r#" SELECT database, table, file_name, rows_processed, status, processing_start_time, processing_end_time, exception FROM {dbtable} WHERE processing_start_time >= toDateTime(fromUnixTimestamp64Nano({start})) AND processing_start_time <= toDateTime(fromUnixTimestamp64Nano({end})) ORDER BY processing_start_time "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_blob_storage_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "blob_storage_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT event_type, query_id, disk_name, bucket, remote_path, data_size, error, event_time_microseconds FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_background_schedule_pool_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "background_schedule_pool_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT log_name, database, table, query_id, duration_ms, error, exception, event_time_microseconds FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_session_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "session_log"); return self .execute(&format!( r#" WITH fromUnixTimestamp64Nano({start}) AS start_, fromUnixTimestamp64Nano({end}) AS end_ SELECT type::String AS type, user, auth_type::String AS auth_type, interface::String AS interface, toString(client_address) AS client_address, client_name, failure_reason, event_time_microseconds FROM {dbtable} WHERE 1 AND event_date >= toDate(start_) AND event_time >= toDateTime(start_) AND event_date <= toDate(end_) AND event_time <= toDateTime(end_) ORDER BY event_time_microseconds "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_aggregated_zookeeper_log_for_perfetto( &self, start: DateTime, end: DateTime, ) -> Result { let dbtable = self.get_log_table_name("system", "aggregated_zookeeper_log"); return self .execute(&format!( r#" SELECT event_time, session_id, parent_path, operation::String AS operation, count, mapKeys(errors) AS error_names, mapValues(errors) AS error_counts, average_latency, component FROM {dbtable} WHERE event_time >= toDateTime(fromUnixTimestamp64Nano({start})) AND event_time <= toDateTime(fromUnixTimestamp64Nano({end})) ORDER BY event_time "#, dbtable = dbtable, start = start .timestamp_nanos_opt() .ok_or(Error::msg("Invalid start"))?, end = end.timestamp_nanos_opt().ok_or(Error::msg("Invalid end"))?, )) .await; } pub async fn get_warnings(&self) -> Result> { let table_exists: u64 = self .execute( "SELECT count() FROM system.tables WHERE database = 'system' AND name = 'warnings'", ) .await? .get(0, "count()")?; if table_exists == 0 { return Ok(Vec::new()); } let block = self.execute("SELECT message FROM system.warnings").await?; let warnings: Vec = collect_values(&block, "message"); let filtered: Vec = warnings .into_iter() .filter(|w| !w.contains("transparent_hugepage") && !w.starts_with("Obsolete settings")) .collect(); Ok(filtered) } pub async fn execute(&self, query: &str) -> Result { let columns = self .pool .get_handle() .await? .query(query) .fetch_all() .await?; log::trace!("Received {} rows for query: {}", columns.row_count(), query); Ok(columns) } async fn execute_simple(&self, query: &str) -> Result<()> { let mut client = self.pool.get_handle().await?; let mut stream = client.query(query).stream_blocks(); let ret = stream.next().await; if let Some(Err(err)) = ret { return Err(Error::new(err)); } else { return Ok(()); } } pub async fn get_cluster_hosts(&self) -> Result> { let cluster = self.options.cluster.clone().unwrap_or_default(); if cluster.is_empty() { return Ok(Vec::new()); } let query = format!( "SELECT DISTINCT hostName() AS host FROM clusterAllReplicas('{}', system.one) ORDER BY host", cluster ); let columns = self.execute(&query).await?; let mut hosts = Vec::new(); for i in 0..columns.row_count() { if let Ok(host) = columns.get::(i, "host") { hosts.push(host); } } Ok(hosts) } pub fn get_host_filter_clause(&self, selected_host: Option<&String>) -> String { if let Some(host) = selected_host && !host.is_empty() && self.options.cluster.is_some() { return format!("AND hostName() = '{}'", host.replace('\'', "''")); } String::new() } // Filter for system.*_log reads. Without clusterAllReplicas(), hostName() collapses to the // executor node, so we match on the persisted `hostname` column instead. pub fn get_log_host_filter_clause(&self, selected_host: Option<&String>) -> String { if let Some(host) = selected_host && !host.is_empty() && self.options.cluster.is_some() { let col = if self.shared_log_pipeline { "hostname" } else { "hostName()" }; return format!("AND {} = '{}'", col, host.replace('\'', "''")); } String::new() } // SELECT-side hostname expression for system.*_log reads. Pairs with get_log_host_filter_clause. pub fn get_log_hostname_column(&self) -> &'static str { if self.shared_log_pipeline { "hostname" } else { "hostName()" } } pub fn get_table_name(&self, database: &str, table: &str) -> String { let cluster = self.options.cluster.clone().unwrap_or_default(); let history = self.options.history; return match (history, cluster.is_empty()) { (false, true) => format!("{}.{}", database, table), (true, false) => format!( "clusterAllReplicas('{}', merge('{}', '^{}'))", cluster, database, table ), (true, true) => format!("merge('{}', '^{}')", database, table), (false, false) => format!( "clusterAllReplicas('{}', '{}', '{}')", cluster, database, table ), }; } // Variant for system.*_log tables. With use_shared_merge_tree_log_pipeline we can skip // clusterAllReplicas() entirely — a single replica observes the whole cluster's rows. pub fn get_log_table_name(&self, database: &str, table: &str) -> String { if self.shared_log_pipeline { let history = self.options.history; return if history { format!("merge('{}', '^{}')", database, table) } else { format!("{}.{}", database, table) }; } self.get_table_name(database, table) } pub fn get_table_name_no_history(&self, database: &str, table: &str) -> String { let cluster = self.options.cluster.clone().unwrap_or_default(); return match cluster.is_empty() { true => format!("{}.{}", database, table), false => format!( "clusterAllReplicas('{}', '{}', '{}')", cluster, database, table ), }; } } ================================================ FILE: src/interpreter/clickhouse_quirks.rs ================================================ use semver::{Version, VersionReq}; #[derive(Debug, Clone, Copy)] pub enum ClickHouseAvailableQuirks { ProcessesElapsed = 1, ProcessesCurrentDatabase = 2, AsynchronousMetricsTotalIndexGranularityBytesInMemoryAllocated = 3, TraceLogHasSymbols = 4, SystemReplicasUUID = 8, QueryLogPeakThreadsUsage = 16, ProcessesPeakThreadsUsage = 32, SystemBackgroundSchedulePool = 64, } // List of quirks (that requires workaround) or new features. const QUIRKS: [(&str, ClickHouseAvailableQuirks); 8] = [ // https://github.com/ClickHouse/ClickHouse/pull/46047 // // NOTE: I use here 22.13 because I have such version in production, which is more or less the // same as 23.1 ( ">=22.13, <23.2", ClickHouseAvailableQuirks::ProcessesElapsed, ), // https://github.com/ClickHouse/ClickHouse/pull/22365 ("<21.4", ClickHouseAvailableQuirks::ProcessesCurrentDatabase), // https://github.com/ClickHouse/ClickHouse/pull/80861 ( ">=24.11, <25.6", ClickHouseAvailableQuirks::AsynchronousMetricsTotalIndexGranularityBytesInMemoryAllocated, ), (">=25.1", ClickHouseAvailableQuirks::TraceLogHasSymbols), (">=25.11", ClickHouseAvailableQuirks::SystemReplicasUUID), // peak_threads_usage is available in system.query_log since 23.8 ( ">=23.8", ClickHouseAvailableQuirks::QueryLogPeakThreadsUsage, ), // peak_threads_usage is available in system.processes since 25.11 ( ">=25.11", ClickHouseAvailableQuirks::ProcessesPeakThreadsUsage, ), // peak_threads_usage is available in system.processes since 25.11 ( ">=25.12", ClickHouseAvailableQuirks::SystemBackgroundSchedulePool, ), ]; pub struct ClickHouseQuirks { // Return more verbose version for the UI version_string: String, mask: u64, } // Custom matcher, that will properly handle prerelease. // https://github.com/dtolnay/semver/issues/323#issuecomment-2432169904 fn version_matches(version: &semver::Version, req: &semver::VersionReq) -> bool { if req.matches(version) { return true; } // This custom matching logic is needed, because semver cannot compare different version with pre-release tags let mut version_without_pre = version.clone(); version_without_pre.pre = "".parse().unwrap(); for comp in &req.comparators { if comp.matches(version) { continue; } // If major & minor & patch are the same (or omitted), // this means there is a mismatch on the pre-release tag if comp.major == version.major && comp.minor.is_none_or(|m| m == version.minor) && comp.patch.is_none_or(|p| p == version.patch) { return false; } // Otherwise, compare without pre-release tags let mut comp_without_pre = comp.clone(); comp_without_pre.pre = "".parse().unwrap(); if !comp_without_pre.matches(&version_without_pre) { return false; } } true } impl ClickHouseQuirks { pub fn new(version_string: String) -> Self { // Version::parse() supports only x.y.z and nothing more, but we don't need anything more, // only .minor may include new features. let components = version_string .strip_prefix('v') .unwrap_or(&version_string) .split('.') .collect::>(); let mut ver_maj_min_patch_pre = components[0..3].join("."); let version_pre = components.last().unwrap_or(&"-testing"); if !version_pre.ends_with("-stable") { log::warn!( "Non-stable version detected ({}), treating as older/development version", version_string ); ver_maj_min_patch_pre.push_str(&format!( "-{}", version_pre .split('-') .collect::>() .last() .unwrap_or(&"alpha") )); } log::debug!("Version (maj.min.patch.pre): {}", ver_maj_min_patch_pre); let version = Version::parse(ver_maj_min_patch_pre.as_str()) .unwrap_or_else(|_| panic!("Cannot parse version: {}", ver_maj_min_patch_pre)); log::debug!("Version: {}", version); let mut mask: u64 = 0; for quirk in &QUIRKS { let version_requirement = VersionReq::parse(quirk.0) .unwrap_or_else(|_| panic!("Cannot parse version requirements for {:?}", quirk.1)); if version_matches(&version, &version_requirement) { mask |= quirk.1 as u64; log::warn!("Apply quirk {:?}", quirk.1); } } return Self { version_string, mask, }; } pub fn get_version(&self) -> String { return self.version_string.clone(); } pub fn has(&self, quirk: ClickHouseAvailableQuirks) -> bool { return (self.mask & quirk as u64) != 0; } } #[cfg(test)] mod tests { use super::*; #[test] fn test_stable_version() { let quirks = ClickHouseQuirks::new("25.11.1.1-stable".to_string()); assert_eq!(quirks.get_version(), "25.11.1.1-stable"); assert!(quirks.has(ClickHouseAvailableQuirks::SystemReplicasUUID)); assert!(quirks.has(ClickHouseAvailableQuirks::ProcessesPeakThreadsUsage)); assert!(quirks.has(ClickHouseAvailableQuirks::TraceLogHasSymbols)); } #[test] fn test_testing_version() { let quirks = ClickHouseQuirks::new("25.11.1.1-testing".to_string()); assert_eq!(quirks.get_version(), "25.11.1.1-testing"); assert!(!quirks.has(ClickHouseAvailableQuirks::SystemReplicasUUID)); assert!(!quirks.has(ClickHouseAvailableQuirks::ProcessesPeakThreadsUsage)); } #[test] fn test_next_testing_prerelease_version() { let quirks = ClickHouseQuirks::new("25.12.1.1-testing".to_string()); assert_eq!(quirks.get_version(), "25.12.1.1-testing"); assert!(quirks.has(ClickHouseAvailableQuirks::SystemReplicasUUID)); assert!(quirks.has(ClickHouseAvailableQuirks::ProcessesPeakThreadsUsage)); } #[test] fn test_version_with_v_prefix() { let quirks = ClickHouseQuirks::new("v25.11.1.1-stable".to_string()); assert_eq!(quirks.get_version(), "v25.11.1.1-stable"); assert!(quirks.has(ClickHouseAvailableQuirks::SystemReplicasUUID)); } // Here are the tests only for version_matches(), in other aspects we are relying on semver tests } ================================================ FILE: src/interpreter/context.rs ================================================ use crate::actions::ActionDescription; use crate::interpreter::{ ClickHouse, Worker, debug_metrics::DebugMetrics, options::{ChDigOptions, ChDigViews}, perfetto::PerfettoServer, }; use anyhow::Result; use chrono::Duration; use cursive::{Cursive, View, event::Event, event::EventResult, views::Dialog, views::OnEventView}; use std::sync::{Arc, Condvar, Mutex, atomic}; pub type ContextArc = Arc>; type GlobalActionCallback = Arc>; pub struct GlobalAction { pub description: ActionDescription, pub callback: GlobalActionCallback, } type ViewActionCallback = Arc Result> + Send + Sync>>; pub struct ViewAction { pub description: ActionDescription, pub callback: ViewActionCallback, } pub struct Context { pub options: ChDigOptions, pub clickhouse: Arc, pub server_version: String, pub worker: Worker, pub background_runner_cv: Arc<(Mutex<()>, Condvar)>, pub background_runner_force: Arc, pub background_runner_summary_force: Arc, pub cb_sink: cursive::CbSink, pub global_actions: Vec, pub views_menu_actions: Vec, pub view_actions: Vec, pub pending_view_callback: Option, pub view_registry: crate::view::ViewRegistry, pub search_history: crate::view::search_history::SearchHistory, pub selected_host: Option, pub current_view: Option, pub perfetto_server: Option>, pub queries_filter: Arc>, pub queries_limit: Arc>, pub debug_metrics: Arc, } impl Context { pub async fn new( options: ChDigOptions, clickhouse: Arc, cb_sink: cursive::CbSink, ) -> Result { let server_version = clickhouse.version(); let debug_metrics = DebugMetrics::new(); let worker = Worker::new(); let background_runner_cv = Arc::new((Mutex::new(()), Condvar::new())); let background_runner_force = Arc::new(atomic::AtomicBool::new(false)); let background_runner_summary_force = Arc::new(atomic::AtomicBool::new(false)); let view_registry = crate::view::ViewRegistry::new(); let queries_filter = Arc::new(Mutex::new(String::new())); let queries_limit = Arc::new(Mutex::new(options.view.queries_limit)); // Metrics are always collected; display is toggled with `!`. The refresh thread // sleeps when hidden, so this is free when unused. debug_metrics.spawn_refresh(cb_sink.clone(), std::time::Duration::from_millis(500)); let context = Arc::new(Mutex::new(Context { options, clickhouse, server_version, worker, background_runner_cv, background_runner_force, background_runner_summary_force, cb_sink, global_actions: Vec::new(), views_menu_actions: Vec::new(), view_actions: Vec::new(), pending_view_callback: None, view_registry, search_history: crate::view::search_history::SearchHistory::new(), selected_host: None, current_view: None, perfetto_server: None, queries_filter, queries_limit, debug_metrics, })); context.lock().unwrap().worker.start(context.clone()); return Ok(context); } pub fn add_global_action( &mut self, siv: &mut Cursive, text: &'static str, event: E, cb: F, ) where F: Fn(&mut Cursive) + Send + Sync + Copy + 'static, E: Into, { let event = event.into(); let action = GlobalAction { description: ActionDescription { text, event }, callback: Arc::new(Box::new(cb)), }; siv.add_global_callback(action.description.event.clone(), cb); self.global_actions.push(action); } pub fn add_global_action_without_shortcut( &mut self, siv: &mut Cursive, text: &'static str, cb: F, ) where F: Fn(&mut Cursive) + Send + Sync + Copy + 'static, { return self.add_global_action(siv, text, Event::Unknown(Vec::from([0u8])), cb); } pub fn add_view(&mut self, text: &'static str, cb: F) where F: Fn(&mut Cursive) + Send + Sync + 'static, { let action = GlobalAction { description: ActionDescription { text, event: Event::Unknown(Vec::from([0u8])), }, callback: Arc::new(Box::new(cb)), }; self.views_menu_actions.push(action); } pub fn register_provider(&mut self, provider: Arc) { let name = provider.name(); self.view_registry.register(provider); self.add_view(name, move |siv| { let context = siv.user_data::().unwrap().clone(); let provider = context.lock().unwrap().view_registry.get(name); { let mut ctx = context.lock().unwrap(); ctx.current_view = Some(provider.view_type()); } provider.show(siv, context.clone()); }); } pub fn add_view_action( &mut self, view: &mut OnEventView, text: &'static str, event: E, cb: F, ) where F: Fn(&mut dyn View) -> Result> + Send + Sync + Copy + 'static, E: Into, V: View, { let event = event.into(); let action = ViewAction { description: ActionDescription { text, event }, callback: Arc::new(Box::new(cb)), }; let event = action.description.event.clone(); let cb = action.callback.clone(); view.set_on_event_inner(event, move |sub_view, _event| { let result = cb.as_ref()(sub_view); match result { Err(err) => { return Some(EventResult::with_cb_once(move |siv: &mut Cursive| { siv.add_layer(Dialog::info(err.to_string())); })); } Ok(event) => return event, } }); self.view_actions.push(action); } pub fn add_view_action_without_shortcut( &mut self, view: &mut OnEventView, text: &'static str, cb: F, ) where F: Fn(&mut dyn View) -> Result> + Send + Sync + Copy + 'static, V: View, { return self.add_view_action(view, text, Event::Unknown(Vec::from([0u8])), cb); } pub fn get_or_start_perfetto_server(&mut self) -> Arc { if let Some(ref server) = self.perfetto_server { return server.clone(); } let server = Arc::new(PerfettoServer::new()); self.perfetto_server = Some(server.clone()); server } pub fn trigger_view_refresh(&self) { self.background_runner_force .store(true, atomic::Ordering::SeqCst); self.background_runner_summary_force .store(true, atomic::Ordering::SeqCst); self.background_runner_cv.1.notify_all(); } pub fn shift_time_interval(&mut self, is_sub: bool, minutes: i64) { let new_start = &mut self.options.view.start; let new_end = &mut self.options.view.end; if is_sub { *new_start -= Duration::try_minutes(minutes).unwrap(); *new_end -= Duration::try_minutes(minutes).unwrap(); log::debug!( "Set time frame to ({}, {}) ({} minutes backward)", new_start, new_end, minutes ); } else { *new_start += Duration::try_minutes(minutes).unwrap(); *new_end += Duration::try_minutes(minutes).unwrap(); log::debug!( "Set time frame to ({}, {}) ({} minutes forward)", new_start, new_end, minutes ); } } } ================================================ FILE: src/interpreter/debug_metrics.rs ================================================ //! Internal chdig observability counters, rendered into the status bar when toggled with `!`. //! //! Metrics are recorded unconditionally — the cost is two atomic ops per worker event plus a //! lock-and-push on a ~256-entry ring buffer. Display is gated on a toggle flag: when off //! the refresh thread sleeps and does not ping the event loop, so there is no UI cost either. //! //! Picks: //! - Nearest-rank percentile over a fixed-size [`Histogram`] (O(N log N) per snapshot, //! N≤256). Simpler than an online estimator (t-digest, HDR histogram) and accurate enough //! for a status bar at a few Hz. //! - Event-loop latency is measured as a `cb_sink` round-trip, not frame render time. //! Cursive does not expose per-frame hooks; round-trip drift is the quantity the user //! actually perceives as "responsiveness". Tracked as a histogram (not a single latest //! value) so transient spikes don't get hidden behind whatever the most recent ping saw. //! - [`InFlightGuard`] is an RAII guard so early returns and panics in the worker cannot //! leak the counter. use std::collections::VecDeque; use std::fmt; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use cursive::CbSink; const SAMPLES_CAPACITY: usize = 256; /// Fixed-capacity ring-buffer histogram over `Duration` samples. Thread-safe via an /// internal `Mutex` — contention is negligible at the rates we record (≤ a few Hz). pub struct Histogram { samples: Mutex>, } impl Histogram { fn new() -> Self { Histogram { samples: Mutex::new(VecDeque::with_capacity(SAMPLES_CAPACITY)), } } pub fn record(&self, d: Duration) { let mut s = self.samples.lock().unwrap(); if s.len() == SAMPLES_CAPACITY { s.pop_front(); } s.push_back(d); } /// Nearest-rank (p50, p90, p99). Returns zeros on an empty histogram. pub fn percentiles(&self) -> (Duration, Duration, Duration) { let s = self.samples.lock().unwrap(); if s.is_empty() { return (Duration::ZERO, Duration::ZERO, Duration::ZERO); } let mut v: Vec = s.iter().copied().collect(); v.sort_unstable(); (percentile(&v, 50), percentile(&v, 90), percentile(&v, 99)) } } pub struct DebugMetrics { shown: AtomicBool, in_flight: AtomicU64, /// `cb_sink` round-trip latency — proxy for "how responsive does chdig feel". ui_lag: Histogram, /// Per-worker-event processing duration (a worker event is one ClickHouse query / /// action chdig issued). event: Histogram, } #[must_use = "Drop decrements the in-flight counter; hold this for the duration of work"] pub struct InFlightGuard(Arc); impl Drop for InFlightGuard { fn drop(&mut self) { self.0.in_flight.fetch_sub(1, Ordering::Relaxed); } } impl DebugMetrics { pub fn new() -> Arc { Arc::new(DebugMetrics { shown: AtomicBool::new(false), in_flight: AtomicU64::new(0), ui_lag: Histogram::new(), event: Histogram::new(), }) } pub fn is_shown(&self) -> bool { self.shown.load(Ordering::Relaxed) } /// Flips visibility and returns the new state. pub fn toggle_shown(&self) -> bool { !self.shown.fetch_xor(true, Ordering::Relaxed) } pub fn track_in_flight(self: &Arc) -> InFlightGuard { self.in_flight.fetch_add(1, Ordering::Relaxed); InFlightGuard(Arc::clone(self)) } pub fn record_event(&self, d: Duration) { self.event.record(d); } pub fn record_ui_lag(&self, d: Duration) { self.ui_lag.record(d); } pub fn snapshot(&self) -> MetricsSnapshot { let (lag_p50, lag_p90, lag_p99) = self.ui_lag.percentiles(); let (evt_p50, evt_p90, evt_p99) = self.event.percentiles(); MetricsSnapshot { in_flight: self.in_flight.load(Ordering::Relaxed), lag_p50, lag_p90, lag_p99, evt_p50, evt_p90, evt_p99, } } /// Spawn a background thread that, *while visibility is on*, probes event-loop lag /// via a `cb_sink` round-trip and pushes the latest snapshot into the status bar. /// When visibility is off the thread sleeps, so the hidden cost is just a dormant /// thread (no cb_sink traffic, no redraws). Exits when the sink is closed. pub fn spawn_refresh(self: &Arc, cb_sink: CbSink, interval: Duration) { let metrics = Arc::clone(self); thread::Builder::new() .name("chdig-debug-metrics".into()) .spawn(move || refresh_loop(metrics, cb_sink, interval)) .expect("spawn chdig-debug-metrics"); } } fn refresh_loop(metrics: Arc, cb_sink: CbSink, interval: Duration) { loop { thread::sleep(interval); if !metrics.is_shown() { continue; } let sent_at = Instant::now(); let metrics = Arc::clone(&metrics); let send_result = cb_sink.send(Box::new(move |siv: &mut cursive::Cursive| { metrics.record_ui_lag(sent_at.elapsed()); let text = metrics.snapshot().to_string(); crate::view::Navigation::set_statusbar_debug(siv, text); })); if send_result.is_err() { break; } } } #[derive(Default, Clone, Copy)] pub struct MetricsSnapshot { pub in_flight: u64, pub lag_p50: Duration, pub lag_p90: Duration, pub lag_p99: Duration, pub evt_p50: Duration, pub evt_p90: Duration, pub evt_p99: Duration, } impl fmt::Display for MetricsSnapshot { /// Status-bar line; written to be readable without a legend: /// * `UI lag` – cb_sink round-trip percentiles (event loop responsiveness) /// * `Active` – worker events currently being processed /// * `Event` – worker-event processing-time percentiles (one per ClickHouse query) /// /// All triples are `p50/p90/p99`, nearest-rank over the last [`SAMPLES_CAPACITY`] /// samples of each kind. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "UI lag p50/p90/p99: {}/{}/{} ms Active: {} Event p50/p90/p99: {}/{}/{} ms", self.lag_p50.as_millis(), self.lag_p90.as_millis(), self.lag_p99.as_millis(), self.in_flight, self.evt_p50.as_millis(), self.evt_p90.as_millis(), self.evt_p99.as_millis(), ) } } /// Nearest-rank percentile; q ∈ 0..=100. Undefined on an empty slice — callers must guard. fn percentile(sorted: &[T], q: u32) -> T { debug_assert!(q <= 100); debug_assert!(!sorted.is_empty()); let rank = (q as usize * sorted.len()).div_ceil(100).max(1); sorted[rank - 1] } #[cfg(test)] mod tests { use super::*; #[test] fn percentile_integer_ranks() { let v: Vec = (1..=10).collect(); assert_eq!(percentile(&v, 50), 5); assert_eq!(percentile(&v, 90), 9); assert_eq!(percentile(&v, 99), 10); assert_eq!(percentile(&v, 100), 10); } #[test] fn percentile_single_element() { assert_eq!(percentile(&[42u64], 50), 42); assert_eq!(percentile(&[42u64], 99), 42); } #[test] fn histogram_caps_at_capacity() { let h = Histogram::new(); // Feed monotonic samples well past capacity and assert that the p99 reflects // only the most recent SAMPLES_CAPACITY values (earliest ones were evicted). let total = SAMPLES_CAPACITY + 50; for i in 0..total { h.record(Duration::from_millis(i as u64)); } let (_p50, _p90, p99) = h.percentiles(); // Oldest retained = total - SAMPLES_CAPACITY = 50; newest = total - 1 = 305. // Nearest-rank p99: rank = ceil(99 * 256 / 100) = 254; value = 50 + (254-1) = 303. assert_eq!(p99, Duration::from_millis(303)); } #[test] fn histogram_empty_returns_zero() { let h = Histogram::new(); assert_eq!( h.percentiles(), (Duration::ZERO, Duration::ZERO, Duration::ZERO) ); } #[test] fn ui_lag_and_event_are_independent() { let m = DebugMetrics::new(); m.record_ui_lag(Duration::from_millis(5)); m.record_event(Duration::from_millis(500)); let s = m.snapshot(); assert_eq!(s.lag_p50, Duration::from_millis(5)); assert_eq!(s.evt_p50, Duration::from_millis(500)); } #[test] fn in_flight_guard_is_raii() { let m = DebugMetrics::new(); assert_eq!(m.snapshot().in_flight, 0); let g1 = m.track_in_flight(); let g2 = m.track_in_flight(); assert_eq!(m.snapshot().in_flight, 2); drop(g1); assert_eq!(m.snapshot().in_flight, 1); drop(g2); assert_eq!(m.snapshot().in_flight, 0); } #[test] fn toggle_shown_returns_new_state() { let m = DebugMetrics::new(); assert!(!m.is_shown()); assert!(m.toggle_shown()); assert!(m.is_shown()); assert!(!m.toggle_shown()); assert!(!m.is_shown()); } #[test] fn display_format_is_readable() { let s = MetricsSnapshot { in_flight: 3, lag_p50: Duration::from_millis(1), lag_p90: Duration::from_millis(4), lag_p99: Duration::from_millis(12), evt_p50: Duration::from_millis(12), evt_p90: Duration::from_millis(87), evt_p99: Duration::from_millis(420), }; let rendered = s.to_string(); assert!(rendered.contains("UI lag p50/p90/p99: 1/4/12 ms")); assert!(rendered.contains("Active: 3")); assert!(rendered.contains("Event p50/p90/p99: 12/87/420 ms")); } } ================================================ FILE: src/interpreter/flamegraph.rs ================================================ use crate::interpreter::clickhouse::Columns; use crate::pastila; use anyhow::{Error, Result}; use crossterm::event::{self, Event as CrosstermEvent, KeyEventKind}; use flamelens::app::{App, AppResult}; use flamelens::flame::FlameGraph; use flamelens::handler::handle_key_events; use flamelens::ui; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use std::io; pub fn block_to_folded(block: &Columns) -> String { block .rows() .map(|x| { [ x.get::(0).unwrap(), x.get::(1).unwrap().to_string(), ] .join(" ") }) .collect::>() .join("\n") } fn run_flamelens(mut app: App) -> AppResult<()> { let backend = CrosstermBackend::new(io::stderr()); let mut terminal = Terminal::new(backend)?; let timeout = std::time::Duration::from_secs(1); terminal.clear()?; // Start the main loop. while app.running { terminal.draw(|frame| { ui::render(&mut app, frame); if let Some(input_buffer) = &app.input_buffer && let Some(cursor) = input_buffer.cursor { frame.set_cursor_position((cursor.0, cursor.1)); } })?; // FIXME: note, right now I cannot use EventHandle with Tui, since EventHandle is not // terminated gracefully if event::poll(timeout).expect("failed to poll new events") { match event::read().expect("unable to read event") { CrosstermEvent::Key(e) => { if e.kind == KeyEventKind::Press { handle_key_events(e, &mut app)? } } CrosstermEvent::Mouse(_e) => {} CrosstermEvent::Resize(_w, _h) => {} CrosstermEvent::FocusGained => {} CrosstermEvent::FocusLost => {} CrosstermEvent::Paste(_) => {} } } } terminal.clear()?; // ratatui's Terminal::drop may shows the cursor, re-hide it for cursive drop(terminal); crossterm::execute!(io::stderr(), crossterm::cursor::Hide)?; Ok(()) } pub fn show(title: &'static str, data: String) -> AppResult<()> { if data.trim().is_empty() { return Err(Error::msg("Flamegraph is empty").into()); } let flamegraph = FlameGraph::from_string(data, true); run_flamelens(App::with_flamegraph(title, flamegraph)) } /// Show a differential flamegraph: `after` rendered with per-frame coloring /// against the `before` baseline (handled by flamelens's `diff_mode`). pub fn show_diff(title: &'static str, before: String, after: String) -> AppResult<()> { if before.trim().is_empty() && after.trim().is_empty() { return Err(Error::msg("Flamegraph diff is empty (both queries have no samples)").into()); } let before_fg = FlameGraph::from_string(before, true); let mut after_fg = FlameGraph::from_string(after, true); after_fg.set_diff_against(&before_fg); run_flamelens(App::with_flamegraph(title, after_fg)) } pub async fn share( data: String, pastila_clickhouse_host: &str, pastila_url: &str, ) -> Result { if data.trim().is_empty() { return Err(Error::msg("Flamegraph is empty")); } let pastila_url = pastila::upload_encrypted(&data, pastila_clickhouse_host, pastila_url).await?; return Ok(format!("https://whodidit.you/#profileURL={}", pastila_url)); } ================================================ FILE: src/interpreter/mod.rs ================================================ // pub for clickhouse::Columns mod background_runner; pub mod clickhouse; mod clickhouse_quirks; mod context; pub mod debug_metrics; mod query; mod worker; // only functions pub mod flamegraph; pub mod options; pub mod perfetto; pub use clickhouse::ClickHouse; pub use clickhouse::TextLogArguments; pub use clickhouse_quirks::ClickHouseAvailableQuirks; pub use clickhouse_quirks::ClickHouseQuirks; pub use context::Context; pub use context::ContextArc; pub use worker::Worker; pub type WorkerEvent = worker::Event; pub type Query = query::Query; pub type BackgroundRunner = background_runner::BackgroundRunner; ================================================ FILE: src/interpreter/options.rs ================================================ use crate::common::RelativeDateTime; use anyhow::{Result, anyhow}; use clap::{ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum, builder::ArgPredicate}; use clap_complete::{Shell, generate}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use quick_xml::de::Deserializer as XmlDeserializer; use serde::Deserialize; use serde_yaml::Deserializer as YamlDeserializer; use std::collections::HashMap; use std::env; use std::ffi::OsString; use std::fs; use std::io; use std::net::{SocketAddr, ToSocketAddrs}; use std::path; use std::process; use std::str::FromStr; use std::time; #[derive(Deserialize, Debug, PartialEq)] struct ClickHouseClientConfigOpenSSLClient { #[serde(rename = "verificationMode")] verification_mode: Option, #[serde(rename = "certificateFile")] certificate_file: Option, #[serde(rename = "privateKeyFile")] private_key_file: Option, #[serde(rename = "caConfig")] ca_config: Option, } #[derive(Deserialize, Debug, PartialEq)] struct ClickHouseClientConfigOpenSSL { client: Option, } #[derive(Deserialize, Debug, PartialEq)] struct ClickHouseClientConfigConnectionsCredentials { name: String, hostname: Option, port: Option, user: Option, password: Option, secure: Option, // chdig analog for accept_invalid_certificate skip_verify: Option, #[serde(rename = "accept-invalid-certificate")] accept_invalid_certificate: Option, ca_certificate: Option, client_certificate: Option, client_private_key: Option, history_file: Option, } #[derive(Deserialize, Default, Debug, PartialEq)] struct ClickHouseClientConfig { user: Option, password: Option, secure: Option, // chdig analog for accept_invalid_certificate skip_verify: Option, #[serde(rename = "accept-invalid-certificate")] accept_invalid_certificate: Option, open_ssl: Option, history_file: Option, connections_credentials: Vec, } #[derive(Deserialize, Default)] struct XmlClickHouseClientConfigConnectionsCredentialsConnection { connection: Option>, } #[derive(Deserialize)] struct XmlClickHouseClientConfig { user: Option, password: Option, secure: Option, // chdig analog for accept_invalid_certificate skip_verify: Option, #[serde(rename = "accept-invalid-certificate")] accept_invalid_certificate: Option, #[serde(rename = "openSSL")] open_ssl: Option, history_file: Option, connections_credentials: Option, } #[derive(Deserialize)] struct YamlClickHouseClientConfig { user: Option, password: Option, secure: Option, // chdig analog for accept_invalid_certificate skip_verify: Option, #[serde(rename = "accept-invalid-certificate")] accept_invalid_certificate: Option, #[serde(rename = "openSSL")] open_ssl: Option, history_file: Option, connections_credentials: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Subcommand)] pub enum ChDigViews { /// Show now running queries (from system.processes) Queries, /// Show last running queries (from system.query_log) LastQueries, /// Show slow (slower then 1 second, ordered by duration) queries (from system.query_log) SlowQueries, /// Show merges for MergeTree engine (system.merges) Merges, /// Show S3 Queue (system.s3queue_metadata_cache) S3Queue, /// Show Azure Queue (system.azure_queue_metadata_cache) AzureQueue, /// Show mutations for MergeTree engine (system.mutations) Mutations, /// Show replication queue for ReplicatedMergeTree engine (system.replication_queue) ReplicationQueue, /// Show fetches for ReplicatedMergeTree engine (system.replicated_fetches) ReplicatedFetches, /// Show information about replicas (system.replicas) Replicas, /// Tables Tables, /// Show all errors that happened in a server since start (system.errors) Errors, /// Show information about backups (system.backups) Backups, /// Show information about dictionaries (system.dictionaries) Dictionaries, /// Show server logs (system.text_log) ServerLogs, /// Show loggers (system.text_log) Loggers, /// Show background schedule pool tasks (system.background_schedule_pool) BackgroundSchedulePool, /// Show background schedule pool logs (system.background_schedule_pool_log) BackgroundSchedulePoolLog, /// Show table parts (system.parts) TableParts, /// Show asynchronous inserts (system.asynchronous_inserts) AsynchronousInserts, /// Show part log (system.part_log) PartLog, /// Spawn client inside chdig Client, } #[derive(Parser, Clone)] #[command(name = "chdig")] #[command(author, version, about, long_about = None)] pub struct ChDigOptions { #[command(flatten)] pub clickhouse: ClickHouseOptions, #[command(flatten)] pub view: ViewOptions, #[command(subcommand)] pub start_view: Option, #[command(flatten)] pub service: ServiceOptions, #[clap(skip)] pub perfetto: ChDigPerfettoConfig, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LogsOrder { #[default] Asc, Desc, } #[derive(Args, Clone, Default)] pub struct ClickHouseOptions { #[arg(short('u'), long, value_name = "URL", env = "CHDIG_URL")] pub url: Option, /// Overrides host in --url (for clickhouse-client compatibility) #[arg(long, env = "CLICKHOUSE_HOST")] pub host: Option, /// Overrides port in --url (for clickhouse-client compatibility) #[arg(long)] pub port: Option, /// Overrides user in --url (for clickhouse-client compatibility) #[arg(long, env = "CLICKHOUSE_USER")] pub user: Option, /// Overrides password in --url (for clickhouse-client compatibility) #[arg(long, env = "CLICKHOUSE_PASSWORD")] pub password: Option, /// Overrides secure=1 in --url (for clickhouse-client compatibility) #[arg(long, action = ArgAction::SetTrue)] pub secure: bool, /// ClickHouse like config (with some advanced features) #[arg(long, env = "CLICKHOUSE_CONFIG")] pub config: Option, #[arg(short('C'), long)] pub connection: Option, // Safe version for "url" (to show in UI) #[clap(skip)] pub url_safe: String, #[arg(short('c'), long)] pub cluster: Option, /// Aggregate system.*_log historical data, using merge() #[arg(long, action = ArgAction::SetTrue)] pub history: bool, #[arg(long, action = ArgAction::SetTrue, overrides_with = "history")] pub no_history: bool, /// Do not hide internal (spawned by chdig) queries #[arg(long, action = ArgAction::SetTrue)] pub internal_queries: bool, #[arg(long, action = ArgAction::SetTrue, overrides_with = "internal_queries")] pub no_internal_queries: bool, /// Limit for logs #[arg(long, default_value_t = 100000)] pub limit: u64, /// Sort order for logs (desc returns the newest --limit rows, useful for long backups) #[arg(long, value_enum, default_value_t = LogsOrder::Asc)] pub logs_order: LogsOrder, /// Override server version (for dev builds with features already available). Should include /// at least three components (maj.min.patch) #[arg(long, hide = true)] pub server_version: Option, /// Skip unavailable shards in distributed queries #[arg(long, action = ArgAction::SetTrue)] pub skip_unavailable_shards: bool, #[clap(skip)] pub history_file: Option, } impl ClickHouseOptions { pub fn connection_info(&self) -> String { if let Some(ref connection) = self.connection { connection.clone() } else if let Ok(url) = url::Url::parse(&self.url_safe) { url.host_str().unwrap_or("localhost").to_string() } else { self.url_safe.clone() } } } #[derive(Args, Clone)] pub struct ViewOptions { #[arg( short('d'), long, value_parser = |arg: &str| -> Result {Ok(time::Duration::from_millis(arg.parse()?))}, default_value = "30000", )] pub delay_interval: time::Duration, #[arg(short('g'), long, action = ArgAction::SetTrue, default_value_if("cluster", ArgPredicate::IsPresent, Some("true")))] /// Grouping distributed queries (turned on by default in --cluster mode) pub group_by: bool, #[arg(short('G'), long, action = ArgAction::SetTrue, overrides_with = "group_by")] no_group_by: bool, #[arg(long, action = ArgAction::SetTrue)] /// Do not accumulate metrics for subqueries in the initial query pub no_subqueries: bool, /// Use short option -b, like atop(1) has #[arg(long, short('b'), default_value = "1hour")] /// Begin of the time interval to look at pub start: RelativeDateTime, #[arg(long, short('e'), default_value = "")] /// End of the time interval pub end: RelativeDateTime, /// Wrap long lines #[arg(long, action = ArgAction::SetTrue)] pub wrap: bool, /// Disable stripping common hostname prefix and suffix in queries and logs views #[arg(long, action = ArgAction::SetTrue)] pub no_strip_hostname_suffix: bool, /// Limit for number of queries to render in queries views #[arg(long, default_value_t = 10000)] pub queries_limit: u64, // TODO: --mouse/--no-mouse (see EXIT_MOUSE_SEQUENCE in termion) } #[derive(Args, Clone)] pub struct ServiceOptions { #[arg(long, value_enum)] completion: Option, #[arg(long)] /// Log (for debugging chdig itself) pub log: Option, #[arg( long, default_value = "https://uzg8q0g12h.eu-central-1.aws.clickhouse.cloud/?user=paste" )] /// Pastila ClickHouse backend for uploading and sharing flamegraphs pub pastila_clickhouse_host: String, #[arg(long, default_value = "https://pastila.nl/")] /// pastila.nl URL (only to show direct link to pastila in logs) pub pastila_url: String, /// Path to chdig config file (YAML) #[arg(long, env = "CHDIG_CONFIG")] pub chdig_config: Option, } #[derive(Deserialize, Clone)] #[serde(default)] pub struct ChDigPerfettoConfig { pub opentelemetry_span_log: bool, pub trace_log: bool, pub query_metric_log: bool, pub part_log: bool, pub query_thread_log: bool, pub text_log: bool, pub text_log_android: bool, pub per_server: bool, pub metric_log: bool, pub asynchronous_metric_log: bool, pub asynchronous_insert_log: bool, pub error_log: bool, pub s3_queue_log: bool, pub azure_queue_log: bool, pub blob_storage_log: bool, pub background_schedule_pool_log: bool, pub session_log: bool, pub aggregated_zookeeper_log: bool, } impl Default for ChDigPerfettoConfig { fn default() -> Self { Self { opentelemetry_span_log: true, trace_log: true, query_metric_log: false, part_log: true, query_thread_log: true, text_log: true, text_log_android: true, per_server: true, metric_log: true, asynchronous_metric_log: false, asynchronous_insert_log: true, error_log: true, s3_queue_log: true, azure_queue_log: true, blob_storage_log: true, background_schedule_pool_log: true, session_log: true, aggregated_zookeeper_log: false, } } } #[derive(Deserialize, Default)] #[serde(default)] struct ChDigConfig { clickhouse: ChDigClickHouseConfig, view: ChDigViewConfig, service: ChDigServiceConfig, perfetto: ChDigPerfettoConfig, } #[derive(Deserialize, Default)] #[serde(default)] struct ChDigClickHouseConfig { url: Option, host: Option, port: Option, user: Option, password: Option, secure: Option, config: Option, connection: Option, cluster: Option, history: Option, internal_queries: Option, limit: Option, logs_order: Option, skip_unavailable_shards: Option, } #[derive(Deserialize, Default)] #[serde(default)] struct ChDigViewConfig { delay_interval: Option, group_by: Option, no_subqueries: Option, start: Option, end: Option, wrap: Option, no_strip_hostname_suffix: Option, queries_limit: Option, } #[derive(Deserialize, Default)] #[serde(default)] struct ChDigServiceConfig { log: Option, pastila_clickhouse_host: Option, pastila_url: Option, } fn read_yaml_clickhouse_client_config(path: &str) -> Result { let file = fs::File::open(path)?; let reader = io::BufReader::new(file); let doc = YamlDeserializer::from_reader(reader); let yaml_config = YamlClickHouseClientConfig::deserialize(doc)?; let config = ClickHouseClientConfig { user: yaml_config.user, password: yaml_config.password, secure: yaml_config.secure, skip_verify: yaml_config.skip_verify, accept_invalid_certificate: yaml_config.accept_invalid_certificate, open_ssl: yaml_config.open_ssl, history_file: yaml_config.history_file, connections_credentials: yaml_config .connections_credentials .unwrap_or_default() .into_values() .collect(), }; return Ok(config); } fn read_xml_clickhouse_client_config(path: &str) -> Result { let file = fs::File::open(path)?; let reader = io::BufReader::new(file); let mut doc = XmlDeserializer::from_reader(reader); let xml_config = XmlClickHouseClientConfig::deserialize(&mut doc)?; let config = ClickHouseClientConfig { user: xml_config.user, password: xml_config.password, secure: xml_config.secure, skip_verify: xml_config.skip_verify, accept_invalid_certificate: xml_config.accept_invalid_certificate, open_ssl: xml_config.open_ssl, history_file: xml_config.history_file, connections_credentials: xml_config .connections_credentials .unwrap_or_default() .connection .unwrap_or_default(), }; return Ok(config); } macro_rules! try_xml { ( $path:expr ) => { if path::Path::new($path).exists() { log::info!("Loading {}", $path); return Some(read_xml_clickhouse_client_config($path)); } }; } macro_rules! try_yaml { ( $path:expr ) => { if path::Path::new($path).exists() { log::info!("Loading {}", $path); return Some(read_yaml_clickhouse_client_config($path)); } }; } fn try_default_clickhouse_client_config() -> Option> { // Try XDG standard directory first if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { try_xml!(&format!("{}/clickhouse/config.xml", xdg_config_home)); try_yaml!(&format!("{}/clickhouse/config.yml", xdg_config_home)); try_yaml!(&format!("{}/clickhouse/config.yaml", xdg_config_home)); } // Try HOME-based locations if let Ok(home) = env::var("HOME") { // XDG fallback: ~/.config try_xml!(&format!("{}/.config/clickhouse/config.xml", home)); try_yaml!(&format!("{}/.config/clickhouse/config.yml", home)); try_yaml!(&format!("{}/.config/clickhouse/config.yaml", home)); // Legacy location: ~/.clickhouse-client try_xml!(&format!("{}/.clickhouse-client/config.xml", home)); try_yaml!(&format!("{}/.clickhouse-client/config.yml", home)); try_yaml!(&format!("{}/.clickhouse-client/config.yaml", home)); } // System-wide configuration try_xml!("/etc/clickhouse-client/config.xml"); try_yaml!("/etc/clickhouse-client/config.yml"); try_yaml!("/etc/clickhouse-client/config.yaml"); return None; } fn read_chdig_config(path: &str) -> Result { let file = fs::File::open(path)?; let reader = io::BufReader::new(file); let doc = YamlDeserializer::from_reader(reader); let config = ChDigConfig::deserialize(doc)?; return Ok(config); } macro_rules! try_chdig_yaml { ( $path:expr ) => { if path::Path::new($path).exists() { log::info!("Loading chdig config {}", $path); return Some(read_chdig_config($path)); } }; } fn try_default_chdig_config() -> Option> { if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { try_chdig_yaml!(&format!("{}/chdig/config.yaml", xdg_config_home)); try_chdig_yaml!(&format!("{}/chdig/config.yml", xdg_config_home)); } if let Ok(home) = env::var("HOME") { try_chdig_yaml!(&format!("{}/.config/chdig/config.yaml", home)); try_chdig_yaml!(&format!("{}/.config/chdig/config.yml", home)); try_chdig_yaml!(&format!("{}/.chdig.yaml", home)); try_chdig_yaml!(&format!("{}/.chdig.yml", home)); } try_chdig_yaml!("/etc/chdig/config.yaml"); try_chdig_yaml!("/etc/chdig/config.yml"); return None; } fn apply_chdig_config(options: &mut ChDigOptions, config: &ChDigConfig) { // clickhouse section let ch = &config.clickhouse; if options.clickhouse.url.is_none() { options.clickhouse.url = ch.url.clone(); } if options.clickhouse.host.is_none() { options.clickhouse.host = ch.host.clone(); } if options.clickhouse.port.is_none() { options.clickhouse.port = ch.port; } if options.clickhouse.user.is_none() { options.clickhouse.user = ch.user.clone(); } if options.clickhouse.password.is_none() { options.clickhouse.password = ch.password.clone(); } if !options.clickhouse.secure && let Some(secure) = ch.secure { options.clickhouse.secure = secure; } if options.clickhouse.config.is_none() { options.clickhouse.config = ch.config.clone(); } if options.clickhouse.connection.is_none() { options.clickhouse.connection = ch.connection.clone(); } if options.clickhouse.cluster.is_none() { options.clickhouse.cluster = ch.cluster.clone(); } if !options.clickhouse.history && let Some(history) = ch.history { options.clickhouse.history = history; } if !options.clickhouse.internal_queries && let Some(internal_queries) = ch.internal_queries { options.clickhouse.internal_queries = internal_queries; } if let Some(limit) = ch.limit { options.clickhouse.limit = limit; } if options.clickhouse.logs_order == LogsOrder::Asc && let Some(logs_order) = ch.logs_order { options.clickhouse.logs_order = logs_order; } if !options.clickhouse.skip_unavailable_shards && let Some(skip) = ch.skip_unavailable_shards { options.clickhouse.skip_unavailable_shards = skip; } // view section let view = &config.view; if let Some(delay) = view.delay_interval { options.view.delay_interval = time::Duration::from_millis(delay); } if !options.view.group_by && let Some(group_by) = view.group_by { options.view.group_by = group_by; } if !options.view.no_subqueries && let Some(no_subqueries) = view.no_subqueries { options.view.no_subqueries = no_subqueries; } if let Some(ref start) = view.start && let Ok(parsed) = RelativeDateTime::from_str(start) { options.view.start = parsed; } if let Some(ref end) = view.end && let Ok(parsed) = RelativeDateTime::from_str(end) { options.view.end = parsed; } if !options.view.wrap && let Some(wrap) = view.wrap { options.view.wrap = wrap; } if !options.view.no_strip_hostname_suffix && let Some(no_strip) = view.no_strip_hostname_suffix { options.view.no_strip_hostname_suffix = no_strip; } if let Some(queries_limit) = view.queries_limit { options.view.queries_limit = queries_limit; } // service section let svc = &config.service; if options.service.log.is_none() { options.service.log = svc.log.clone(); } if let Some(ref host) = svc.pastila_clickhouse_host { options.service.pastila_clickhouse_host = host.clone(); } if let Some(ref url) = svc.pastila_url { options.service.pastila_url = url.clone(); } // perfetto section options.perfetto = config.perfetto.clone(); } fn parse_url(options: &ClickHouseOptions) -> Result { let url_str = options.url.clone().unwrap_or_default(); let url = if url_str.contains("://") { // url::Url::scheme() does not works as we want, // since for "foo:bar@127.1" the scheme will be "foo", url::Url::parse(&url_str)? } else { url::Url::parse(&format!("tcp://{}", &url_str))? }; Ok(url) } pub fn is_cloud_host(host: &str) -> bool { let host = host.to_lowercase(); host.ends_with(".clickhouse.cloud") || host.ends_with(".clickhouse-staging.com") || host.ends_with(".clickhouse-dev.com") } fn is_local_address(host: &str) -> bool { let localhost = SocketAddr::from(([127, 0, 0, 1], 0)); let addresses = format!("{}:0", host).to_socket_addrs(); log::trace!("Resolving: {} -> {:?}", host, addresses); if let Ok(addresses) = addresses { for address in addresses { if address != localhost { log::trace!("Address {:?} is not local", address); return false; } } log::trace!("Host {} is local", host); return true; } return false; } fn set_password_from_opt(url: &mut url::Url, password: Option, force: bool) -> Result<()> { if let Some(password) = password && (url.password().is_none() || force) { url.set_password(Some( &utf8_percent_encode(&password, NON_ALPHANUMERIC).to_string(), )) .map_err(|_| anyhow!("password is invalid"))?; } Ok(()) } fn clickhouse_url_defaults( options: &mut ClickHouseOptions, config: Option, ) -> Result<()> { let mut url = parse_url(options)?; let connection = &options.connection; let mut secure: Option; let mut skip_verify: Option; let mut ca_certificate: Option; let mut client_certificate: Option; let mut client_private_key: Option; { let pairs: HashMap<_, _> = url.query_pairs().into_owned().collect(); secure = pairs.get("secure").and_then(|v| bool::from_str(v).ok()); skip_verify = pairs .get("skip_verify") .and_then(|v| bool::from_str(v).ok()); ca_certificate = pairs.get("ca_certificate").cloned(); client_certificate = pairs.get("client_certificate").cloned(); client_private_key = pairs.get("client_private_key").cloned(); } // host should be set first, since url crate does not allow to set user/password without host. let mut has_host = url.host().is_some(); if !has_host { url.set_host(Some("127.1"))?; } // Apply clickhouse-client compatible options if let Some(host) = &options.host { url.set_host(Some(host))?; has_host = true; } if let Some(port) = options.port { url.set_port(Some(port)) .map_err(|_| anyhow!("port is invalid"))?; } if let Some(user) = &options.user { url.set_username(user) .map_err(|_| anyhow!("username is invalid"))?; } set_password_from_opt(&mut url, options.password.clone(), true)?; if options.secure { secure = Some(true); } // // config // if let Some(config) = config { if url.username().is_empty() && let Some(user) = config.user { url.set_username(user.as_str()) .map_err(|_| anyhow!("username is invalid"))?; } set_password_from_opt(&mut url, config.password, false)?; if secure.is_none() && let Some(conf_secure) = config.secure { secure = Some(conf_secure); } let ssl_client = config.open_ssl.and_then(|ssl| ssl.client); if skip_verify.is_none() && let Some(conf_skip_verify) = config .skip_verify .or(config.accept_invalid_certificate) .or_else(|| { ssl_client .as_ref() .map(|client| client.verification_mode == Some("none".to_string())) }) { skip_verify = Some(conf_skip_verify); } if ca_certificate.is_none() && let Some(conf_ca_certificate) = ssl_client.as_ref().map(|v| v.ca_config.clone()) { ca_certificate = conf_ca_certificate.clone(); } if client_certificate.is_none() && let Some(conf_client_certificate) = ssl_client.as_ref().map(|v| v.certificate_file.clone()) { client_certificate = conf_client_certificate.clone(); } if client_private_key.is_none() && let Some(conf_client_private_key) = ssl_client.as_ref().map(|v| v.private_key_file.clone()) { client_private_key = conf_client_private_key.clone(); } if options.history_file.is_none() { options.history_file = config.history_file; } // // connections_credentials section from config // let mut connection_found = false; if let Some(connection) = connection { for c in config.connections_credentials.iter() { if &c.name != connection { continue; } if connection_found { panic!("Multiple connections had been matched. Fix you config.xml"); } connection_found = true; if !has_host && let Some(hostname) = &c.hostname { url.set_host(Some(hostname.as_str()))?; } if url.port().is_none() && let Some(port) = c.port { url.set_port(Some(port)) .map_err(|_| anyhow!("Cannot set port"))?; } if url.username().is_empty() && let Some(user) = &c.user { url.set_username(user.as_str()) .map_err(|_| anyhow!("username is invalid"))?; } set_password_from_opt(&mut url, c.password.clone(), false)?; if secure.is_none() && let Some(con_secure) = c.secure { secure = Some(con_secure); } if skip_verify.is_none() && let Some(con_skip_verify) = c.skip_verify { skip_verify = Some(con_skip_verify); } if ca_certificate.is_none() { ca_certificate = c.ca_certificate.clone(); } if client_certificate.is_none() { client_certificate = c.client_certificate.clone(); } if client_private_key.is_none() { client_private_key = c.client_private_key.clone(); } if options.history_file.is_none() { options.history_file = c.history_file.clone(); } } if !connection_found { panic!("Connection {} was not found", connection); } } } else if connection.is_some() { panic!("No client config had been read, while --connection was set"); } // Cloud hosts always use secure connections unless explicitly disabled if secure.is_none() && is_cloud_host(&url.host().ok_or_else(|| anyhow!("No host"))?.to_string()) { secure = Some(true); } // - 9000 for non secure // - 9440 for secure if url.port().is_none() { url.set_port(Some(if secure.unwrap_or_default() { 9440 } else { 9000 })) .map_err(|_| anyhow!("Cannot set port"))?; } let mut url_safe = url.clone(); // url_safe if url_safe.password().is_some() { url_safe .set_password(None) .map_err(|_| anyhow!("Cannot hide password"))?; } options.url_safe = url_safe.to_string(); // Switch database to "system", since "default" may not be present. if url_safe.path().is_empty() || url_safe.path() == "/" { url.set_path("/system"); } // some default settings in URL { let host_str = url.host().ok_or_else(|| anyhow!("No host"))?.to_string(); let pairs: HashMap<_, _> = url_safe.query_pairs().into_owned().collect(); let is_local = is_local_address(&host_str); let is_cloud = is_cloud_host(&host_str); let mut mut_pairs = url.query_pairs_mut(); // Enable compression in non-local network (in the same way as clickhouse does by default) if !pairs.contains_key("compression") && !is_local { mut_pairs.append_pair("compression", "lz4"); } if !pairs.contains_key("connection_timeout") { if is_cloud { // Cloud services may need time to wake up from idle state mut_pairs.append_pair("connection_timeout", "600s"); } else { // default is: 500ms (too small) mut_pairs.append_pair("connection_timeout", "5s"); } } // Note, right now even on a big clusters, everything works within default timeout (180s), // but just to make it "user friendly" even for some obscure setups, let's increase the // timeout still. if !pairs.contains_key("query_timeout") { mut_pairs.append_pair("query_timeout", "600s"); } if let Some(secure) = secure { mut_pairs.append_pair("secure", secure.to_string().as_str()); } if let Some(skip_verify) = skip_verify { mut_pairs.append_pair("skip_verify", skip_verify.to_string().as_str()); } if let Some(ca_certificate) = ca_certificate { mut_pairs.append_pair("ca_certificate", &ca_certificate); } if let Some(client_certificate) = client_certificate { mut_pairs.append_pair("client_certificate", &client_certificate); } if let Some(client_private_key) = client_private_key { mut_pairs.append_pair("client_private_key", &client_private_key); } if options.skip_unavailable_shards { mut_pairs.append_pair("skip_unavailable_shards", "1"); } } options.url = Some(url.to_string()); return Ok(()); } fn adjust_defaults(options: &mut ChDigOptions) -> Result<()> { // Load and apply chdig config before clickhouse client config, // so that e.g. clickhouse.config from chdig config feeds into the client config loading. let chdig_config = if let Some(ref path) = options.service.chdig_config { Some(read_chdig_config(path)?) } else if let Some(config) = try_default_chdig_config() { Some(config?) } else { None }; if let Some(ref chdig_config) = chdig_config { apply_chdig_config(options, chdig_config); } let config = if let Some(user_config) = &options.clickhouse.config { if user_config.to_lowercase().ends_with(".xml") { Some(read_xml_clickhouse_client_config(user_config)?) } else { Some(read_yaml_clickhouse_client_config(user_config)?) } } else if let Some(config) = try_default_clickhouse_client_config() { Some(config?) } else { None }; clickhouse_url_defaults(&mut options.clickhouse, config)?; // FIXME: overrides_with works before default_value_if, hence --no-group-by never works if options.view.no_group_by { options.view.group_by = false; } return Ok(()); } pub fn parse_from(itr: I) -> Result where I: IntoIterator, T: Into + Clone, { let mut options = ChDigOptions::parse_from(itr); // Generate autocompletion if let Some(shell) = options.service.completion { let mut cmd = ChDigOptions::command(); let name = cmd.get_name().to_string(); generate(shell, &mut cmd, name, &mut io::stdout()); process::exit(0); } adjust_defaults(&mut options)?; return Ok(options); } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; #[test] fn test_url_parse_no_proto() { assert_eq!( parse_url(&ClickHouseOptions::default()).unwrap(), url::Url::parse("tcp://").unwrap() ); } #[test] fn test_url_parse_user() { let mut options = ClickHouseOptions { user: Some("foo".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); assert_eq!( parse_url(&options).unwrap(), url::Url::parse("tcp://foo@127.1:9000/system?connection_timeout=5s&query_timeout=600s") .unwrap() ); } #[test] fn test_url_parse_password() { let mut options = ClickHouseOptions { password: Some("foo".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); assert_eq!( parse_url(&options).unwrap(), url::Url::parse( "tcp://:foo@127.1:9000/system?connection_timeout=5s&query_timeout=600s" ) .unwrap() ); } #[test] fn test_url_parse_password_with_special_chars() { let password = "!@#$%41^&*(%)"; let mut options = ClickHouseOptions { password: Some(password.into()), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); assert_eq!( parse_url(&options).unwrap(), url::Url::parse("tcp://:%21%40%23%24%2541%5E%26%2A%28%25%29@127.1:9000/system?connection_timeout=5s&query_timeout=600s"). unwrap() ); } #[test] fn test_url_parse_port() { let mut options = ClickHouseOptions { port: Some(9440), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); assert_eq!( parse_url(&options).unwrap(), url::Url::parse("tcp://127.1:9440/system?connection_timeout=5s&query_timeout=600s") .unwrap() ); } #[test] fn test_url_parse_secure() { let mut options = ClickHouseOptions { secure: true, ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); assert_eq!( parse_url(&options).unwrap(), url::Url::parse( "tcp://127.1:9440/system?connection_timeout=5s&query_timeout=600s&secure=true" ) .unwrap() ); } #[test] fn test_config_empty() { assert_eq!( read_xml_clickhouse_client_config("tests/configs/empty.xml").is_ok(), true ); assert_eq!( read_yaml_clickhouse_client_config("tests/configs/empty.yaml").is_ok(), true ); } #[test] fn test_config_unknown_directives() { assert_eq!( read_xml_clickhouse_client_config("tests/configs/unknown_directives.xml").is_ok(), true ); assert_eq!( read_yaml_clickhouse_client_config("tests/configs/unknown_directives.yaml").is_ok(), true ); } #[test] fn test_config_basic() { let xml_config = read_xml_clickhouse_client_config("tests/configs/basic.xml").unwrap(); let yaml_config = read_yaml_clickhouse_client_config("tests/configs/basic.yaml").unwrap(); let config = ClickHouseClientConfig { user: Some("foo".into()), password: Some("bar".into()), ..Default::default() }; assert_eq!(config, xml_config); assert_eq!(config, yaml_config); } #[test] fn test_config_tls() { let xml_config = read_xml_clickhouse_client_config("tests/configs/tls.xml").unwrap(); let yaml_config = read_yaml_clickhouse_client_config("tests/configs/tls.yaml").unwrap(); let config = ClickHouseClientConfig { secure: Some(true), open_ssl: Some(ClickHouseClientConfigOpenSSL { client: Some(ClickHouseClientConfigOpenSSLClient { verification_mode: Some("strict".into()), certificate_file: Some("cert".into()), private_key_file: Some("key".into()), ca_config: Some("ca".into()), }), }), ..Default::default() }; assert_eq!(config, xml_config); assert_eq!(config, yaml_config); } #[test] fn test_config_tls_applying_config_to_connection_url() { let config = read_yaml_clickhouse_client_config("tests/configs/tls.yaml").ok(); let mut options = ClickHouseOptions { ..Default::default() }; clickhouse_url_defaults(&mut options, config).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(args.get("secure"), Some(&"true".into())); assert_eq!(args.get("ca_certificate"), Some(&"ca".into())); assert_eq!(args.get("client_certificate"), Some(&"cert".into())); assert_eq!(args.get("client_private_key"), Some(&"key".into())); assert_eq!(args.get("skip_verify"), Some(&"false".into())); } #[test] fn test_config_connections_applying_config_to_connection_url_play() { let config = read_yaml_clickhouse_client_config("tests/configs/connections.yaml").ok(); let mut options = ClickHouseOptions { connection: Some("play".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, config).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(url.host().unwrap().to_string(), "play.clickhouse.com"); assert_eq!(args.get("secure"), Some(&"true".into())); assert_eq!(args.contains_key("skip_verify"), false); } #[test] fn test_config_connections_applying_config_to_connection_url_play_tls() { let config = read_yaml_clickhouse_client_config("tests/configs/connections.yaml").ok(); let mut options = ClickHouseOptions { connection: Some("play-tls".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, config).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(url.host().unwrap().to_string(), "play.clickhouse.com"); assert_eq!(args.get("secure"), Some(&"true".into())); assert_eq!(args.get("ca_certificate"), Some(&"ca".into())); assert_eq!(args.get("client_certificate"), Some(&"cert".into())); assert_eq!(args.get("client_private_key"), Some(&"key".into())); assert_eq!(args.get("skip_verify"), Some(&"true".into())); } #[test] fn test_config_connections_host() { let config = read_yaml_clickhouse_client_config("tests/configs/connections.yaml").ok(); let mut options = ClickHouseOptions { connection: Some("play-tls".into()), host: Some("localhost".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, config).unwrap(); assert_eq!( parse_url(&options).unwrap().host().unwrap().to_string(), "localhost" ); } #[test] fn test_config_apply_accept_invalid_certificate() { let config = read_yaml_clickhouse_client_config("tests/configs/accept_invalid_certificate.yaml") .unwrap(); assert_eq!(config.accept_invalid_certificate, Some(true)); let mut options = ClickHouseOptions { ..Default::default() }; clickhouse_url_defaults(&mut options, Some(config)).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(args.get("skip_verify"), Some(&"true".into())); } #[test] fn test_cloud_defaults() { { let mut options = ClickHouseOptions { host: Some("uzg8q0g12h.eu-central-1.aws.clickhouse.cloud".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(args.get("secure"), Some(&"true".into())); assert_eq!(args.get("connection_timeout"), Some(&"600s".into())); } // Note, checking for ClickHouseOptions{secure: false} does not make sense, since it is the default { let mut options = ClickHouseOptions { url: Some("uzg8q0g12h.eu-central-1.aws.clickhouse.cloud/?secure=false&connection_timeout=1ms".into()), ..Default::default() }; clickhouse_url_defaults(&mut options, None).unwrap(); let url = parse_url(&options).unwrap(); let args: HashMap<_, _> = url.query_pairs().into_owned().collect(); assert_eq!(args.get("secure"), Some(&"false".into())); assert_eq!(args.get("connection_timeout"), Some(&"1ms".into())); } } #[test] fn test_chdig_config_empty() { let config = read_chdig_config("tests/configs/chdig_empty.yaml").unwrap(); assert!(config.clickhouse.url.is_none()); assert!(config.clickhouse.host.is_none()); assert!(config.view.delay_interval.is_none()); assert!(config.service.log.is_none()); } #[test] fn test_chdig_config_basic() { let config = read_chdig_config("tests/configs/chdig_basic.yaml").unwrap(); assert_eq!( config.clickhouse.url.as_deref(), Some("tcp://config-host:9000") ); assert_eq!(config.clickhouse.host.as_deref(), Some("config-host")); assert_eq!(config.clickhouse.port, Some(9440)); assert_eq!(config.clickhouse.user.as_deref(), Some("config_user")); assert_eq!(config.clickhouse.password.as_deref(), Some("config_pass")); assert_eq!(config.clickhouse.secure, Some(true)); assert_eq!(config.clickhouse.cluster.as_deref(), Some("my_cluster")); assert_eq!(config.clickhouse.history, Some(true)); assert_eq!(config.clickhouse.internal_queries, Some(true)); assert_eq!(config.clickhouse.limit, Some(50000)); assert_eq!(config.clickhouse.skip_unavailable_shards, Some(true)); assert_eq!(config.view.delay_interval, Some(5000)); assert_eq!(config.view.group_by, Some(true)); assert_eq!(config.view.no_subqueries, Some(true)); assert_eq!(config.view.start.as_deref(), Some("2hours")); assert_eq!(config.view.end.as_deref(), Some("30min")); assert_eq!(config.view.wrap, Some(true)); assert_eq!(config.view.no_strip_hostname_suffix, Some(true)); assert_eq!(config.view.queries_limit, Some(500)); assert_eq!(config.service.log.as_deref(), Some("/tmp/chdig.log")); assert_eq!( config.service.pastila_clickhouse_host.as_deref(), Some("https://custom.host/") ); assert_eq!( config.service.pastila_url.as_deref(), Some("https://custom.pastila/") ); } #[test] fn test_chdig_config_partial() { let config = read_chdig_config("tests/configs/chdig_partial.yaml").unwrap(); assert_eq!(config.clickhouse.host.as_deref(), Some("partial-host")); assert_eq!(config.clickhouse.user.as_deref(), Some("partial_user")); assert!(config.clickhouse.url.is_none()); assert!(config.clickhouse.port.is_none()); assert!(config.clickhouse.secure.is_none()); assert_eq!(config.view.delay_interval, Some(10000)); assert!(config.view.group_by.is_none()); assert!(config.view.wrap.is_none()); assert!(config.service.log.is_none()); } #[test] fn test_chdig_config_apply_clickhouse() { let config = read_chdig_config("tests/configs/chdig_basic.yaml").unwrap(); let mut options = ChDigOptions::parse_from(["chdig"]); apply_chdig_config(&mut options, &config); assert_eq!(options.clickhouse.host.as_deref(), Some("config-host")); assert_eq!(options.clickhouse.user.as_deref(), Some("config_user")); assert_eq!(options.clickhouse.password.as_deref(), Some("config_pass")); assert_eq!(options.clickhouse.port, Some(9440)); assert_eq!(options.clickhouse.secure, true); assert_eq!(options.clickhouse.cluster.as_deref(), Some("my_cluster")); assert_eq!(options.clickhouse.history, true); assert_eq!(options.clickhouse.internal_queries, true); assert_eq!(options.clickhouse.limit, 50000); assert_eq!(options.clickhouse.skip_unavailable_shards, true); } #[test] fn test_chdig_config_apply_view() { let config = read_chdig_config("tests/configs/chdig_basic.yaml").unwrap(); let mut options = ChDigOptions::parse_from(["chdig"]); apply_chdig_config(&mut options, &config); assert_eq!( options.view.delay_interval, time::Duration::from_millis(5000) ); assert_eq!(options.view.group_by, true); assert_eq!(options.view.no_subqueries, true); assert_eq!(options.view.wrap, true); assert_eq!(options.view.no_strip_hostname_suffix, true); assert_eq!(options.view.queries_limit, 500); assert_eq!(options.service.log.as_deref(), Some("/tmp/chdig.log")); assert_eq!( options.service.pastila_clickhouse_host, "https://custom.host/" ); assert_eq!(options.service.pastila_url, "https://custom.pastila/"); } #[test] fn test_chdig_config_perfetto() { let config = read_chdig_config("tests/configs/chdig_basic.yaml").unwrap(); assert_eq!(config.perfetto.opentelemetry_span_log, true); assert_eq!(config.perfetto.trace_log, true); assert_eq!(config.perfetto.query_metric_log, true); assert_eq!(config.perfetto.part_log, false); assert_eq!(config.perfetto.query_thread_log, true); assert_eq!(config.perfetto.text_log, false); let mut options = ChDigOptions::parse_from(["chdig"]); apply_chdig_config(&mut options, &config); assert_eq!(options.perfetto.opentelemetry_span_log, true); assert_eq!(options.perfetto.part_log, false); assert_eq!(options.perfetto.query_metric_log, true); } #[test] fn test_chdig_config_perfetto_defaults() { let config = read_chdig_config("tests/configs/chdig_empty.yaml").unwrap(); assert_eq!(config.perfetto.opentelemetry_span_log, true); assert_eq!(config.perfetto.trace_log, true); assert_eq!(config.perfetto.query_metric_log, false); assert_eq!(config.perfetto.part_log, true); assert_eq!(config.perfetto.query_thread_log, true); assert_eq!(config.perfetto.text_log, true); } #[test] fn test_chdig_config_cli_overrides_config() { let config = read_chdig_config("tests/configs/chdig_basic.yaml").unwrap(); let mut options = ChDigOptions::parse_from([ "chdig", "--host", "cli-host", "--user", "cli_user", "--secure", "--log", "/tmp/cli.log", ]); apply_chdig_config(&mut options, &config); // Option fields: CLI wins when set assert_eq!(options.clickhouse.host.as_deref(), Some("cli-host")); assert_eq!(options.clickhouse.user.as_deref(), Some("cli_user")); assert_eq!(options.service.log.as_deref(), Some("/tmp/cli.log")); // Bool flags: CLI true wins assert_eq!(options.clickhouse.secure, true); // Option fields not set on CLI come from config assert_eq!(options.clickhouse.password.as_deref(), Some("config_pass")); assert_eq!(options.clickhouse.cluster.as_deref(), Some("my_cluster")); // Non-Option fields: config always applies assert_eq!(options.clickhouse.limit, 50000); assert_eq!( options.view.delay_interval, time::Duration::from_millis(5000) ); } } ================================================ FILE: src/interpreter/perfetto.rs ================================================ use crate::interpreter::Query; use crate::interpreter::clickhouse::{Columns, MetricLogRow, QueryMetricRow}; use chrono::{DateTime, Local}; use chrono_tz::Tz; use perfetto_protos::android_log::AndroidLogPacket; use perfetto_protos::android_log::android_log_packet::LogEvent; use perfetto_protos::android_log_constants::AndroidLogPriority; use perfetto_protos::clock_snapshot::ClockSnapshot; use perfetto_protos::clock_snapshot::clock_snapshot::Clock; use perfetto_protos::counter_descriptor::CounterDescriptor; use perfetto_protos::counter_descriptor::counter_descriptor::Unit; use perfetto_protos::debug_annotation::DebugAnnotation; use perfetto_protos::debug_annotation::debug_annotation as da; use perfetto_protos::interned_data::InternedData; use perfetto_protos::profile_common::{Callstack, Frame, InternedString, Mapping}; use perfetto_protos::profile_packet::StreamingProfilePacket; use perfetto_protos::thread_descriptor::ThreadDescriptor as PerfettoThreadDescriptor; use perfetto_protos::trace::Trace; use perfetto_protos::trace_packet::TracePacket; use perfetto_protos::trace_packet::trace_packet::Data; use perfetto_protos::track_descriptor::TrackDescriptor; use perfetto_protos::track_descriptor::track_descriptor::Static_or_dynamic_name; use perfetto_protos::track_event::TrackEvent; use perfetto_protos::track_event::track_event::{Counter_value_field, Name_field, Type}; use protobuf::{EnumOrUnknown, Message, MessageField}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; const SEQUENCE_ID: u32 = 1; // Sequence-scoped clock (>=64), mapped to BOOTTIME via ClockSnapshot. // All TrackEvent packets (slices, counters) use this clock on SEQUENCE_ID. // // Clock timeline notes: // - Clock 128 is sequence-scoped: the ClockSnapshot on SEQUENCE_ID defines it // ONLY for that sequence. Other sequences cannot use it (see add_stack_traces). // - The ClockSnapshot must be the first packet (timestamp=0, self-referencing). // Using a non-zero timestamp in clock 6 (BOOTTIME) instead doesn't work reliably. // - The first make_packet() call emits SEQ_INCREMENTAL_STATE_CLEARED (flags=1). // This is safe because it's a TrackDescriptor without a timestamp (processed // inline before the ClockSnapshot enters the sort queue). // - Never emit SEQ_INCREMENTAL_STATE_CLEARED on timestamped packets sharing this // sequence — it destroys the clock mapping for all subsequent packets. const CLOCK_ID_UNIXTIME: u32 = 128; struct Sample { callstack_iid: u64, timestamp_us: i64, } pub struct PerfettoTraceBuilder { packets: Vec, next_uuid: u64, next_sequence_id: u32, first_event_emitted: bool, function_name_iids: HashMap, frame_iids: HashMap<(u64, u64), u64>, callstack_iids: HashMap, u64>, next_intern_id: u64, host_uuids: HashMap, // (host_name, category) → category track uuid host_category_uuids: HashMap<(String, &'static str), u64>, per_server: bool, text_log_android: bool, } impl PerfettoTraceBuilder { pub fn new(per_server: bool, text_log_android: bool) -> Self { PerfettoTraceBuilder { packets: Vec::new(), next_uuid: 1, next_sequence_id: SEQUENCE_ID + 1, first_event_emitted: false, function_name_iids: HashMap::new(), frame_iids: HashMap::new(), callstack_iids: HashMap::new(), next_intern_id: 1, host_uuids: HashMap::new(), host_category_uuids: HashMap::new(), per_server, text_log_android, } } fn alloc_uuid(&mut self) -> u64 { let uuid = self.next_uuid; self.next_uuid += 1; uuid } fn make_packet(&mut self) -> TracePacket { let mut pkt = TracePacket::new(); pkt.set_trusted_packet_sequence_id(SEQUENCE_ID); if !self.first_event_emitted { pkt.sequence_flags = Some(1); // SEQ_INCREMENTAL_STATE_CLEARED self.first_event_emitted = true; } else { pkt.sequence_flags = Some(2); // SEQ_NEEDS_INCREMENTAL_STATE } pkt } fn make_event_packet(&mut self, ts_ns: u64) -> TracePacket { let mut pkt = self.make_packet(); pkt.timestamp = Some(ts_ns); pkt.timestamp_clock_id = Some(CLOCK_ID_UNIXTIME); pkt } fn add_process_track(&mut self, uuid: u64, name: &str) { let mut pkt = self.make_packet(); let mut td = TrackDescriptor::new(); td.uuid = Some(uuid); td.static_or_dynamic_name = Some(Static_or_dynamic_name::Name(name.to_string())); pkt.data = Some(Data::TrackDescriptor(td)); self.packets.push(pkt); } fn add_child_track(&mut self, uuid: u64, parent_uuid: u64, name: &str) { let mut pkt = self.make_packet(); let mut td = TrackDescriptor::new(); td.uuid = Some(uuid); td.parent_uuid = Some(parent_uuid); td.static_or_dynamic_name = Some(Static_or_dynamic_name::Name(name.to_string())); pkt.data = Some(Data::TrackDescriptor(td)); self.packets.push(pkt); } fn add_counter_track(&mut self, uuid: u64, parent_uuid: u64, name: &str, unit: Unit) { let mut pkt = self.make_packet(); let mut td = TrackDescriptor::new(); td.uuid = Some(uuid); td.parent_uuid = Some(parent_uuid); td.static_or_dynamic_name = Some(Static_or_dynamic_name::Name(name.to_string())); let mut cd = CounterDescriptor::new(); cd.unit = Some(EnumOrUnknown::new(unit)); td.counter = MessageField::some(cd); pkt.data = Some(Data::TrackDescriptor(td)); self.packets.push(pkt); } fn add_slice_begin( &mut self, track_uuid: u64, name: &str, ts_ns: u64, annotations: Vec, ) { let mut pkt = self.make_event_packet(ts_ns); let mut te = TrackEvent::new(); te.type_ = Some(EnumOrUnknown::new(Type::TYPE_SLICE_BEGIN)); te.track_uuid = Some(track_uuid); te.name_field = Some(Name_field::Name(name.to_string())); te.debug_annotations = annotations; pkt.data = Some(Data::TrackEvent(te)); self.packets.push(pkt); } fn add_slice_end(&mut self, track_uuid: u64, ts_ns: u64) { let mut pkt = self.make_event_packet(ts_ns); let mut te = TrackEvent::new(); te.type_ = Some(EnumOrUnknown::new(Type::TYPE_SLICE_END)); te.track_uuid = Some(track_uuid); pkt.data = Some(Data::TrackEvent(te)); self.packets.push(pkt); } fn add_instant( &mut self, track_uuid: u64, name: &str, ts_ns: u64, annotations: Vec, ) { let mut pkt = self.make_event_packet(ts_ns); let mut te = TrackEvent::new(); te.type_ = Some(EnumOrUnknown::new(Type::TYPE_INSTANT)); te.track_uuid = Some(track_uuid); te.name_field = Some(Name_field::Name(name.to_string())); te.debug_annotations = annotations; pkt.data = Some(Data::TrackEvent(te)); self.packets.push(pkt); } fn add_counter_value(&mut self, track_uuid: u64, ts_ns: u64, value: i64) { let mut pkt = self.make_event_packet(ts_ns); let mut te = TrackEvent::new(); te.type_ = Some(EnumOrUnknown::new(Type::TYPE_COUNTER)); te.track_uuid = Some(track_uuid); te.counter_value_field = Some(Counter_value_field::CounterValue(value)); pkt.data = Some(Data::TrackEvent(te)); self.packets.push(pkt); } /// Returns (unit, scale_factor) for a ProfileEvent name. /// Scale factor converts the raw value to the unit's base /// (e.g. microseconds × 1000 → nanoseconds for UNIT_TIME_NS). fn unit_for_event(name: &str) -> (Unit, i64) { if name.ends_with("Bytes") { (Unit::UNIT_SIZE_BYTES, 1) } else if name.ends_with("Microseconds") { (Unit::UNIT_TIME_NS, 1000) } else if name.ends_with("Milliseconds") { (Unit::UNIT_TIME_NS, 1_000_000) } else if name.ends_with("Nanoseconds") { (Unit::UNIT_TIME_NS, 1) } else { (Unit::UNIT_UNSPECIFIED, 1) } } fn make_annotation_str(name: &str, value: &str) -> DebugAnnotation { let mut ann = DebugAnnotation::new(); ann.name_field = Some(da::Name_field::Name(name.to_string())); ann.value = Some(da::Value::StringValue(value.to_string())); ann } fn make_annotation_int(name: &str, value: i64) -> DebugAnnotation { let mut ann = DebugAnnotation::new(); ann.name_field = Some(da::Name_field::Name(name.to_string())); ann.value = Some(da::Value::IntValue(value)); ann } fn datetime_to_ns(dt: &DateTime) -> Option { dt.timestamp_nanos_opt().map(|ns| ns as u64) } fn log_level_to_prio(level: &str) -> AndroidLogPriority { match level { "Fatal" | "Critical" => AndroidLogPriority::PRIO_FATAL, "Error" => AndroidLogPriority::PRIO_ERROR, "Warning" => AndroidLogPriority::PRIO_WARN, "Information" => AndroidLogPriority::PRIO_INFO, "Debug" => AndroidLogPriority::PRIO_DEBUG, _ => AndroidLogPriority::PRIO_VERBOSE, } } // --- High-level methods --- pub fn add_queries(&mut self, queries: &[Query]) { // (host, user) → thread_uuid let mut user_uuids: HashMap<(String, String), u64> = HashMap::new(); for q in queries { let host_uuid = self.get_or_create_host_uuid(&q.host_name); let user_key = (q.host_name.clone(), q.user.clone()); let user_uuid = *user_uuids.entry(user_key).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, host_uuid, &q.user); uuid }); let start_ns = match Self::datetime_to_ns(&q.query_start_time_microseconds) { Some(ns) => ns, None => { log::warn!("Perfetto: query {} has invalid start time", q.query_id); continue; } }; let end_ns = match Self::datetime_to_ns(&q.query_end_time_microseconds) { Some(ns) => ns, None => { log::warn!("Perfetto: query {} has invalid end time", q.query_id); continue; } }; let label = if q.normalized_query.chars().count() > 80 { let truncated: String = q.normalized_query.chars().take(80).collect(); format!("{}...", truncated) } else { q.normalized_query.clone() }; let mut annotations = vec![ Self::make_annotation_str("query_id", &q.query_id), Self::make_annotation_str("initial_query_id", &q.initial_query_id), Self::make_annotation_str("user", &q.user), Self::make_annotation_str("database", &q.current_database), Self::make_annotation_int("memory", q.memory), Self::make_annotation_int("threads", q.threads as i64), ]; if !q.original_query.is_empty() { annotations.push(Self::make_annotation_str("query", &q.original_query)); } self.add_slice_begin(user_uuid, &label, start_ns, annotations); self.add_slice_end(user_uuid, end_ns); } } fn get_or_create_host_uuid(&mut self, host_name: &str) -> u64 { if let Some(&uuid) = self.host_uuids.get(host_name) { return uuid; } let uuid = self.alloc_uuid(); self.add_process_track(uuid, host_name); self.host_uuids.insert(host_name.to_string(), uuid); uuid } fn get_host_category_track(&mut self, host_name: &str, category: &'static str) -> Option { if !self.per_server || host_name.is_empty() { return None; } let host_uuid = self.get_or_create_host_uuid(host_name); let key = (host_name.to_string(), category); if let Some(&uuid) = self.host_category_uuids.get(&key) { Some(uuid) } else { let uuid = self.alloc_uuid(); self.add_child_track(uuid, host_uuid, category); self.host_category_uuids.insert(key, uuid); Some(uuid) } } pub fn add_otel_spans(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } // Group spans by operation_name → thread track under query's host process // Use a single process track for OTel spans let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "OpenTelemetry Spans"); let mut op_uuids: HashMap = HashMap::new(); // (host_uuid, operation_name) → track_uuid let mut server_op_uuids: HashMap<(u64, String), u64> = HashMap::new(); for i in 0..columns.row_count() { let operation_name: String = columns.get(i, "operation_name").unwrap_or_default(); let start_us: u64 = match columns.get(i, "start_time_us") { Ok(v) => v, Err(e) => { log::warn!("Perfetto: otel_span row {} start_time_us: {}", i, e); continue; } }; let finish_us: u64 = match columns.get(i, "finish_time_us") { Ok(v) => v, Err(e) => { log::warn!("Perfetto: otel_span row {} finish_time_us: {}", i, e); continue; } }; let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let host_name: String = columns.get(i, "host_name").unwrap_or_default(); let start_ns = start_us.saturating_mul(1000); let end_ns = finish_us.saturating_mul(1000); let track_uuid = *op_uuids.entry(operation_name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track( uuid, process_uuid, &format!("Processor: {}", operation_name), ); uuid }); let annotations = vec![Self::make_annotation_str("query_id", &query_id)]; self.add_slice_begin(track_uuid, &operation_name, start_ns, annotations.clone()); self.add_slice_end(track_uuid, end_ns); if let Some(cat_uuid) = self.get_host_category_track(&host_name, "OpenTelemetry Spans") { let server_track = *server_op_uuids .entry((cat_uuid, operation_name.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, cat_uuid, &operation_name); uuid }); self.add_slice_begin(server_track, &operation_name, start_ns, annotations); self.add_slice_end(server_track, end_ns); } } } pub fn add_trace_log_counters(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "ProfileEvent Counters"); // event_name → (track_uuid, running_total) let mut counter_tracks: HashMap = HashMap::new(); // (host_uuid, event_name) → (track_uuid, running_total) let mut server_tracks: HashMap<(u64, String), (u64, i64)> = HashMap::new(); for i in 0..columns.row_count() { let event: String = columns.get(i, "event").unwrap_or_default(); let increment: i64 = columns.get(i, "increment").unwrap_or(0); let host_name: String = columns.get(i, "host_name").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: trace_log row {} event_time_microseconds: {}", i, e ); continue; } }; let (unit, scale) = Self::unit_for_event(&event); let scaled_increment = increment * scale; let (track_uuid, running_total) = counter_tracks.entry(event.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, &event, unit); (uuid, 0) }); *running_total += scaled_increment; self.add_counter_value(*track_uuid, timestamp_ns, *running_total); if let Some(cat_uuid) = self.get_host_category_track(&host_name, "ProfileEvent Counters") { let (track_uuid, running_total) = server_tracks .entry((cat_uuid, event.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, cat_uuid, &event, unit); (uuid, 0) }); *running_total += scaled_increment; self.add_counter_value(*track_uuid, timestamp_ns, *running_total); } } } pub fn add_query_metrics(&mut self, rows: &[QueryMetricRow]) { if rows.is_empty() { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Query Metrics"); // metric_name → track_uuid let mut counter_tracks: HashMap = HashMap::new(); // (host_uuid, metric_name) → track_uuid let mut server_tracks: HashMap<(u64, String), u64> = HashMap::new(); for row in rows { // memory_usage / peak_memory_usage for (name, value, unit) in [ ("memory_usage", row.memory_usage, Unit::UNIT_SIZE_BYTES), ( "peak_memory_usage", row.peak_memory_usage, Unit::UNIT_SIZE_BYTES, ), ] { let track_uuid = *counter_tracks.entry(name.to_string()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, name, unit); uuid }); self.add_counter_value(track_uuid, row.timestamp_ns, value); if let Some(cat_uuid) = self.get_host_category_track(&row.host_name, "Query Metrics") { let server_track = *server_tracks .entry((cat_uuid, name.to_string())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, cat_uuid, name, unit); uuid }); self.add_counter_value(server_track, row.timestamp_ns, value); } } // ProfileEvent_* metrics for (name, value) in &row.profile_events { let (unit, scale) = Self::unit_for_event(name); let scaled_value = *value as i64 * scale; let track_uuid = *counter_tracks.entry(name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, name, unit); uuid }); self.add_counter_value(track_uuid, row.timestamp_ns, scaled_value); if let Some(cat_uuid) = self.get_host_category_track(&row.host_name, "Query Metrics") { let server_track = *server_tracks .entry((cat_uuid, name.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, cat_uuid, name, unit); uuid }); self.add_counter_value(server_track, row.timestamp_ns, scaled_value); } } } } pub fn add_part_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Part Log"); // "db.table" → thread_uuid let mut table_uuids: HashMap = HashMap::new(); // (host_uuid, "db.table") → track_uuid let mut server_table_uuids: HashMap<(u64, String), u64> = HashMap::new(); for i in 0..columns.row_count() { let event_type: String = columns.get(i, "event_type").unwrap_or_default(); let event_time: DateTime = match columns.get(i, "event_time_microseconds") { Ok(v) => v, Err(e) => { log::warn!( "Perfetto: part_log row {} event_time_microseconds: {}", i, e ); continue; } }; let duration_ms: u64 = columns.get(i, "duration_ms").unwrap_or(0); let database: String = columns.get(i, "database").unwrap_or_default(); let table: String = columns.get(i, "table").unwrap_or_default(); let part_name: String = columns.get(i, "part_name").unwrap_or_default(); let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let rows: u64 = columns.get(i, "rows").unwrap_or(0); let size_in_bytes: u64 = columns.get(i, "size_in_bytes").unwrap_or(0); let host_name: String = columns.get(i, "host_name").unwrap_or_default(); let table_key = format!("{}.{}", database, table); let track_uuid = *table_uuids.entry(table_key.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &table_key); uuid }); let end_ns = match event_time.with_timezone(&Local).timestamp_nanos_opt() { Some(ns) => ns as u64, None => { log::warn!("Perfetto: part_log row {} timestamp overflow", i); continue; } }; let start_ns = end_ns.saturating_sub(duration_ms * 1_000_000); let label = format!("{} {}", event_type, part_name); let annotations = vec![ Self::make_annotation_str("query_id", &query_id), Self::make_annotation_str("part_name", &part_name), Self::make_annotation_int("rows", rows as i64), Self::make_annotation_int("size_in_bytes", size_in_bytes as i64), ]; self.add_slice_begin(track_uuid, &label, start_ns, annotations.clone()); self.add_slice_end(track_uuid, end_ns); if let Some(cat_uuid) = self.get_host_category_track(&host_name, "Part Log") { let server_track = *server_table_uuids .entry((cat_uuid, table_key.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, cat_uuid, &table_key); uuid }); self.add_slice_begin(server_track, &label, start_ns, annotations); self.add_slice_end(server_track, end_ns); } } } pub fn add_query_thread_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Query Threads"); // thread_name → track_uuid let mut thread_uuids: HashMap = HashMap::new(); // (host_uuid, thread_name) → track_uuid let mut server_thread_uuids: HashMap<(u64, String), u64> = HashMap::new(); for i in 0..columns.row_count() { let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let thread_name: String = columns.get(i, "thread_name").unwrap_or_default(); let host_name: String = columns.get(i, "host_name").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: query_thread_log row {} event_time_microseconds: {}", i, e ); continue; } }; let duration_ms: u64 = columns.get(i, "query_duration_ms").unwrap_or(0); let peak_memory: i64 = columns.get(i, "peak_memory_usage").unwrap_or(0); let names: Vec = columns.get(i, "ProfileEvents.Names").unwrap_or_default(); let values: Vec = columns.get(i, "ProfileEvents.Values").unwrap_or_default(); let track_uuid = *thread_uuids.entry(thread_name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &thread_name); uuid }); let end_ns = timestamp_ns; let start_ns = end_ns.saturating_sub(duration_ms * 1_000_000); let mut annotations = vec![ Self::make_annotation_str("query_id", &query_id), Self::make_annotation_str("thread_name", &thread_name), Self::make_annotation_int("peak_memory_usage", peak_memory), ]; // Add top ProfileEvents as annotations let mut pe: Vec<(String, u64)> = names.into_iter().zip(values).collect(); pe.sort_by(|a, b| b.1.cmp(&a.1)); for (name, value) in pe.iter().take(10) { if *value > 0 { annotations.push(Self::make_annotation_int(name, *value as i64)); } } self.add_slice_begin(track_uuid, &query_id, start_ns, annotations.clone()); self.add_slice_end(track_uuid, end_ns); if let Some(cat_uuid) = self.get_host_category_track(&host_name, "Query Threads") { let server_track = *server_thread_uuids .entry((cat_uuid, thread_name.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, cat_uuid, &thread_name); uuid }); self.add_slice_begin(server_track, &query_id, start_ns, annotations); self.add_slice_end(server_track, end_ns); } } } pub fn add_text_logs(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Query Logs"); // level → track_uuid let mut level_uuids: HashMap = HashMap::new(); // (host_uuid, level) → track_uuid let mut server_level_uuids: HashMap<(u64, String), u64> = HashMap::new(); let mut alp = if self.text_log_android { Some(AndroidLogPacket::new()) } else { None }; for i in 0..columns.row_count() { let level: String = columns.get(i, "level").unwrap_or_default(); let logger_name: String = columns.get(i, "logger_name").unwrap_or_default(); let message: String = columns.get(i, "message").unwrap_or_default(); let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let host_name: String = columns.get(i, "host_name").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: text_log row {} event_time_microseconds: {}", i, e ); continue; } }; let track_uuid = *level_uuids.entry(level.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &level); uuid }); let annotations = vec![ Self::make_annotation_str("query_id", &query_id), Self::make_annotation_str("level", &level), Self::make_annotation_str("logger", &logger_name), ]; self.add_instant(track_uuid, &message, timestamp_ns, annotations.clone()); if let Some(cat_uuid) = self.get_host_category_track(&host_name, "Query Logs") { let server_track = *server_level_uuids .entry((cat_uuid, level.clone())) .or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, cat_uuid, &level); uuid }); self.add_instant(server_track, &message, timestamp_ns, annotations); } if let Some(ref mut alp) = alp { let mut event = LogEvent::new(); event.timestamp = Some(timestamp_ns); event.tag = Some(logger_name); event.message = Some(message); event.prio = Some(EnumOrUnknown::new(Self::log_level_to_prio(&level))); alp.events.push(event); } } if let Some(alp) = alp.filter(|a| !a.events.is_empty()) { let first_ts = alp.events[0].timestamp.unwrap_or(0); let mut pkt = self.make_event_packet(first_ts); pkt.data = Some(Data::AndroidLog(alp)); self.packets.push(pkt); } } pub fn add_metric_log(&mut self, rows: &[MetricLogRow]) { if rows.is_empty() { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Metric Log"); // event_name → (track_uuid, running_total) let mut pe_tracks: HashMap = HashMap::new(); // metric_name → track_uuid let mut cm_tracks: HashMap = HashMap::new(); for row in rows { for (name, value) in &row.profile_events { let (unit, scale) = Self::unit_for_event(name); let scaled = *value as i64 * scale; let (track_uuid, running_total) = pe_tracks.entry(name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, name, unit); (uuid, 0) }); *running_total += scaled; self.add_counter_value(*track_uuid, row.timestamp_ns, *running_total); } for (name, value) in &row.current_metrics { let (unit, scale) = Self::unit_for_event(name); let track_uuid = *cm_tracks.entry(name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, name, unit); uuid }); self.add_counter_value(track_uuid, row.timestamp_ns, *value * scale); } } } pub fn add_asynchronous_metric_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Async Metrics"); let mut counter_tracks: HashMap = HashMap::new(); for i in 0..columns.row_count() { let metric: String = columns.get(i, "metric").unwrap_or_default(); let value: f64 = columns.get(i, "value").unwrap_or(0.0); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: asynchronous_metric_log row {} event_time_microseconds: {}", i, e ); continue; } }; let track_uuid = *counter_tracks.entry(metric.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_counter_track(uuid, process_uuid, &metric, Unit::UNIT_UNSPECIFIED); uuid }); self.add_counter_value(track_uuid, timestamp_ns, value as i64); } } pub fn add_asynchronous_insert_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Async Inserts"); let mut table_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let database: String = columns.get(i, "database").unwrap_or_default(); let table: String = columns.get(i, "table").unwrap_or_default(); let format: String = columns.get(i, "format").unwrap_or_default(); let status: String = columns.get(i, "status").unwrap_or_default(); let bytes: u64 = columns.get(i, "bytes").unwrap_or(0); let exception: String = columns.get(i, "exception").unwrap_or_default(); let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let start_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: asynchronous_insert_log row {} event_time_microseconds: {}", i, e ); continue; } }; let end_ns: u64 = match columns.get::, _>(i, "flush_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(_) => start_ns, }; let table_key = format!("{}.{}", database, table); let track_uuid = *table_uuids.entry(table_key.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &table_key); uuid }); let label = format!("{} ({})", table_key, status); let mut annotations = vec![ Self::make_annotation_str("query_id", &query_id), Self::make_annotation_str("format", &format), Self::make_annotation_str("status", &status), Self::make_annotation_int("bytes", bytes as i64), ]; if !exception.is_empty() { annotations.push(Self::make_annotation_str("exception", &exception)); } self.add_slice_begin(track_uuid, &label, start_ns, annotations); self.add_slice_end(track_uuid, end_ns); } } pub fn add_error_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Error Log"); let mut error_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let error: String = columns.get(i, "error").unwrap_or_default(); let code: i64 = columns.get(i, "code").unwrap_or(0); let value: u64 = columns.get(i, "value").unwrap_or(0); let remote: u8 = columns.get(i, "remote").unwrap_or(0); let last_error_message: String = columns.get(i, "last_error_message").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!("Perfetto: error_log row {} event_time: {}", i, e); continue; } }; let track_uuid = *error_uuids.entry(error.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &error); uuid }); let mut annotations = vec![ Self::make_annotation_int("code", code), Self::make_annotation_int("value", value as i64), Self::make_annotation_int("remote", remote as i64), ]; if !last_error_message.is_empty() { annotations.push(Self::make_annotation_str( "last_error_message", &last_error_message, )); } self.add_instant(track_uuid, &error, timestamp_ns, annotations); } } pub fn add_s3_queue_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "S3 Queue"); let track_uuid = self.alloc_uuid(); self.add_child_track(track_uuid, process_uuid, "files"); for i in 0..columns.row_count() { let file_name: String = columns.get(i, "file_name").unwrap_or_default(); let rows_processed: u64 = columns.get(i, "rows_processed").unwrap_or(0); let status: String = columns.get(i, "status").unwrap_or_default(); let exception: String = columns.get(i, "exception").unwrap_or_default(); let start_ns: u64 = match columns.get::, _>(i, "processing_start_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(_) => continue, }; let end_ns: u64 = match columns.get::, _>(i, "processing_end_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(_) => start_ns, }; let mut annotations = vec![ Self::make_annotation_str("file_name", &file_name), Self::make_annotation_int("rows_processed", rows_processed as i64), Self::make_annotation_str("status", &status), ]; if !exception.is_empty() { annotations.push(Self::make_annotation_str("exception", &exception)); } self.add_slice_begin(track_uuid, &file_name, start_ns, annotations); self.add_slice_end(track_uuid, end_ns); } } pub fn add_azure_queue_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Azure Queue"); let mut table_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let database: String = columns.get(i, "database").unwrap_or_default(); let table: String = columns.get(i, "table").unwrap_or_default(); let file_name: String = columns.get(i, "file_name").unwrap_or_default(); let rows_processed: u64 = columns.get(i, "rows_processed").unwrap_or(0); let status: String = columns.get(i, "status").unwrap_or_default(); let exception: String = columns.get(i, "exception").unwrap_or_default(); let start_ns: u64 = match columns.get::, _>(i, "processing_start_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(_) => continue, }; let end_ns: u64 = match columns.get::, _>(i, "processing_end_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(_) => start_ns, }; let table_key = format!("{}.{}", database, table); let track_uuid = *table_uuids.entry(table_key.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &table_key); uuid }); let mut annotations = vec![ Self::make_annotation_str("file_name", &file_name), Self::make_annotation_int("rows_processed", rows_processed as i64), Self::make_annotation_str("status", &status), ]; if !exception.is_empty() { annotations.push(Self::make_annotation_str("exception", &exception)); } self.add_slice_begin(track_uuid, &file_name, start_ns, annotations); self.add_slice_end(track_uuid, end_ns); } } pub fn add_blob_storage_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Blob Storage"); let mut type_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let event_type: String = columns.get(i, "event_type").unwrap_or_default(); let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let disk_name: String = columns.get(i, "disk_name").unwrap_or_default(); let bucket: String = columns.get(i, "bucket").unwrap_or_default(); let remote_path: String = columns.get(i, "remote_path").unwrap_or_default(); let data_size: u64 = columns.get(i, "data_size").unwrap_or(0); let error: String = columns.get(i, "error").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: blob_storage_log row {} event_time_microseconds: {}", i, e ); continue; } }; let track_uuid = *type_uuids.entry(event_type.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &event_type); uuid }); let mut annotations = vec![ Self::make_annotation_str("query_id", &query_id), Self::make_annotation_str("disk_name", &disk_name), Self::make_annotation_str("bucket", &bucket), Self::make_annotation_str("remote_path", &remote_path), Self::make_annotation_int("data_size", data_size as i64), ]; if !error.is_empty() { annotations.push(Self::make_annotation_str("error", &error)); } self.add_instant(track_uuid, &event_type, timestamp_ns, annotations); } } pub fn add_background_pool_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Background Pool"); let mut log_name_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let log_name: String = columns.get(i, "log_name").unwrap_or_default(); let database: String = columns.get(i, "database").unwrap_or_default(); let table: String = columns.get(i, "table").unwrap_or_default(); let query_id: String = columns.get(i, "query_id").unwrap_or_default(); let duration_ms: u64 = columns.get(i, "duration_ms").unwrap_or(0); let error: String = columns.get(i, "error").unwrap_or_default(); let exception: String = columns.get(i, "exception").unwrap_or_default(); let end_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: background_schedule_pool_log row {} event_time_microseconds: {}", i, e ); continue; } }; let start_ns = end_ns.saturating_sub(duration_ms * 1_000_000); let track_uuid = *log_name_uuids.entry(log_name.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &log_name); uuid }); let mut annotations = vec![ Self::make_annotation_str("database", &database), Self::make_annotation_str("table", &table), Self::make_annotation_str("query_id", &query_id), ]; if !error.is_empty() { annotations.push(Self::make_annotation_str("error", &error)); } if !exception.is_empty() { annotations.push(Self::make_annotation_str("exception", &exception)); } let label = format!("{}.{}", database, table); self.add_slice_begin(track_uuid, &label, start_ns, annotations); self.add_slice_end(track_uuid, end_ns); } } pub fn add_session_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "Sessions"); let mut type_uuids: HashMap = HashMap::new(); for i in 0..columns.row_count() { let session_type: String = columns.get(i, "type").unwrap_or_default(); let user: String = columns.get(i, "user").unwrap_or_default(); let auth_type: String = columns.get(i, "auth_type").unwrap_or_default(); let interface: String = columns.get(i, "interface").unwrap_or_default(); let client_address: String = columns.get(i, "client_address").unwrap_or_default(); let client_name: String = columns.get(i, "client_name").unwrap_or_default(); let failure_reason: String = columns.get(i, "failure_reason").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: session_log row {} event_time_microseconds: {}", i, e ); continue; } }; let track_uuid = *type_uuids.entry(session_type.clone()).or_insert_with(|| { let uuid = self.alloc_uuid(); self.add_child_track(uuid, process_uuid, &session_type); uuid }); let mut annotations = vec![ Self::make_annotation_str("user", &user), Self::make_annotation_str("auth_type", &auth_type), Self::make_annotation_str("interface", &interface), Self::make_annotation_str("client_address", &client_address), Self::make_annotation_str("client_name", &client_name), ]; if !failure_reason.is_empty() { annotations.push(Self::make_annotation_str("failure_reason", &failure_reason)); } let label = format!("{} ({})", session_type, user); self.add_instant(track_uuid, &label, timestamp_ns, annotations); } } pub fn add_aggregated_zookeeper_log(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } let process_uuid = self.alloc_uuid(); self.add_process_track(process_uuid, "ZooKeeper"); // operation → (count_track, latency_track) let mut op_tracks: HashMap = HashMap::new(); for i in 0..columns.row_count() { let operation: String = columns.get(i, "operation").unwrap_or_default(); let count: u64 = columns.get(i, "count").unwrap_or(0); let average_latency: f64 = columns.get(i, "average_latency").unwrap_or(0.0); let parent_path: String = columns.get(i, "parent_path").unwrap_or_default(); let component: String = columns.get(i, "component").unwrap_or_default(); let timestamp_ns: u64 = match columns.get::, _>(i, "event_time") { Ok(dt) => dt.with_timezone(&Local).timestamp_nanos_opt().unwrap_or(0) as u64, Err(e) => { log::warn!( "Perfetto: aggregated_zookeeper_log row {} event_time: {}", i, e ); continue; } }; let (count_track, latency_track) = *op_tracks.entry(operation.clone()).or_insert_with(|| { let ct = self.alloc_uuid(); self.add_counter_track( ct, process_uuid, &format!("{} count", operation), Unit::UNIT_UNSPECIFIED, ); let lt = self.alloc_uuid(); self.add_counter_track( lt, process_uuid, &format!("{} avg_latency", operation), Unit::UNIT_UNSPECIFIED, ); (ct, lt) }); self.add_counter_value(count_track, timestamp_ns, count as i64); self.add_counter_value(latency_track, timestamp_ns, average_latency as i64); // Also emit an instant with annotations for the detail if !parent_path.is_empty() || !component.is_empty() { let error_names: Vec = columns.get(i, "error_names").unwrap_or_default(); let error_counts: Vec = columns.get(i, "error_counts").unwrap_or_default(); let mut annotations = vec![ Self::make_annotation_str("parent_path", &parent_path), Self::make_annotation_str("component", &component), Self::make_annotation_int("count", count as i64), ]; for (en, ec) in error_names.iter().zip(error_counts.iter()) { annotations.push(Self::make_annotation_int(en, *ec as i64)); } // Use count_track for the instant self.add_instant(count_track, &operation, timestamp_ns, annotations); } } } fn alloc_intern_id(&mut self) -> u64 { let id = self.next_intern_id; self.next_intern_id += 1; id } // Add CPU/Real/Memory stack trace samples as StreamingProfilePacket. // // Perfetto profiling timeline pitfalls (hard-won lessons): // - Clock 128 is sequence-scoped: a ClockSnapshot on seq 1 does NOT help seq 2+. // - Built-in clocks (e.g. BOOTTIME=6) also fail on non-main sequences in practice. // - SEQ_INCREMENTAL_STATE_CLEARED nukes clock mappings on the sequence — never // use it on the main sequence after the ClockSnapshot. // - StreamingProfilePacket timestamps come from ThreadDescriptor.reference_timestamp_us // + timestamp_delta_us, NOT from TracePacket.timestamp. If reference_timestamp_us // is unset, all samples land at time 0. // - Samples go into cpu_profile_stack_sample table, not perf_sample. // // The working approach: each trace type gets its own sequence with a ThreadDescriptor // that carries reference_timestamp_us (microseconds). No clock_id needed on the // packets — timing is entirely from reference_timestamp_us + deltas. pub fn add_stack_traces(&mut self, columns: &Columns) { if columns.row_count() == 0 { return; } // Global: trace_type → samples let mut samples_by_type: HashMap> = HashMap::new(); // Per-server: (host_name, trace_type) → samples let mut samples_by_host_type: HashMap<(String, String), Vec> = HashMap::new(); // Interning accumulators for this batch let mut interned_strings: Vec = Vec::new(); let mut interned_frames: Vec = Vec::new(); let mut interned_callstacks: Vec = Vec::new(); let mapping_iid = self.alloc_intern_id(); for i in 0..columns.row_count() { let trace_type: String = columns.get(i, "trace_type").unwrap_or_default(); let stack: Vec = columns.get(i, "stack").unwrap_or_default(); if stack.is_empty() { continue; } let timestamp_us: i64 = match columns.get::, _>(i, "event_time_microseconds") { Ok(dt) => dt.with_timezone(&Local).timestamp_micros(), Err(e) => { log::warn!( "Perfetto: stack trace row {} event_time_microseconds: {}", i, e ); continue; } }; // Intern each frame in the stack let mut frame_ids = Vec::with_capacity(stack.len()); for func_name in &stack { let func_iid = *self .function_name_iids .entry(func_name.clone()) .or_insert_with(|| { let iid = self.next_intern_id; self.next_intern_id += 1; let mut is = InternedString::new(); is.iid = Some(iid); is.str = Some(func_name.as_bytes().to_vec()); interned_strings.push(is); iid }); let frame_key = (func_iid, mapping_iid); let frame_iid = *self.frame_iids.entry(frame_key).or_insert_with(|| { let iid = self.next_intern_id; self.next_intern_id += 1; let mut f = Frame::new(); f.iid = Some(iid); f.function_name_id = Some(func_iid); f.mapping_id = Some(mapping_iid); interned_frames.push(f); iid }); frame_ids.push(frame_iid); } let callstack_iid = *self .callstack_iids .entry(frame_ids.clone()) .or_insert_with(|| { let iid = self.next_intern_id; self.next_intern_id += 1; let mut cs = Callstack::new(); cs.iid = Some(iid); cs.frame_ids = frame_ids; interned_callstacks.push(cs); iid }); samples_by_type .entry(trace_type.clone()) .or_default() .push(Sample { callstack_iid, timestamp_us, }); if self.per_server { let host_name: String = columns.get(i, "host_name").unwrap_or_default(); if !host_name.is_empty() { samples_by_host_type .entry((host_name, trace_type)) .or_default() .push(Sample { callstack_iid, timestamp_us, }); } } } // Build one dummy mapping let mut mapping = Mapping::new(); mapping.iid = Some(mapping_iid); // Each trace_type gets its own sequence with a dedicated ThreadDescriptor. // Sample timestamps come from ThreadDescriptor.reference_timestamp_us + deltas, // so profiling packets don't need clock_id/timestamp (avoids sequence-scoped // clock 128 resolution issues on non-main sequences). for (trace_type, samples) in &samples_by_type { let name = format!("{} Samples", trace_type); self.emit_streaming_profile( &name, samples, &interned_strings, &interned_frames, &interned_callstacks, &mapping, ); } for ((host, trace_type), samples) in &samples_by_host_type { let name = format!("{}: {} Samples", host, trace_type); self.emit_streaming_profile( &name, samples, &interned_strings, &interned_frames, &interned_callstacks, &mapping, ); } } fn emit_streaming_profile( &mut self, thread_name: &str, samples: &[Sample], interned_strings: &[InternedString], interned_frames: &[Frame], interned_callstacks: &[Callstack], mapping: &Mapping, ) { if samples.is_empty() { return; } let seq_id = self.next_sequence_id; self.next_sequence_id += 1; let fake_tid = seq_id as i32; let mut td = PerfettoThreadDescriptor::new(); td.pid = Some(1); td.tid = Some(fake_tid); td.thread_name = Some(thread_name.to_string()); td.reference_timestamp_us = Some(samples[0].timestamp_us); let mut desc_pkt = TracePacket::new(); desc_pkt.set_trusted_packet_sequence_id(seq_id); desc_pkt.sequence_flags = Some(1 | 2); desc_pkt.trusted_pid = Some(1); desc_pkt.data = Some(Data::ThreadDescriptor(td)); self.packets.push(desc_pkt); let mut callstack_iids = Vec::with_capacity(samples.len()); let mut timestamp_deltas = Vec::with_capacity(samples.len()); let mut prev_us = samples[0].timestamp_us; for (idx, s) in samples.iter().enumerate() { callstack_iids.push(s.callstack_iid); if idx == 0 { timestamp_deltas.push(0); } else { timestamp_deltas.push(s.timestamp_us - prev_us); prev_us = s.timestamp_us; } } let mut spp = StreamingProfilePacket::new(); spp.callstack_iid = callstack_iids; spp.timestamp_delta_us = timestamp_deltas; let mut interned_data = InternedData::new(); interned_data.function_names = interned_strings.to_vec(); interned_data.frames = interned_frames.to_vec(); interned_data.callstacks = interned_callstacks.to_vec(); interned_data.mappings = vec![mapping.clone()]; let mut pkt = TracePacket::new(); pkt.set_trusted_packet_sequence_id(seq_id); pkt.sequence_flags = Some(2); pkt.trusted_pid = Some(1); pkt.interned_data = MessageField::some(interned_data); pkt.data = Some(Data::StreamingProfilePacket(spp)); self.packets.push(pkt); } /// Build a ClockSnapshot mapping all clocks with an identity transform. /// All at timestamp 0 with 1ns multiplier, so raw nanosecond values pass through as-is. /// Built-in clocks 1 (MONOTONIC), 3 (REALTIME), 6 (BOOTTIME) are needed because /// some packet types (e.g. AndroidLogPacket) have their timestamps resolved /// internally via built-in clocks. fn make_clock_snapshot() -> ClockSnapshot { let mut cs = ClockSnapshot::new(); let make_clock = |id: u32| -> Clock { let mut c = Clock::new(); c.clock_id = Some(id); c.timestamp = Some(0); c.unit_multiplier_ns = Some(1); c.is_incremental = Some(false); c }; cs.clocks = vec![ make_clock(CLOCK_ID_UNIXTIME), // 128 - sequence-scoped make_clock(1), // BUILTIN_CLOCK_MONOTONIC make_clock(3), // BUILTIN_CLOCK_REALTIME make_clock(6), // BUILTIN_CLOCK_BOOTTIME ]; cs } pub fn build(self) -> Vec { // ClockSnapshot with timestamp=0 in its own clock (self-referencing). // The trace processor resolves this specially for ClockSnapshot packets, // placing it at the very start of the trace (time 0). let cs = Self::make_clock_snapshot(); let mut cs_pkt = TracePacket::new(); cs_pkt.set_trusted_packet_sequence_id(SEQUENCE_ID); cs_pkt.sequence_flags = Some(1 | 2); cs_pkt.timestamp = Some(0); cs_pkt.timestamp_clock_id = Some(CLOCK_ID_UNIXTIME); cs_pkt.data = Some(Data::ClockSnapshot(cs)); let mut trace = Trace::new(); trace.packet = Vec::with_capacity(self.packets.len() + 1); trace.packet.push(cs_pkt); trace.packet.extend(self.packets); trace.write_to_bytes().unwrap_or_default() } } pub struct PerfettoServer { trace_data: Arc>>>>, #[allow(dead_code)] server_thread: Option>, } impl PerfettoServer { pub fn new() -> Self { let trace_data: Arc>>>> = Arc::new(Mutex::new(None)); let trace_data_clone = trace_data.clone(); let server_thread = std::thread::spawn(move || { let server = match tiny_http::Server::http("127.0.0.1:9001") { Ok(s) => s, Err(e) => { log::error!("Failed to start Perfetto HTTP server on port 9001: {}", e); return; } }; log::info!("Perfetto HTTP server listening on port 9001"); for request in server.incoming_requests() { let url = request.url().to_string(); log::trace!("Perfetto HTTP request: {} {}", request.method(), url); if request.method() == &tiny_http::Method::Options { let response = tiny_http::Response::empty(200) .with_header( "Access-Control-Allow-Origin: *" .parse::() .unwrap(), ) .with_header( "Access-Control-Allow-Methods: GET, POST, OPTIONS" .parse::() .unwrap(), ) .with_header( "Access-Control-Allow-Headers: *" .parse::() .unwrap(), ); request.respond(response).ok(); continue; } if url == "/trace" { let data: Option>> = trace_data_clone.lock().unwrap().clone(); match data { Some(bytes) => { let response = tiny_http::Response::from_data((*bytes).clone()) .with_header( "Content-Type: application/octet-stream" .parse::() .unwrap(), ) .with_header( "Access-Control-Allow-Origin: *" .parse::() .unwrap(), ); request.respond(response).ok(); } None => { let response = tiny_http::Response::from_string("No trace data available") .with_status_code(404) .with_header( "Access-Control-Allow-Origin: *" .parse::() .unwrap(), ); request.respond(response).ok(); } } } else { let response = tiny_http::Response::from_string("Not Found") .with_status_code(404) .with_header( "Access-Control-Allow-Origin: *" .parse::() .unwrap(), ); request.respond(response).ok(); } } }); PerfettoServer { trace_data, server_thread: Some(server_thread), } } pub fn set_trace(&self, data: Vec) { *self.trace_data.lock().unwrap() = Some(Arc::new(data)); } pub fn get_perfetto_url(&self) -> String { "https://ui.perfetto.dev/#!/?url=http://127.0.0.1:9001/trace".to_string() } } ================================================ FILE: src/interpreter/query.rs ================================================ use anyhow::Result; use chrono::{DateTime, Local}; use chrono_tz::Tz; use size::{Base, SizeFormatter, Style}; use std::collections::HashMap; use std::fmt; use super::clickhouse::Columns; // Analog of mapFromArrays() in ClickHouse fn map_from_arrays(keys: Vec, values: Vec) -> HashMap where K: std::hash::Hash + std::cmp::Eq, { let mut map = HashMap::new(); for (k, v) in keys.into_iter().zip(values) { map.insert(k, v); } return map; } #[derive(Clone, Debug)] pub struct Query { pub selection: bool, pub host_name: String, pub display_host_name: Option, pub user: String, pub threads: usize, pub memory: i64, pub elapsed: f64, pub query_start_time_microseconds: DateTime, pub query_end_time_microseconds: DateTime, // Is the name good enough? Maybe simply "queries" or "shards_queries"? pub subqueries: u64, pub is_initial_query: bool, pub is_cancelled: bool, pub initial_query_id: String, pub query_id: String, pub normalized_query: String, pub original_query: String, pub current_database: String, pub profile_events: HashMap, pub settings: HashMap, // Used for metric rates (like top(1) shows) pub prev_elapsed: Option, pub prev_profile_events: Option>, // If running is true, then the metrics will be shown as per-second rate, otherwise raw data. // Since for system.processes we indeed the rates, while for slow queries/last queries raw // data. pub running: bool, } impl Query { /// Creates a Query from a ClickHouse block at the specified row index pub fn from_clickhouse_block( columns: &Columns, row_index: usize, running: bool, ) -> Result { let mut profile_events = map_from_arrays( columns.get::, _>(row_index, "ProfileEvents.Names")?, columns.get::, _>(row_index, "ProfileEvents.Values")?, ); let mut settings = map_from_arrays( columns.get::, _>(row_index, "Settings.Names")?, columns.get::, _>(row_index, "Settings.Values")?, ); // FIXME: Shrinking is slow, but without it memory consumption is too high, 100-200x // more! This is because by some reason the capacity inside clickhouse.rs is 4096, // which is ~100x more then we need for ProfileEvents (~40). profile_events.shrink_to_fit(); settings.shrink_to_fit(); Ok(Query { selection: false, host_name: columns.get::<_, _>(row_index, "host_name")?, display_host_name: None, user: columns.get::<_, _>(row_index, "user")?, threads: columns.get::(row_index, "peak_threads_usage")? as usize, memory: columns.get::<_, _>(row_index, "peak_memory_usage")?, elapsed: columns.get::<_, _>(row_index, "elapsed")?, query_start_time_microseconds: columns .get::, _>(row_index, "query_start_time_microseconds")? .with_timezone(&Local), query_end_time_microseconds: columns .get::, _>(row_index, "query_end_time_microseconds")? .with_timezone(&Local), subqueries: 1, // See queries_count_subqueries() is_initial_query: columns.get::(row_index, "is_initial_query")? == 1, is_cancelled: columns.get::(row_index, "is_cancelled")? == 1, initial_query_id: columns.get::<_, _>(row_index, "initial_query_id")?, query_id: columns.get::<_, _>(row_index, "query_id")?, normalized_query: columns.get::<_, _>(row_index, "normalized_query")?, original_query: columns.get::<_, _>(row_index, "original_query")?, current_database: columns.get::<_, _>(row_index, "current_database")?, profile_events, settings, prev_elapsed: None, prev_profile_events: None, running, }) } // NOTE: maybe it should be corrected with moving sampling? pub fn cpu(&self) -> f64 { if !self.running { let ms = *self .profile_events .get("OSCPUVirtualTimeMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 * 100.; } if let Some(prev_profile_events) = &self.prev_profile_events { let ms_prev = *prev_profile_events .get("OSCPUVirtualTimeMicroseconds") .unwrap_or(&0); let ms_now = *self .profile_events .get("OSCPUVirtualTimeMicroseconds") .unwrap_or(&0); let elapsed = self.elapsed - self.prev_elapsed.unwrap(); if elapsed > 0. { // It is possible to overflow, at least because metrics for initial queries is // summarized, and when query on some node will be finished (non initial), then initial // query will have less data. return ms_now.saturating_sub(ms_prev) as f64 / 1e6 / elapsed * 100.; } } let ms = *self .profile_events .get("OSCPUVirtualTimeMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 / self.elapsed * 100.; } pub fn io_wait(&self) -> f64 { if !self.running { let ms = *self .profile_events .get("OSIOWaitMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 * 100.; } if let Some(prev_profile_events) = &self.prev_profile_events { let ms_prev = *prev_profile_events .get("OSIOWaitMicroseconds") .unwrap_or(&0); let ms_now = *self .profile_events .get("OSIOWaitMicroseconds") .unwrap_or(&0); let elapsed = self.elapsed - self.prev_elapsed.unwrap(); if elapsed > 0. { // It is possible to overflow, at least because metrics for initial queries is // summarized, and when query on some node will be finished (non initial), then initial // query will have less data. return ms_now.saturating_sub(ms_prev) as f64 / 1e6 / elapsed * 100.; } } let ms = *self .profile_events .get("OSIOWaitMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 / self.elapsed * 100.; } pub fn cpu_wait(&self) -> f64 { if !self.running { let ms = *self .profile_events .get("OSCPUWaitMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 * 100.; } if let Some(prev_profile_events) = &self.prev_profile_events { let ms_prev = *prev_profile_events .get("OSCPUWaitMicroseconds") .unwrap_or(&0); let ms_now = *self .profile_events .get("OSCPUWaitMicroseconds") .unwrap_or(&0); let elapsed = self.elapsed - self.prev_elapsed.unwrap(); if elapsed > 0. { // It is possible to overflow, at least because metrics for initial queries is // summarized, and when query on some node will be finished (non initial), then initial // query will have less data. return ms_now.saturating_sub(ms_prev) as f64 / 1e6 / elapsed * 100.; } } let ms = *self .profile_events .get("OSCPUWaitMicroseconds") .unwrap_or(&0); return (ms as f64) / 1e6 / self.elapsed * 100.; } pub fn net_io(&self) -> f64 { return self.get_per_second_rate_events_multi(&[ "NetworkSendBytes", "NetworkReceiveBytes", "ReadBufferFromS3Bytes", "WriteBufferFromS3Bytes", ]); } pub fn disk_io(&self) -> f64 { return self.get_per_second_rate_events_multi(&[ "WriteBufferFromFileDescriptorWriteBytes", // Note that it may differs from ReadCompressedBytes, since later takes into account // network. "ReadBufferFromFileDescriptorReadBytes", ]); } pub fn io(&self) -> f64 { return self.get_per_second_rate_events_multi(&[ // Though sometimes it is bigger the the real uncompressed reads, so maybe it is better // to use CompressedReadBufferBytes instead. // But yes it will not take into account non-compressed reads, but this should be rare // (except for the cases when the MergeTree is used with CODEC NONE). "SelectedBytes", "InsertedBytes", ]); } fn get_profile_events_multi(&self, names: &[&'static str]) -> u64 { let mut result: u64 = 0; for &name in names { result += *self.profile_events.get(name).unwrap_or(&0); } return result; } fn get_prev_profile_events_multi(&self, names: &[&'static str]) -> u64 { let mut result: u64 = 0; for &name in names { result += *self .prev_profile_events .as_ref() .unwrap() .get(name) .unwrap_or(&0); } return result; } fn get_per_second_rate_events_multi(&self, events: &[&'static str]) -> f64 { if !self.running { return self.get_profile_events_multi(events) as f64; } if self.prev_profile_events.is_some() { let now = self.get_profile_events_multi(events); let prev = self.get_prev_profile_events_multi(events); let diff = now.saturating_sub(prev); let elapsed = self.elapsed - self.prev_elapsed.unwrap(); if elapsed > 0. { return (diff as f64) / elapsed; } } let value = self.get_profile_events_multi(events); return value as f64 / self.elapsed; } } impl fmt::Display for Query { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let formatter = SizeFormatter::new() .with_base(Base::Base10) .with_style(Style::Abbreviated); let memory_str = formatter.format(self.memory); let status = if self.running { "Running" } else { "Finished" }; writeln!(f, "Query ID: {}", self.query_id)?; writeln!(f, "Initial Query ID: {}", self.initial_query_id)?; writeln!(f, "Status: {}", status)?; writeln!(f, "Is Initial: {}", self.is_initial_query)?; writeln!(f, "Is Cancelled: {}", self.is_cancelled)?; writeln!(f, "Subqueries: {}", self.subqueries)?; writeln!(f, "Host: {}", self.host_name)?; writeln!(f, "User: {}", self.user)?; writeln!(f, "Database: {}", self.current_database)?; writeln!(f, "Threads: {}", self.threads)?; writeln!(f, "Memory: {}", memory_str)?; writeln!(f, "Elapsed: {:.2}s", self.elapsed)?; writeln!(f, "CPU: {:.1}%", self.cpu())?; writeln!(f, "IO Wait: {:.1}%", self.io_wait())?; writeln!(f, "CPU Wait: {:.1}%", self.cpu_wait())?; writeln!( f, "Start Time: {}", self.query_start_time_microseconds .format("%Y-%m-%d %H:%M:%S") )?; writeln!( f, "End Time: {}", self.query_end_time_microseconds.format("%Y-%m-%d %H:%M:%S") )?; writeln!(f, "Query:")?; write!(f, "{}", self.original_query) } } ================================================ FILE: src/interpreter/worker.rs ================================================ use crate::{ common::{RelativeDateTime, Stopwatch}, interpreter::{ ContextArc, Query, clickhouse::{ClickHouse, TextLogArguments, TraceType}, flamegraph, perfetto::PerfettoTraceBuilder, }, pastila, utils::{highlight_sql, share_graph}, view::{self, Navigation}, }; use anyhow::{Result, anyhow}; use chrono::{DateTime, Local}; // FIXME: "leaky abstractions" use clickhouse_rs::errors::Error as ClickHouseError; use cursive::traits::*; use cursive::views; use futures::channel::mpsc; use std::collections::{HashMap, hash_map::Entry}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; #[derive(Debug, Clone)] pub enum Event { // [filter, limit] ProcessList(String, u64), // [filter, start, end, limit] SlowQueryLog(String, RelativeDateTime, RelativeDateTime, u64), // [filter, start, end, limit] LastQueryLog(String, RelativeDateTime, RelativeDateTime, u64), // (view_name, args) TextLog(&'static str, TextLogArguments), // [bool (true - show in TUI, false - share via pastila), type, start, end] ServerFlameGraph(bool, TraceType, DateTime, DateTime), // [bool (true - show in TUI, false - share via pastila)] JemallocFlameGraph(bool), // (type, bool (true - show in TUI, false - open in browser), start time, end time, [query_ids]) QueryFlameGraph( TraceType, bool, DateTime, Option>, Vec, ), // (type, start time, end time, [query_ids_a = before], [query_ids_b = after]). // Diff mode is TUI-only (color-coded via flamelens), no share path. QueryFlameGraphDiff( TraceType, DateTime, Option>, Vec, Vec, ), // [bool (true - show in TUI, false - open in browser), query_ids] LiveQueryFlameGraph(bool, Option>), Summary, // query_id KillQuery(String), // (database, query) ExecuteQuery(String, String), // (database, query) ExplainSyntax(String, String, HashMap), // (database, query) ExplainPlan(String, String), // (database, query) ExplainPipeline(String, String), // (database, query) ExplainPipelineShareGraph(String, String), // (database, query) ExplainPlanIndexes(String, String), // (database, table) ShowCreateTable(String, String), // (view_name, query) SQLQuery(&'static str, String), // (log_name, database, table, start, end) BackgroundSchedulePoolLogs( Option, String, String, RelativeDateTime, RelativeDateTime, ), // (database, table) TableParts(String, String), // (database, table) AsynchronousInserts(String, String), // (content to share via pastila) ShareLogs(String), // (queries, query_ids, start, end) PerfettoExport( Vec, Vec, DateTime, Option>, ), // (start, end) ServerPerfettoExport(DateTime, DateTime), } impl Event { fn enum_key(&self) -> String { match self { Event::ProcessList(..) => "ProcessList".to_string(), Event::SlowQueryLog(..) => "SlowQueryLog".to_string(), Event::LastQueryLog(..) => "LastQueryLog".to_string(), Event::TextLog(..) => "TextLog".to_string(), Event::ServerFlameGraph(..) => "ServerFlameGraph".to_string(), Event::JemallocFlameGraph(..) => "JemallocFlameGraph".to_string(), Event::QueryFlameGraph(..) => "QueryFlameGraph".to_string(), Event::QueryFlameGraphDiff(..) => "QueryFlameGraphDiff".to_string(), Event::LiveQueryFlameGraph(..) => "LiveQueryFlameGraph".to_string(), Event::Summary => "Summary".to_string(), Event::KillQuery(..) => "KillQuery".to_string(), Event::ExecuteQuery(..) => "ExecuteQuery".to_string(), Event::ExplainSyntax(..) => "ExplainSyntax".to_string(), Event::ExplainPlan(..) => "ExplainPlan".to_string(), Event::ExplainPipeline(..) => "ExplainPipeline".to_string(), Event::ExplainPipelineShareGraph(..) => "ExplainPipelineShareGraph".to_string(), Event::ExplainPlanIndexes(..) => "ExplainPlanIndexes".to_string(), Event::ShowCreateTable(..) => "ShowCreateTable".to_string(), Event::SQLQuery(view_name, _query) => format!("SQLQuery({})", view_name), Event::BackgroundSchedulePoolLogs(..) => "BackgroundSchedulePoolLogs".to_string(), Event::TableParts(..) => "TableParts".to_string(), Event::AsynchronousInserts(..) => "AsynchronousInserts".to_string(), Event::ShareLogs(..) => "ShareLogs".to_string(), Event::PerfettoExport(..) => "PerfettoExport".to_string(), Event::ServerPerfettoExport(..) => "ServerPerfettoExport".to_string(), } } } type ReceiverArc = Arc>>; type Sender = mpsc::Sender; pub struct Worker { sender: Sender, sender_by_event: HashMap, receiver: ReceiverArc, thread: Option>, paused: bool, } // TODO: can we simplify things with callbacks? (EnumValue(Type)) impl Worker { pub fn new() -> Self { // Here the futures::channel::mpsc::channel is used over standard std::sync::mpsc::channel, // since standard does not allow to configure backlog (queue max size), while we uses // channel per distinct event (to avoid running multiple queries for the same view, since // it does not make sense), i.e. separate channel for Summary, separate for // UpdateProcessList and so on. // // Note, by default channel reserves slot for each sender [1]. // // [1]: https://github.com/rust-lang/futures-rs/issues/403 let (sender, receiver) = mpsc::channel::(1); let receiver = Arc::new(Mutex::new(receiver)); return Worker { sender, sender_by_event: HashMap::new(), receiver, thread: None, paused: false, }; } pub fn start(&mut self, context: ContextArc) { let receiver = self.receiver.clone(); let context = context.clone(); self.thread = Some(std::thread::spawn(move || { start_tokio(context, receiver); })); } pub fn toggle_pause(&mut self) { self.paused = !self.paused; log::trace!( "Toggle pause ({})", if self.paused { "paused" } else { "unpaused" } ); } pub fn is_paused(&self) -> bool { return self.paused; } // @force - ignore pause pub fn send(&mut self, force: bool, event: Event) { if !force && self.paused { return; } let entry = self.sender_by_event.entry(event.enum_key()); let channel_created = matches!(&entry, Entry::Vacant(_)); let sender = entry.or_insert(self.sender.clone()); log::trace!( "Sending event: {:?} (channel created: {})", &event, channel_created ); // Simply ignore errors (queue is full, likely update interval is too short). sender.try_send(event.clone()).unwrap_or_else(|e| { log::error!( "Cannot send event {:?}: {} (too low --delay-interval?)", event, e ) }); } } #[tokio::main(flavor = "current_thread")] async fn start_tokio(context: ContextArc, receiver: ReceiverArc) { log::info!("Event worker started"); loop { let event = match receiver.lock().unwrap().try_recv() { Ok(event) => event, // Channel closed. Err(mpsc::TryRecvError::Closed) => break, // No message available. Err(mpsc::TryRecvError::Empty) => { // Same as INPUT_POLL_DELAY_MS, but I hate such implementations, both should be fixed. thread::sleep(Duration::from_millis(30)); continue; } }; log::trace!("Got event: {:?}", event); let mut need_clear = false; let cb_sink = context.lock().unwrap().cb_sink.clone(); let options = context.lock().unwrap().options.clone(); let update_status = |message: &str| { let content = message.to_string(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.set_statusbar_content(content); })) // Ignore errors on exit .unwrap_or_default(); }; update_status(&format!("Processing {}...", event.enum_key())); let debug_metrics = context.lock().unwrap().debug_metrics.clone(); // RAII: decrements on scope exit, including panic or early return paths. let _in_flight = debug_metrics.track_in_flight(); let stopwatch = Stopwatch::start_new(); if let Err(err) = process_event(context.clone(), event.clone(), &mut need_clear).await { cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { let is_paused = siv .user_data::() .unwrap() .lock() .unwrap() .worker .is_paused(); if !is_paused { siv.toggle_pause_updates(Some("due previous errors")); } const CLICKHOUSE_ERROR_CODE_ALL_CONNECTION_TRIES_FAILED: u32 = 279; let has_cluster = siv .user_data::() .unwrap() .lock() .unwrap() .options .clickhouse .cluster .as_ref() .is_some_and(|v| !v.is_empty()); if has_cluster && let Some(ClickHouseError::Server(server_error)) = &err.downcast_ref::() && server_error.code == CLICKHOUSE_ERROR_CODE_ALL_CONNECTION_TRIES_FAILED { siv.add_layer(views::Dialog::info(format!( "{}\n(consider adding skip_unavailable_shards=1 to the connection URL)", err ))); return; } siv.add_layer(views::Dialog::info(err.to_string())); })) // Ignore errors on exit .unwrap_or_default(); } let elapsed = stopwatch.elapsed(); debug_metrics.record_event(elapsed); let mut completion_status = format!( "Processing {} took {} ms.", event.enum_key(), elapsed.as_millis() ); // It should not be reset, since delay_interval should be set to the maximum service // query duration time. if stopwatch.elapsed() > options.view.delay_interval { completion_status.push_str(" (consider increasing --delay_interval)"); } update_status(&completion_status); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { if need_clear { siv.complete_clear(); } siv.on_event(cursive::event::Event::Refresh); })) // Ignore errors on exit .unwrap_or_default(); } log::info!("Event worker finished"); } async fn render_or_share_flamegraph( tui: bool, cb_sink: cursive::CbSink, title: &'static str, data: String, pastila_clickhouse_host: String, pastila_url: String, ) -> Result<()> { if tui { cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { flamegraph::show(title, data) .or_else(|e| { siv.add_layer(views::Dialog::info(e.to_string())); return anyhow::Ok(()); }) .unwrap(); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } else { let url = flamegraph::share(data, &pastila_clickhouse_host, &pastila_url).await?; let url_clone = url.clone(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::text(format!("Flamegraph shared (encrypted):\n\n{}", url)) .title("Share Complete") .button("Close", |siv| { siv.pop_layer(); }), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; crate::utils::open_url_command(&url_clone).status()?; } return Ok(()); } use crate::interpreter::options::ChDigPerfettoConfig; async fn fetch_and_populate_perfetto_trace( clickhouse: &Arc, builder: &mut PerfettoTraceBuilder, cfg: &ChDigPerfettoConfig, query_ids: Option<&[String]>, start: DateTime, end_time: DateTime, ) { let (otel, trace_log, metrics, parts, threads, stack_traces, text_logs) = tokio::join!( async { if cfg.opentelemetry_span_log { Some( clickhouse .get_otel_spans_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.trace_log { Some( clickhouse .get_trace_log_counters_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.query_metric_log { Some( clickhouse .get_query_metrics_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.part_log { Some( clickhouse .get_part_log_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.query_thread_log { Some( clickhouse .get_query_thread_log_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.trace_log { Some( clickhouse .get_stack_traces_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, async { if cfg.text_log { Some( clickhouse .get_text_log_for_perfetto(query_ids, start, end_time) .await, ) } else { None } }, ); match otel { Some(Ok(block)) => builder.add_otel_spans(&block), Some(Err(e)) => log::warn!("Failed to fetch opentelemetry_span_log: {}", e), None => {} } match trace_log { Some(Ok(block)) => builder.add_trace_log_counters(&block), Some(Err(e)) => log::warn!("Failed to fetch trace_log counters: {}", e), None => {} } match metrics { Some(Ok(rows)) => builder.add_query_metrics(&rows), Some(Err(e)) => log::warn!("Failed to fetch query_metric_log: {}", e), None => {} } match parts { Some(Ok(block)) => builder.add_part_log(&block), Some(Err(e)) => log::warn!("Failed to fetch part_log: {}", e), None => {} } match threads { Some(Ok(block)) => builder.add_query_thread_log(&block), Some(Err(e)) => log::warn!("Failed to fetch query_thread_log: {}", e), None => {} } match stack_traces { Some(Ok(block)) => builder.add_stack_traces(&block), Some(Err(e)) => log::warn!("Failed to fetch trace_log stack traces: {}", e), None => {} } match text_logs { Some(Ok(block)) => builder.add_text_logs(&block), Some(Err(e)) => log::warn!("Failed to fetch text_log: {}", e), None => {} } } async fn fetch_server_perfetto_sources( clickhouse: &Arc, builder: &mut PerfettoTraceBuilder, cfg: &ChDigPerfettoConfig, start: DateTime, end_time: DateTime, ) { let ( metric_log, async_metric_log, async_insert_log, error_log, s3_queue_log, azure_queue_log, blob_storage_log, bg_pool_log, session_log, zk_log, ) = tokio::join!( async { if cfg.metric_log { Some( clickhouse .get_metric_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.asynchronous_metric_log { Some( clickhouse .get_asynchronous_metric_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.asynchronous_insert_log { Some( clickhouse .get_asynchronous_insert_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.error_log { Some(clickhouse.get_error_log_for_perfetto(start, end_time).await) } else { None } }, async { if cfg.s3_queue_log { Some( clickhouse .get_s3_queue_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.azure_queue_log { Some( clickhouse .get_azure_queue_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.blob_storage_log { Some( clickhouse .get_blob_storage_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.background_schedule_pool_log { Some( clickhouse .get_background_schedule_pool_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.session_log { Some( clickhouse .get_session_log_for_perfetto(start, end_time) .await, ) } else { None } }, async { if cfg.aggregated_zookeeper_log { Some( clickhouse .get_aggregated_zookeeper_log_for_perfetto(start, end_time) .await, ) } else { None } }, ); match metric_log { Some(Ok(rows)) => builder.add_metric_log(&rows), Some(Err(e)) => log::warn!("Failed to fetch metric_log: {}", e), None => {} } match async_metric_log { Some(Ok(block)) => builder.add_asynchronous_metric_log(&block), Some(Err(e)) => log::warn!("Failed to fetch asynchronous_metric_log: {}", e), None => {} } match async_insert_log { Some(Ok(block)) => builder.add_asynchronous_insert_log(&block), Some(Err(e)) => log::warn!("Failed to fetch asynchronous_insert_log: {}", e), None => {} } match error_log { Some(Ok(block)) => builder.add_error_log(&block), Some(Err(e)) => log::warn!("Failed to fetch error_log: {}", e), None => {} } match s3_queue_log { Some(Ok(block)) => builder.add_s3_queue_log(&block), Some(Err(e)) => log::warn!("Failed to fetch s3queue_log: {}", e), None => {} } match azure_queue_log { Some(Ok(block)) => builder.add_azure_queue_log(&block), Some(Err(e)) => log::warn!("Failed to fetch azure_queue_log: {}", e), None => {} } match blob_storage_log { Some(Ok(block)) => builder.add_blob_storage_log(&block), Some(Err(e)) => log::warn!("Failed to fetch blob_storage_log: {}", e), None => {} } match bg_pool_log { Some(Ok(block)) => builder.add_background_pool_log(&block), Some(Err(e)) => log::warn!("Failed to fetch background_schedule_pool_log: {}", e), None => {} } match session_log { Some(Ok(block)) => builder.add_session_log(&block), Some(Err(e)) => log::warn!("Failed to fetch session_log: {}", e), None => {} } match zk_log { Some(Ok(block)) => builder.add_aggregated_zookeeper_log(&block), Some(Err(e)) => log::warn!("Failed to fetch aggregated_zookeeper_log: {}", e), None => {} } } fn serve_perfetto_trace( context: ContextArc, cb_sink: cursive::CbSink, builder: PerfettoTraceBuilder, ) -> Result<()> { let data = builder.build(); let data_len = data.len(); if let Err(e) = std::fs::write("/tmp/chdig_perfetto.pftrace", &data) { log::warn!("Failed to save debug trace: {}", e); } else { log::info!( "Saved debug trace to /tmp/chdig_perfetto.pftrace ({} bytes)", data_len ); } let server = context.lock().unwrap().get_or_start_perfetto_server(); server.set_trace(data); let url = server.get_perfetto_url(); let url_clone = url.clone(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::text(format!( "Perfetto trace exported ({} bytes)\n\nOpening: {}", data_len, url )) .title("Perfetto Export") .button("Close", |siv| { siv.pop_layer(); }), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; crate::utils::open_url_command(&url_clone).status()?; Ok(()) } async fn process_event(context: ContextArc, event: Event, need_clear: &mut bool) -> Result<()> { let cb_sink = context.lock().unwrap().cb_sink.clone(); let clickhouse = context.lock().unwrap().clickhouse.clone(); let pastila_clickhouse_host = context .lock() .unwrap() .options .service .pastila_clickhouse_host .clone(); let pastila_url = context.lock().unwrap().options.service.pastila_url.clone(); let selected_host = context.lock().unwrap().selected_host.clone(); match event { Event::ProcessList(filter, limit) => { let block = clickhouse .get_processlist(filter, limit, selected_host.as_ref()) .await?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.call_on_name_or_render_error( "processes", move |view: &mut views::OnEventView| { return view.get_inner_mut().update(block); }, ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::SlowQueryLog(filter, start, end, limit) => { let block = clickhouse .get_slow_query_log(&filter, start, end, limit, selected_host.as_ref()) .await?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.call_on_name_or_render_error( "slow_query_log", move |view: &mut views::OnEventView| { return view.get_inner_mut().update(block); }, ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::LastQueryLog(filter, start, end, limit) => { let block = clickhouse .get_last_query_log(&filter, start, end, limit, selected_host.as_ref()) .await?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.call_on_name_or_render_error( "last_query_log", move |view: &mut views::OnEventView| { return view.get_inner_mut().update(block); }, ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::TextLog(view_name, args) => { let block = clickhouse.get_query_logs(&args).await?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.call_on_name_or_render_error( view_name, move |view: &mut view::TextLogView| { return view.update(block); }, ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ServerFlameGraph(tui, trace_type, start, end) => { let flamegraph_block = clickhouse .get_flamegraph( trace_type, None, Some(start), Some(end), selected_host.as_ref(), ) .await?; render_or_share_flamegraph( tui, cb_sink, "Server", flamegraph::block_to_folded(&flamegraph_block), pastila_clickhouse_host, pastila_url, ) .await?; *need_clear = true; } Event::JemallocFlameGraph(tui) => { let flamegraph_block = clickhouse .get_jemalloc_flamegraph(selected_host.as_ref()) .await?; render_or_share_flamegraph( tui, cb_sink, "jemalloc", flamegraph::block_to_folded(&flamegraph_block), pastila_clickhouse_host, pastila_url, ) .await?; *need_clear = true; } Event::QueryFlameGraph(trace_type, tui, start, end, query_ids) => { let flamegraph_block = clickhouse .get_flamegraph( trace_type, Some(&query_ids), Some(start), end, selected_host.as_ref(), ) .await?; render_or_share_flamegraph( tui, cb_sink, "Query", flamegraph::block_to_folded(&flamegraph_block), pastila_clickhouse_host, pastila_url, ) .await?; *need_clear = true; } Event::QueryFlameGraphDiff(trace_type, start, end, query_ids_a, query_ids_b) => { let (block_a, block_b) = tokio::try_join!( clickhouse.get_flamegraph( trace_type.clone(), Some(&query_ids_a), Some(start), end, selected_host.as_ref(), ), clickhouse.get_flamegraph( trace_type, Some(&query_ids_b), Some(start), end, selected_host.as_ref(), ), )?; let before = flamegraph::block_to_folded(&block_a); let after = flamegraph::block_to_folded(&block_b); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { flamegraph::show_diff("Query diff", before, after) .or_else(|e| { siv.add_layer(views::Dialog::info(e.to_string())); return anyhow::Ok(()); }) .unwrap(); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; *need_clear = true; } Event::LiveQueryFlameGraph(tui, query_ids) => { let flamegraph_block = clickhouse .get_live_query_flamegraph(&query_ids, selected_host.as_ref()) .await?; render_or_share_flamegraph( tui, cb_sink, "Query (live)", flamegraph::block_to_folded(&flamegraph_block), pastila_clickhouse_host, pastila_url, ) .await?; *need_clear = true; } Event::ExplainPlanIndexes(database, query) => { let plan = clickhouse .explain_plan_indexes(database.as_str(), query.as_str()) .await? .join("\n"); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::around( views::LinearLayout::vertical() .child(views::TextView::new("EXPLAIN PLAN indexes=1").center()) .child(views::DummyView.fixed_height(1)) .child(views::TextView::new(plan)), ) .scrollable(), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ExecuteQuery(database, query) => { let stopwatch = Stopwatch::start_new(); clickhouse .execute_query(database.as_str(), query.as_str()) .await?; // TODO: print results? cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer(views::Dialog::info(format!( "Query executed ({} ms). Look results in 'Last queries'", stopwatch.elapsed_ms(), ))); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ExplainSyntax(database, query, settings) => { let query = clickhouse .explain_syntax(database.as_str(), query.as_str(), &settings) .await? .join("\n"); let query = highlight_sql(&query)?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::around( views::LinearLayout::vertical() .child(views::TextView::new("EXPLAIN SYNTAX").center()) .child(views::DummyView.fixed_height(1)) .child(views::TextView::new(query)), ) .scrollable(), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ExplainPlan(database, query) => { let plan = clickhouse .explain_plan(database.as_str(), query.as_str()) .await? .join("\n"); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::around( views::LinearLayout::vertical() .child(views::TextView::new("EXPLAIN PLAN").center()) .child(views::DummyView.fixed_height(1)) .child(views::TextView::new(plan)), ) .scrollable(), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ExplainPipeline(database, query) => { let pipeline = clickhouse .explain_pipeline(database.as_str(), query.as_str()) .await? .join("\n"); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::around( views::LinearLayout::vertical() .child(views::TextView::new("EXPLAIN PIPELINE").center()) .child(views::DummyView.fixed_height(1)) .child(views::TextView::new(pipeline)), ) .scrollable(), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ExplainPipelineShareGraph(database, query) => { let pipeline = clickhouse .explain_pipeline_graph(database.as_str(), query.as_str()) .await? .join("\n"); // Upload graph to pastila and open in browser match share_graph(pipeline, &pastila_clickhouse_host, &pastila_url).await { Ok(_) => {} Err(err) => { let error_msg = err.to_string(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer(views::Dialog::info(error_msg)); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } } } Event::ShowCreateTable(database, table) => { let create_statement = clickhouse .show_create_table(database.as_str(), table.as_str()) .await?; let create_statement = highlight_sql(&create_statement)?; let title = format!("CREATE TABLE {}.{}", database, table); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer( views::Dialog::around(views::TextView::new(create_statement).scrollable()) .title(title), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::KillQuery(query_id) => { let start = Instant::now(); let ret = clickhouse.kill_query(query_id.as_str()).await; let elapsed = start.elapsed(); // NOTE: should we do this via cursive, to block the UI? let message; if let Err(err) = ret { message = format!("{} (elapsed: {:?})", err, elapsed); } else { message = format!("Query {} killed (elapsed: {:?})", query_id, elapsed); } cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer(views::Dialog::info(message)); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::Summary => { let block = clickhouse.get_summary(selected_host.as_ref()).await; match block { Err(err) => { let message = err.to_string(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.add_layer(views::Dialog::info(message)); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Ok(summary) => { cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.call_on_name("summary", move |view: &mut view::SummaryView| { view.update(summary); }); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } } } Event::SQLQuery(view_name, query) => { let block = clickhouse.execute(query.as_str()).await?; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { log::trace!( "Updating {} (with block of {} rows)", view_name, block.row_count() ); // TODO: update specific view (can we accept type somehow in the enum?) siv.call_on_name_or_render_error( view_name, move |view: &mut views::OnEventView| { return view.get_inner_mut().update(block); }, ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::BackgroundSchedulePoolLogs(log_name, database, table, start, end) => { let query_ids = clickhouse .get_background_schedule_pool_query_ids( log_name.clone(), database.clone(), table.clone(), start.clone(), end.clone(), selected_host.as_ref(), ) .await?; if query_ids.is_empty() { let error_msg = if let Some(log_name) = log_name { format!( "No entries for {} jobs (database: {}, table: {}, start: {}, end: {})", log_name, database, table, start, end ) } else { format!( "No entries for {}.{} (start: {}, end: {})", database, table, start, end ) }; return Err(anyhow!(error_msg)); } let title = if let Some(ref log_name) = log_name { format!("Logs for task: {}", log_name) } else { format!("Logs for tasks of {}.{}", database, table) }; cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { use cursive::view::Resizable; let context = siv.user_data::().unwrap().clone(); siv.add_layer(views::Dialog::around( views::LinearLayout::vertical() .child(views::TextView::new(title).center()) .child(views::DummyView.fixed_height(1)) .child(views::NamedView::new( "background_schedule_pool_logs", view::TextLogView::new( "background_schedule_pool_logs", context, TextLogArguments { query_ids: Some(query_ids), logger_names: None, hostname: None, message_filter: None, max_level: None, start: start.into(), end, }, ), )), )); siv.focus_name("background_schedule_pool_logs").unwrap(); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::TableParts(database, table) => { cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { let context = siv.user_data::().unwrap().clone(); crate::view::providers::table_parts::show_table_parts_dialog( siv, context, Some(database), Some(table), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::AsynchronousInserts(database, table) => { cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { let context = siv.user_data::().unwrap().clone(); crate::view::providers::asynchronous_inserts::show_asynchronous_inserts_dialog( siv, context, Some(database), Some(table), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; } Event::ShareLogs(content) => { let url = pastila::upload_encrypted(&content, &pastila_clickhouse_host, &pastila_url).await?; let url_clone = url.clone(); cb_sink .send(Box::new(move |siv: &mut cursive::Cursive| { siv.pop_layer(); siv.add_layer( views::Dialog::text(format!("Logs shared (encrypted):\n\n{}", url)) .title("Share Complete") .button("Close", |siv| { siv.pop_layer(); }), ); })) .map_err(|_| anyhow!("Cannot send message to UI"))?; crate::utils::open_url_command(&url_clone).status()?; } Event::PerfettoExport(queries, query_ids, start, end) => { let perfetto_cfg = context.lock().unwrap().options.perfetto.clone(); let end_time = end.unwrap_or_else(Local::now) + chrono::TimeDelta::seconds(1); let mut builder = PerfettoTraceBuilder::new(perfetto_cfg.per_server, perfetto_cfg.text_log_android); for q in &queries { log::info!( "Perfetto query: id={} start_ns={} end_ns={} elapsed={}", q.query_id, q.query_start_time_microseconds .timestamp_nanos_opt() .unwrap_or(0), q.query_end_time_microseconds .timestamp_nanos_opt() .unwrap_or(0), q.elapsed, ); } builder.add_queries(&queries); fetch_and_populate_perfetto_trace( &clickhouse, &mut builder, &perfetto_cfg, Some(&query_ids), start, end_time, ) .await; serve_perfetto_trace(context.clone(), cb_sink, builder)?; } Event::ServerPerfettoExport(start, end) => { let perfetto_cfg = context.lock().unwrap().options.perfetto.clone(); let query_block = clickhouse.get_queries_for_perfetto(start, end).await?; let mut queries = Vec::new(); for i in 0..query_block.row_count() { match Query::from_clickhouse_block(&query_block, i, false) { Ok(q) => queries.push(q), Err(e) => log::warn!("Perfetto: failed to parse query row {}: {}", i, e), } } let end_time = end + chrono::TimeDelta::seconds(1); let mut builder = PerfettoTraceBuilder::new(perfetto_cfg.per_server, perfetto_cfg.text_log_android); builder.add_queries(&queries); fetch_and_populate_perfetto_trace( &clickhouse, &mut builder, &perfetto_cfg, None, start, end_time, ) .await; fetch_server_perfetto_sources( &clickhouse, &mut builder, &perfetto_cfg, start, end_time, ) .await; serve_perfetto_trace(context.clone(), cb_sink, builder)?; } } return Ok(()); } ================================================ FILE: src/lib.rs ================================================ mod actions; mod common; mod interpreter; mod pastila; mod utils; mod view; mod bin; pub use bin::chdig_main; pub use bin::chdig_main_async; ================================================ FILE: src/main.rs ================================================ use anyhow::Result; use chdig::chdig_main_async; use std::env::args_os; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { #[cfg(feature = "tokio-console")] console_subscriber::init(); return chdig_main_async(args_os()).await; } ================================================ FILE: src/pastila.rs ================================================ use aes_gcm::{ Aes128Gcm, KeyInit, Nonce, aead::{Aead, generic_array::GenericArray}, }; use anyhow::{Result, anyhow}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use clickhouse_rs::{Block, Options, Pool}; use rand::RngCore; use regex::Regex; use std::str::FromStr; use url::Url; /// ClickHouse's SipHash-2-4 implementation (128-bit version) /// See https://github.com/ClickHouse/ClickHouse/pull/46065 for details pub struct ClickHouseSipHash { v0: u64, v1: u64, v2: u64, v3: u64, cnt: u64, current_word: u64, current_bytes_len: usize, } impl ClickHouseSipHash { pub fn new() -> Self { Self { v0: 0x736f6d6570736575u64, v1: 0x646f72616e646f6du64, v2: 0x6c7967656e657261u64, v3: 0x7465646279746573u64, cnt: 0, current_word: 0, current_bytes_len: 0, } } #[inline] fn sipround(&mut self) { self.v0 = self.v0.wrapping_add(self.v1); self.v1 = self.v1.rotate_left(13); self.v1 ^= self.v0; self.v0 = self.v0.rotate_left(32); self.v2 = self.v2.wrapping_add(self.v3); self.v3 = self.v3.rotate_left(16); self.v3 ^= self.v2; self.v0 = self.v0.wrapping_add(self.v3); self.v3 = self.v3.rotate_left(21); self.v3 ^= self.v0; self.v2 = self.v2.wrapping_add(self.v1); self.v1 = self.v1.rotate_left(17); self.v1 ^= self.v2; self.v2 = self.v2.rotate_left(32); } pub fn write(&mut self, data: &[u8]) { for &byte in data { let byte_idx = self.current_bytes_len; self.current_word |= (byte as u64) << (byte_idx * 8); self.current_bytes_len += 1; self.cnt += 1; if self.current_bytes_len == 8 { self.v3 ^= self.current_word; self.sipround(); self.sipround(); self.v0 ^= self.current_word; self.current_word = 0; self.current_bytes_len = 0; } } } pub fn finish128(mut self) -> u128 { let cnt_byte = (self.cnt % 256) as u8; self.current_word |= (cnt_byte as u64) << 56; self.v3 ^= self.current_word; self.sipround(); self.sipround(); self.v0 ^= self.current_word; self.v2 ^= 0xff; self.sipround(); self.sipround(); self.sipround(); self.sipround(); let low = self.v0 ^ self.v1; let high = self.v2 ^ self.v3; ((high as u128) << 64) | (low as u128) } } pub fn calculate_hash(text: &str) -> String { let mut hasher = ClickHouseSipHash::new(); hasher.write(text.as_bytes()); let hash = hasher.finish128(); format!("{:032x}", hash.swap_bytes()) } pub fn get_fingerprint(text: &str) -> String { let re = Regex::new(r"\b\w{4,100}\b").unwrap(); let words: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect(); if words.len() < 3 { return "ffffffff".to_string(); } let mut min_hash: Option = None; for i in 0..words.len().saturating_sub(2) { let triplet = format!("{} {} {}", words[i], words[i + 1], words[i + 2]); let mut hasher = ClickHouseSipHash::new(); hasher.write(triplet.as_bytes()); let hash_value = hasher.finish128(); min_hash = Some(min_hash.map_or(hash_value, |current| current.min(hash_value))); } let full_hash = match min_hash { Some(hash) => format!("{:032x}", hash.swap_bytes()), None => "ffffffffffffffffffffffffffffffff".to_string(), }; full_hash[..8].to_string() } fn encrypt_content(content: &str, key: &[u8; 16]) -> Result { let cipher = Aes128Gcm::new(GenericArray::from_slice(key)); let nonce = Nonce::from_slice(&key[..12]); let ciphertext = cipher .encrypt(nonce, content.as_bytes()) .map_err(|e| anyhow!("Encryption failed: {}", e))?; Ok(BASE64.encode(&ciphertext)) } async fn get_pastila_client(pastila_clickhouse_host: &str) -> Result { let url = { let http_url = Url::parse(pastila_clickhouse_host)?; let host = http_url .host_str() .ok_or_else(|| anyhow!("No host in pastila_clickhouse_host"))?; let user = if !http_url.username().is_empty() { http_url.username().to_string() } else { http_url .query_pairs() .find(|(k, _)| k == "user") .map(|(_, v)| v.to_string()) .unwrap_or_else(|| "default".to_string()) }; let secure = http_url.scheme() == "https"; let port = if secure { 9440 } else { 9000 }; format!( "tcp://{}@{}:{}/?secure={}&connection_timeout=5s", user, host, port, secure ) }; let options = Options::from_str(&url)?; let pool = Pool::new(options); let client = pool.get_handle().await?; Ok(client) } pub async fn upload_encrypted( content: &str, pastila_clickhouse_host: &str, pastila_url: &str, ) -> Result { let mut key = [0u8; 16]; rand::thread_rng().fill_bytes(&mut key); let encrypted = encrypt_content(content, &key)?; let fingerprint_hex = get_fingerprint(&encrypted); let hash_hex = calculate_hash(&encrypted); log::info!( "Uploading {} bytes ({} bytes encrypted) to {}", content.len(), encrypted.len(), pastila_clickhouse_host ); { let mut client = get_pastila_client(pastila_clickhouse_host).await?; let block = Block::new() .column("fingerprint_hex", vec![fingerprint_hex.as_str()]) .column("hash_hex", vec![hash_hex.as_str()]) .column("content", vec![encrypted.as_str()]) .column("is_encrypted", vec![1_u8]); client.insert("paste.data", block).await?; } let pastila_url = pastila_url.trim_end_matches('/'); let key_fragment = format!("#{}", BASE64.encode(key)); let pastila_page_url = format!( "{}/?{}/{}{}GCM", pastila_url, fingerprint_hex, hash_hex, key_fragment ); Ok(pastila_page_url) } ================================================ FILE: src/utils.rs ================================================ use crate::actions::ActionDescription; use crate::pastila; use crate::view::Navigation; use anyhow::{Context, Error, Result}; use cursive::Cursive; use cursive::align::HAlign; use cursive::event; use cursive::utils::markup::StyledString; use cursive::view::Nameable; use cursive::views::{EditView, LinearLayout, OnEventView, Panel, SelectView}; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; use std::collections::HashMap; use std::env; use std::fs; use std::io::Write; use std::process::{Command, Stdio}; use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; use tempfile::Builder; /// RAII guard that leaves cursive's terminal state (raw mode, alternate screen, /// mouse capture, hidden cursor) and restores it on drop. /// /// Uses cursive's re-exported crossterm to ensure we operate on the same global /// raw mode state that the cursive backend uses. pub struct TerminalRawModeGuard { restored: bool, } use cursive::backends::crossterm::crossterm as ct; impl TerminalRawModeGuard { pub fn leave() -> Self { ct::terminal::disable_raw_mode().unwrap(); ct::execute!( std::io::stdout(), ct::event::DisableMouseCapture, ct::style::ResetColor, ct::style::SetAttribute(ct::style::Attribute::Reset), ct::cursor::Show, ct::terminal::LeaveAlternateScreen, ) .unwrap(); Self { restored: false } } fn do_restore() -> std::io::Result<()> { ct::terminal::enable_raw_mode()?; ct::execute!( std::io::stdout(), ct::terminal::EnterAlternateScreen, ct::event::EnableMouseCapture, ct::cursor::Hide, ) } pub fn restore(&mut self) -> std::io::Result<()> { self.restored = true; Self::do_restore() } } impl Drop for TerminalRawModeGuard { fn drop(&mut self) { if !self.restored { let _ = Self::do_restore(); } } } pub fn fuzzy_actions(siv: &mut Cursive, actions: Vec, on_select: F) where F: Fn(&mut Cursive, String) + 'static + Send + Sync, { let items: Vec<(String, String)> = actions .iter() .map(|a| { let text = a.text.to_string(); (text.clone(), text) }) .collect(); fuzzy_select_strings(siv, "Fuzzy search", items, on_select); } pub fn fuzzy_select_strings( siv: &mut Cursive, title: &str, items: Vec<(String, String)>, on_select: F, ) where F: Fn(&mut Cursive, String) + 'static + Send + Sync, { if siv.has_view("fuzzy_search") { return; } let mut select = SelectView::::new().h_align(HAlign::Left).autojump(); for (label, value) in &items { select.add_item(label.clone(), value.clone()); } select.set_on_submit(move |siv, item: &String| { let selected = item.clone(); siv.pop_layer(); on_select(siv, selected); }); let search = EditView::new() .on_edit(move |siv, query, _| { siv.call_on_name("fuzzy_select", |view: &mut SelectView| { view.clear(); let matcher = SkimMatcherV2::default(); let query_words: Vec<&str> = query.split_whitespace().collect(); let mut matches: Vec<(i64, String, String)> = items .iter() .filter_map(|(label, value)| { if query_words.is_empty() { return Some((0, label.clone(), value.clone())); } let mut total_score = 0i64; for word in &query_words { match matcher.fuzzy_match(label, word) { Some(score) => total_score += score, None => return None, } } Some((total_score, label.clone(), value.clone())) }) .collect(); matches.sort_by(|a, b| b.0.cmp(&a.0)); for (_, label, value) in matches { view.add_item(label, value); } }); }) .on_submit(|siv, _| { siv.call_on_name("fuzzy_select", |view: &mut SelectView| { view.set_selection(0); }); siv.focus_name("fuzzy_select").ok(); siv.on_event(event::Event::Key(cursive::event::Key::Enter)); }) .with_name("fuzzy_search"); let layout = LinearLayout::vertical() .child(search) .child(select.with_name("fuzzy_select")); let dialog = OnEventView::new(Panel::new(layout).title(title.to_string())) .on_pre_event(event::Event::CtrlChar('k'), |s| { s.call_on_name("fuzzy_select", |view: &mut SelectView| { view.select_up(1); }); }) .on_pre_event(event::Event::CtrlChar('j'), |s| { s.call_on_name("fuzzy_select", |view: &mut SelectView| { view.select_down(1); }); }) .on_pre_event(event::Event::CtrlChar('w'), |s| { let callback = s.call_on_name("fuzzy_search", |view: &mut EditView| { let content = view.get_content(); let cursor = view.get_cursor(); let before_cursor = &content[..cursor]; let trimmed = before_cursor.trim_end(); if trimmed.is_empty() { let cb = view.set_content(""); view.set_cursor(0); return Some(cb); } let new_pos = trimmed .rfind(|c: char| c.is_whitespace()) .map(|pos| pos + 1) .unwrap_or(0); let new_content = format!("{}{}", &content[..new_pos], &content[cursor..]); let cb = view.set_content(new_content); view.set_cursor(new_pos); Some(cb) }); if let Some(Some(cb)) = callback { cb(s); } }) .on_event(event::Key::Backspace, |_| {}) .on_event(event::Event::CtrlChar('p'), |s| { s.pop_layer(); }) .on_event(event::Key::Esc, |s| { s.pop_layer(); }); siv.add_layer(dialog); siv.focus_name("fuzzy_search").ok(); } pub fn highlight_sql(text: &str) -> Result { let syntax_set = SyntaxSet::load_defaults_newlines(); let ts = ThemeSet::load_defaults(); let mut highlighter = syntect::easy::HighlightLines::new( syntax_set .find_syntax_by_token("sql") .context("Cannot load SQL syntax")?, &ts.themes["base16-ocean.dark"], ); // NOTE: parse() does not interpret syntect::highlighting::Color::a (alpha/transparency) return cursive_syntect::parse(text, &mut highlighter, &syntax_set) .context("Cannot highlight query"); } pub fn get_query(query: &str, settings: &HashMap) -> String { // NOTE: LinesIterator (that is used by TextView for wrapping) cannot handle "\t", // it renders as a replacement glyph at the start of each wrapped/continuation line. let mut ret = query.replace('\t', " "); let settings_str = settings .iter() .enumerate() .map(|(i, kv)| { let is_last = i + 1 == settings.len(); // NOTE: LinesIterator (that is used by TextView for wrapping) cannot handle "\t", hence 4 spaces let prefix = " "; format!( "{}{}='{}'{}\n", prefix, kv.0, kv.1.replace('\'', "\\\'"), if !is_last { "," } else { "" } ) }) .collect::>() .join(""); if !query.contains("SETTINGS") { ret.push_str("\nSETTINGS\n"); } else { ret.push_str(",\n"); } ret.push_str(&settings_str); return ret; } pub fn edit_query(query: &str, settings: &HashMap) -> Result { let mut tmp_file = Builder::new() .prefix("chdig-query-") .suffix(".sql") .rand_bytes(5) .tempfile()?; let query = get_query(query, settings); tmp_file.write_all(query.as_bytes())?; let editor = env::var_os("EDITOR").unwrap_or_else(|| "vim".into()); let tmp_file_path = tmp_file.path().to_str().unwrap(); let _guard = TerminalRawModeGuard::leave(); let result = Command::new(&editor) .arg(tmp_file_path) .spawn() .map_err(|e| Error::msg(format!("Cannot execute editor {:?} ({})", editor, e)))? .wait()?; if !result.success() { return Err(Error::msg(format!( "Editor exited unsuccessfully {:?} ({})", editor, result ))); } let query = fs::read_to_string(tmp_file_path)?; return Ok(query); } pub fn open_url_command(url: &str) -> Command { let mut cmd = if cfg!(target_os = "windows") { let mut c = Command::new("cmd"); c.args(["/C", "start", "", url]); // "" to avoid stealing the first quoted argument as window title c } else if cfg!(target_os = "macos") { let mut c = Command::new("open"); c.arg(url); c } else { let mut c = Command::new("xdg-open"); c.arg(url); c }; cmd.stderr(Stdio::null()).stdout(Stdio::null()); cmd } pub async fn share_graph( graph: String, pastila_clickhouse_host: &str, pastila_url: &str, ) -> Result<()> { if graph.is_empty() { return Err(Error::msg("Graph is empty")); } // Create a self-contained HTML file that renders the Graphviz graph // Using viz.js from CDN for client-side rendering let html = format!( r#" Graphviz Graph
Loading graph...
"#, serde_json::to_string(&graph)? ); // Upload HTML to pastila with end-to-end encryption let mut url = pastila::upload_encrypted(&html, pastila_clickhouse_host, pastila_url).await?; if let Some(anchor_pos) = url.find('#') { url.insert_str(anchor_pos, ".html"); } // Open the URL in the browser open_url_command(&url).status()?; Ok(()) } pub fn find_common_hostname_prefix_and_suffix<'a, I>(hostnames: I) -> (String, String) where I: Iterator, { let hostnames_vec: Vec<&str> = hostnames.collect(); if hostnames_vec.is_empty() { return (String::new(), String::new()); } let first = hostnames_vec[0]; // Find common prefix let mut prefix_end = first.len(); for pos in (0..first.len()).rev() { let candidate = &first[..=pos]; if hostnames_vec[1..].iter().all(|h| h.starts_with(candidate)) { prefix_end = pos + 1; break; } } let common_prefix = &first[..prefix_end]; let prefix_delim_pos = common_prefix .rfind('.') .into_iter() .chain(common_prefix.rfind('-')) .max(); let prefix = if let Some(pos) = prefix_delim_pos { common_prefix[..=pos].to_string() } else { String::new() }; // Find common suffix let mut suffix_start = 0; for pos in 0..first.len() { let candidate = &first[pos..]; if hostnames_vec[1..].iter().all(|h| h.ends_with(candidate)) { suffix_start = pos; break; } } let common_suffix = &first[suffix_start..]; let suffix_delim_pos = common_suffix .find('.') .into_iter() .chain(common_suffix.find('-')) .min(); let suffix = if let Some(pos) = suffix_delim_pos { common_suffix[pos..].to_string() } else { String::new() }; (prefix, suffix) } ================================================ FILE: src/view/log_view.rs ================================================ use anyhow::{Error, Result}; use chrono::{DateTime, Datelike, Duration, Local, Timelike}; use cursive::{ Cursive, Printer, Vec2, event::{Callback, Event, EventResult, Key}, theme::{Color, ColorStyle, Style}, utils::{lines::spans::LinesIterator, markup::StyledString}, view::{Nameable, Resizable, ScrollStrategy, View, ViewWrapper, scroll}, views::{Dialog, EditView, NamedView, OnEventView}, wrap_impl, }; use regex::Regex; use std::collections::{HashMap, hash_map::DefaultHasher}; use std::fs; use std::hash::{Hash, Hasher}; use std::io::Write; use unicode_width::UnicodeWidthStr; use crate::common::RelativeDateTime; use crate::interpreter::{ContextArc, TextLogArguments}; use crate::utils::find_common_hostname_prefix_and_suffix; use crate::view::{TextLogView, show_bottom_prompt}; // Hash-based color function matching ClickHouse's setColor from terminalColors.cpp // Uses YCbCr color space with constant brightness (y=128) for better readability fn hash_to_color(hash: u64) -> Color { let y = 128u8; let cb = ((hash >> 8) & 0xFF) as u8; let cr = (hash & 0xFF) as u8; // YCbCr to RGB conversion (ITU-R BT.601) // R = Y + 1.402 * (Cr - 128) // G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128) // B = Y + 1.772 * (Cb - 128) let cb_offset = cb as i32 - 128; let cr_offset = cr as i32 - 128; let r = (y as i32 + (1402 * cr_offset) / 1000).clamp(0, 255) as u8; let g = (y as i32 - (344 * cb_offset) / 1000 - (714 * cr_offset) / 1000).clamp(0, 255) as u8; let b = (y as i32 + (1772 * cb_offset) / 1000).clamp(0, 255) as u8; Color::Rgb(r, g, b) } // Color for log priority level matching ClickHouse's setColorForLogPriority from terminalColors.cpp fn get_level_color(level: &str) -> Color { match level { // Fatal: \033[1;41m (bold + red background) - using bright red "Fatal" => Color::Rgb(255, 85, 85), // Critical: \033[7;31m (reverse video + red) - using bright red "Critical" => Color::Rgb(255, 85, 85), // Error: \033[1;31m (bold red) - bright red "Error" => Color::Rgb(255, 85, 85), // Warning: \033[0;31m (red) - normal red "Warning" => Color::Rgb(255, 0, 0), // Notice: \033[0;33m (yellow) - normal yellow "Notice" => Color::Rgb(255, 255, 0), // Information: \033[1m (bold) - using default terminal color (light gray) "Information" => Color::Rgb(192, 192, 192), // Debug: no color - default terminal color "Debug" => Color::TerminalDefault, // Trace: \033[2m (dim) - dark gray "Trace" => Color::Rgb(128, 128, 128), // Test: no specific color in ClickHouse "Test" => Color::TerminalDefault, _ => Color::TerminalDefault, } } // Hash function similar to ClickHouse's intHash64 fn int_hash_64(value: u64) -> u64 { let mut hasher = DefaultHasher::new(); value.hash(&mut hasher); hasher.finish() } fn string_hash(s: &str) -> u64 { let mut hasher = DefaultHasher::new(); s.hash(&mut hasher); hasher.finish() } #[derive(Clone)] pub struct LogEntry { pub host_name: String, pub display_host_name: Option, pub event_time_microseconds: DateTime, pub thread_id: u64, pub level: String, pub message: String, pub query_id: Option, pub logger_name: Option, } struct IdentifierMaps { query_id_map: HashMap, logger_name_map: HashMap, level_map: HashMap, host_name_map: HashMap, } impl LogEntry { fn to_styled_string(&self, cluster: bool) -> StyledString { self.to_styled_string_with_identifiers(cluster, None) } fn to_styled_string_with_identifiers( &self, cluster: bool, identifier_maps: Option<&IdentifierMaps>, ) -> StyledString { let mut line = StyledString::new(); if cluster { line.append_plain("["); let host_hash = string_hash(&self.host_name); let host_color = hash_to_color(host_hash); let display_name = self.display_host_name.as_ref().unwrap_or(&self.host_name); line.append_styled(display_name, host_color); if let Some(maps) = identifier_maps && let Some(id) = maps.host_name_map.get(&self.host_name) { line.append_styled(format!("[{}]", id), Color::Rgb(255, 255, 0)); } line.append_plain("] "); } // Format timestamp with microseconds matching ClickHouse format: YYYY.MM.DD HH:MM:SS.microseconds let dt = self.event_time_microseconds; let microseconds = dt.timestamp_subsec_micros(); let timestamp = format!( "{:04}.{:02}.{:02} {:02}:{:02}:{:02}.{:06}", dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), dt.second(), microseconds ); line.append_plain(format!("{} ", timestamp)); // Thread ID with hash-based coloring: [ thread_id ] line.append_plain("[ "); let thread_hash = int_hash_64(self.thread_id); let thread_color = hash_to_color(thread_hash); line.append_styled(format!("{}", self.thread_id), thread_color); line.append_plain(" ] "); // Query ID with hash-based coloring: {query_id} // ClickHouse writes query_id even if empty for log parser convenience line.append_plain("{"); let query_id_str = self.query_id.as_deref().unwrap_or(""); if !query_id_str.is_empty() { let query_hash = string_hash(query_id_str); let query_color = hash_to_color(query_hash); line.append_styled(query_id_str, query_color); if let Some(maps) = identifier_maps && let Some(id) = maps.query_id_map.get(query_id_str) { line.append_styled(format!("[{}]", id), Color::Rgb(255, 255, 0)); } } line.append_plain("} "); // Priority level with color: line.append_plain("<"); let level_color = get_level_color(self.level.as_str()); line.append_styled(self.level.as_str(), level_color); if let Some(maps) = identifier_maps && let Some(id) = maps.level_map.get(&self.level) { line.append_styled(format!("[{}]", id), Color::Rgb(255, 255, 0)); } line.append_plain("> "); // Logger name (source) with hash-based coloring: source: if let Some(logger_name) = &self.logger_name { let logger_hash = string_hash(logger_name); let logger_color = hash_to_color(logger_hash); line.append_styled(logger_name, logger_color); if let Some(maps) = identifier_maps && let Some(id) = maps.logger_name_map.get(logger_name) { line.append_styled(format!("[{}]", id), Color::Rgb(255, 255, 0)); } line.append_plain(": "); } // Message line.append_plain(self.message.as_str()); return line; } } #[derive(Clone)] enum FilterType { QueryId(String), LoggerName(String), Level(String), HostName(String), } pub struct LogViewBase { max_width: usize, content_size_with_wrap: Vec2, // Size without respecting wrap, since with wrap width is equal to the longest line screen_size_without_wrap: Vec2, needs_relayout: bool, update_content: bool, scroll_core: scroll::Core, search_direction_forward: bool, search_regex: Option, matched_row: Option, matched_col: Option, matched_len: usize, cluster: bool, wrap: bool, no_strip_hostname_suffix: bool, descending: bool, // Filter mode state filter_mode: bool, filter_identifiers: HashMap, active_filter: Option, logs: Vec, // When filtering is active, stores indices into self.logs for visible entries // Empty when no filter is active (all logs visible) filtered_log_indices: Vec, // Cumulative row counts: log_cumulative_rows[i] = total rows in logs 0..i // This allows O(log n) binary search to map display_row -> log_index log_cumulative_rows: Vec, last_computed_width: usize, } impl Default for LogViewBase { fn default() -> Self { Self { max_width: 0, content_size_with_wrap: Vec2::zero(), screen_size_without_wrap: Vec2::zero(), needs_relayout: false, update_content: false, scroll_core: scroll::Core::default(), search_direction_forward: false, search_regex: None, matched_row: None, matched_col: None, matched_len: 0, cluster: false, wrap: false, no_strip_hostname_suffix: false, descending: false, filter_mode: false, filter_identifiers: HashMap::new(), active_filter: None, logs: Vec::new(), filtered_log_indices: Vec::new(), log_cumulative_rows: Vec::new(), last_computed_width: usize::MAX, } } } cursive::impl_scroller!(LogViewBase::scroll_core); impl LogViewBase { // Get the log at the given visible index // If filtering is active, maps through filtered_log_indices fn get_visible_log(&self, visible_idx: usize) -> Option<&LogEntry> { if self.filtered_log_indices.is_empty() { self.logs.get(visible_idx) } else { self.filtered_log_indices .get(visible_idx) .and_then(|&idx| self.logs.get(idx)) } } // Get count of visible logs fn visible_log_count(&self) -> usize { if self.filtered_log_indices.is_empty() { self.logs.len() } else { self.filtered_log_indices.len() } } // Get identifier maps for rendering with highlights fn get_identifier_maps(&self) -> Option { if !self.filter_mode { return None; } let mut identifier_maps = IdentifierMaps { query_id_map: HashMap::new(), logger_name_map: HashMap::new(), level_map: HashMap::new(), host_name_map: HashMap::new(), }; for (id, filter_type) in &self.filter_identifiers { match filter_type { FilterType::QueryId(val) => { identifier_maps.query_id_map.insert(val.clone(), id.clone()); } FilterType::LoggerName(val) => { identifier_maps .logger_name_map .insert(val.clone(), id.clone()); } FilterType::Level(val) => { identifier_maps.level_map.insert(val.clone(), id.clone()); } FilterType::HostName(val) => { identifier_maps .host_name_map .insert(val.clone(), id.clone()); } } } Some(identifier_maps) } // Binary search to find which log a display row belongs to // Returns (log_index, row_within_log) fn display_row_to_log(&self, display_row: usize) -> Option<(usize, usize)> { if self.log_cumulative_rows.is_empty() { return None; } // Use proper binary search: find first cumulative > display_row // cumulative_rows[i] = total rows in logs 0..=i let log_idx = match self.log_cumulative_rows.binary_search(&(display_row + 1)) { Ok(idx) => idx, // Found exact match for display_row + 1 Err(idx) => idx, // Would insert at idx, so first element > display_row is at idx }; if log_idx >= self.log_cumulative_rows.len() { return None; } let row_start = if log_idx == 0 { 0 } else { self.log_cumulative_rows[log_idx - 1] }; let row_within_log = display_row - row_start; Some((log_idx, row_within_log)) } // Map log_index to its starting display row fn log_to_display_row(&self, log_idx: usize) -> usize { if log_idx == 0 { 0 } else { self.log_cumulative_rows .get(log_idx - 1) .copied() .unwrap_or(0) } } fn extract_identifiers(&mut self) { let mut query_ids: HashMap = HashMap::new(); let mut logger_names: HashMap = HashMap::new(); let mut levels: HashMap = HashMap::new(); let mut host_names: HashMap = HashMap::new(); for log in &self.logs { if let Some(ref query_id) = log.query_id && !query_id.is_empty() { query_ids.entry(query_id.clone()).or_insert(0); } if let Some(ref logger_name) = log.logger_name { logger_names.entry(logger_name.clone()).or_insert(0); } levels.entry(log.level.clone()).or_insert(0); host_names.entry(log.host_name.clone()).or_insert(0); } self.filter_identifiers.clear(); let mut counter = 1; for query_id in query_ids.keys() { let id = format!("q{}", counter); self.filter_identifiers .insert(id, FilterType::QueryId(query_id.clone())); counter += 1; } counter = 1; for logger_name in logger_names.keys() { let id = format!("l{}", counter); self.filter_identifiers .insert(id, FilterType::LoggerName(logger_name.clone())); counter += 1; } counter = 1; for level in levels.keys() { let id = format!("v{}", counter); self.filter_identifiers .insert(id, FilterType::Level(level.clone())); counter += 1; } counter = 1; for host_name in host_names.keys() { let id = format!("h{}", counter); self.filter_identifiers .insert(id, FilterType::HostName(host_name.clone())); counter += 1; } } fn rebuild_content_with_highlights(&mut self) { self.filtered_log_indices.clear(); self.needs_relayout = true; self.compute_rows(); } fn rebuild_content_normal(&mut self) { self.filtered_log_indices.clear(); self.needs_relayout = true; self.compute_rows(); } fn apply_filter(&mut self) { self.filtered_log_indices.clear(); if let Some(ref filter) = self.active_filter { for (idx, log) in self.logs.iter().enumerate() { let matches = match filter { FilterType::QueryId(val) => log.query_id.as_ref() == Some(val), FilterType::LoggerName(val) => log.logger_name.as_ref() == Some(val), FilterType::Level(val) => &log.level == val, FilterType::HostName(val) => &log.host_name == val, }; if matches { self.filtered_log_indices.push(idx); } } } self.needs_relayout = true; self.compute_rows(); } fn search_in_direction(&mut self, forward: bool) -> bool { if self.search_regex.is_none() { return false; } let start_row = self .matched_row .unwrap_or_else(|| self.scroll_core.content_viewport().top()); let start_log_idx = self .display_row_to_log(start_row) .map(|(idx, _)| idx) .unwrap_or(0); let total_logs = self.visible_log_count(); let identifier_maps = self.get_identifier_maps(); if forward { for log_idx in (start_log_idx..total_logs).chain(0..start_log_idx) { if self.search_log(log_idx, start_log_idx, &identifier_maps, forward) { return true; } } } else { for log_idx in (0..=start_log_idx) .rev() .chain((start_log_idx + 1..total_logs).rev()) { if self.search_log(log_idx, start_log_idx, &identifier_maps, forward) { return true; } } } false } fn search_log( &mut self, log_idx: usize, start_log_idx: usize, identifier_maps: &Option, forward: bool, ) -> bool { if let Some(log) = self.get_visible_log(log_idx) { let mut styled = if let Some(maps) = identifier_maps { log.to_styled_string_with_identifiers(self.cluster, Some(maps)) } else { log.to_styled_string(self.cluster) }; styled.append("\n"); let display_row_start = self.log_to_display_row(log_idx); if forward { let mut current_row = display_row_start; for row in LinesIterator::new(&styled, self.last_computed_width) { if log_idx == start_log_idx && Some(current_row) <= self.matched_row { current_row += 1; continue; } if self.search_row(&styled, &row, current_row, forward) { return true; } current_row += 1; } } else { let rows: Vec<_> = LinesIterator::new(&styled, self.last_computed_width).collect(); for (row_within_log, row) in rows.iter().enumerate().rev() { let current_row = display_row_start + row_within_log; if log_idx == start_log_idx && Some(current_row) >= self.matched_row { continue; } if self.search_row(&styled, row, current_row, forward) { return true; } } } } false } fn search_row( &mut self, styled: &StyledString, row: &cursive::utils::lines::spans::Row, current_row: usize, forward: bool, ) -> bool { let re = match &self.search_regex { Some(re) => re, None => return false, }; let mut x = 0; for span in row.resolve_stream(styled) { if let Some(m) = re.find(span.content) { self.matched_row = Some(current_row); self.matched_col = Some(x + span.content[..m.start()].width()); self.matched_len = m.as_str().width(); log::trace!( "search regex matched_row: {:?} ({}-search)", self.matched_row, if forward { "forward" } else { "reverse" } ); return true; } x += span.content.width(); } false } fn update_search_forward(&mut self) -> bool { self.search_in_direction(true) } fn update_search_reverse(&mut self) -> bool { self.search_in_direction(false) } fn update_search(&mut self) -> bool { // In case of resize we can have less rows then before, // so reset the matched_row for this scenario to avoid out-of-bound access. let total_rows = self.log_cumulative_rows.last().copied().unwrap_or(0); if total_rows < self.matched_row.unwrap_or_default() { self.matched_row = None; } if self.search_direction_forward { return self.update_search_forward(); } else { return self.update_search_reverse(); } } fn set_options(&mut self, options: &str) -> Result<()> { if options.is_empty() { } else if options == "S" { self.wrap = !self.wrap; log::trace!("Toggle wrap mode, switched to {}", self.wrap); } else { return Err(Error::msg(format!("Invalid options: {}", options))); } return Ok(()); } fn push_logs(&mut self, mut logs: Vec) { log::trace!("Add {} log entries", logs.len()); if logs.is_empty() { return; } // In descending mode the "head" is the top of the viewport, otherwise it's the bottom. let old_total_rows = self.log_cumulative_rows.last().copied().unwrap_or(0); let viewport = self.scroll_core.content_viewport(); let at_head = if self.descending { viewport.top() == 0 } else { old_total_rows == 0 || viewport.bottom() + 1 >= old_total_rows }; // If the user scrolled away from the head, pin the viewport so incoming rows do // not yank them around (for DESC we still need to shift below, since prepending // rotates every row index). if !at_head { self.scroll_core .set_scroll_strategy(ScrollStrategy::KeepRow); } // Strip common hostname prefix and suffix from first 1000 newly added items if !self.no_strip_hostname_suffix && logs.len() > 1 { let sample_size = logs.len().min(1000); let (common_prefix, common_suffix) = find_common_hostname_prefix_and_suffix( logs.iter().take(sample_size).map(|l| l.host_name.as_str()), ); if !common_prefix.is_empty() || !common_suffix.is_empty() { for log in logs.iter_mut() { let mut hostname = log.host_name.as_str(); if !common_prefix.is_empty() && let Some(stripped) = hostname.strip_prefix(&common_prefix) { hostname = stripped; } if !common_suffix.is_empty() && let Some(stripped) = hostname.strip_suffix(&common_suffix) { hostname = stripped; } log.display_host_name = Some(hostname.to_string()); } } } if self.descending { // Prepend: the batch already arrives newest-first (ORDER BY ... DESC), // so splicing at the front keeps the global ordering newest -> oldest. self.logs.splice(0..0, logs); // Indices of existing logs shifted, so incremental compute_rows() is unsafe. self.log_cumulative_rows.clear(); } else { self.logs.extend(logs); } if self.filter_mode { self.extract_identifiers(); self.rebuild_content_with_highlights(); } else if self.active_filter.is_some() { self.apply_filter(); } else { self.needs_relayout = true; self.compute_rows(); } // After prepending, shift the viewport down by the number of rows added so the // user keeps looking at the same logical entry they were reading before. if self.descending && !at_head { let new_total_rows = self.log_cumulative_rows.last().copied().unwrap_or(0); let delta = new_total_rows.saturating_sub(old_total_rows); if delta > 0 { let vp = self.scroll_core.content_viewport(); self.scroll_core .set_offset(Vec2::new(vp.left(), vp.top() + delta)); } } } fn compute_rows(&mut self) { let width = if self.wrap { // For scrolling we need to subtract some padding self.screen_size_without_wrap.x.saturating_sub(2) } else { usize::MAX }; // On resize/wrap change row indices shift, so the old matched_row is invalid if self.matched_row.is_some() && self.last_computed_width != width { self.matched_row = None; } let visible_count = self.visible_log_count(); // Check if we can do incremental computation: // - Width hasn't changed (no wrap mode change or resize affecting width) // - No filtering is active (filtered_log_indices is empty, NOTE: we can optimize this case as well) // - We have previous computed data // - We're only adding logs (visible_count >= previous count) let can_do_incremental = self.last_computed_width == width && self.filtered_log_indices.is_empty() && !self.log_cumulative_rows.is_empty() && visible_count >= self.log_cumulative_rows.len(); let start_idx = if can_do_incremental { self.log_cumulative_rows.len() } else { self.log_cumulative_rows.clear(); 0 }; let mut max_width = if can_do_incremental { self.max_width } else { 0 }; let mut cumulative = if can_do_incremental { *self.log_cumulative_rows.last().unwrap() } else { 0 }; let identifier_maps = self.get_identifier_maps(); // Build cumulative row counts by computing styled strings on-demand // We compute them here just to count rows, then discard them (saves memory) for i in start_idx..visible_count { if let Some(log) = self.get_visible_log(i) { let mut styled = if let Some(ref maps) = identifier_maps { log.to_styled_string_with_identifiers(self.cluster, Some(maps)) } else { log.to_styled_string(self.cluster) }; styled.append("\n"); let mut row_count = 0; for row in LinesIterator::new(&styled, width) { max_width = usize::max(max_width, row.width); row_count += 1; } cumulative += row_count; self.log_cumulative_rows.push(cumulative); } } self.max_width = max_width; self.last_computed_width = width; log::trace!( "Updating rows cache (width: {:?}, wrap: {}, max width: {}, rows: {}, visible_logs: {}/{}, incremental: {}/{}, inner size: {:?}, last size: {:?})", width, self.wrap, max_width, cumulative, visible_count, self.logs.len(), can_do_incremental, start_idx, self.scroll_core.inner_size(), self.scroll_core.last_available_size() ); // Show the horizontal scrolling self.needs_relayout = true; } fn rows_are_valid(&mut self, size: Vec2) -> bool { if self.update_content || self.needs_relayout { return false; } if self.wrap && self.content_size_with_wrap != size { return false; } return true; } fn layout_content(&mut self, size: Vec2) { if !self.rows_are_valid(size) { log::trace!( "Size changed: content_size={:?}, screen_size={:?}, size={:?}", self.content_size_with_wrap, self.screen_size_without_wrap, size ); self.content_size_with_wrap = size; self.compute_rows(); self.scroll_core.set_scroll_x(!self.wrap); } self.needs_relayout = false; self.update_content = false; } fn inner_required_size(&mut self, mut req: Vec2) -> Vec2 { self.screen_size_without_wrap = req; let total_rows = self.log_cumulative_rows.last().copied().unwrap_or(0); req.y = total_rows; req.x = usize::max(req.x, self.max_width); return req; } fn draw_content(&self, printer: &Printer<'_, '_>) { let start_row = printer.content_offset.y; let end_row = start_row + printer.output_size.y; let total_rows = self.log_cumulative_rows.last().copied().unwrap_or(0); let identifier_maps = self.get_identifier_maps(); for display_row in start_row..end_row.min(total_rows) { // Binary search to find which log this display row belongs to if let Some((log_idx, row_within_log)) = self.display_row_to_log(display_row) && let Some(log) = self.get_visible_log(log_idx) { let mut styled = if let Some(ref maps) = identifier_maps { log.to_styled_string_with_identifiers(self.cluster, Some(maps)) } else { log.to_styled_string(self.cluster) }; styled.append("\n"); if let Some(row) = LinesIterator::new(&styled, self.last_computed_width).nth(row_within_log) { let y = display_row; let mut x = 0; for span in row.resolve_stream(&styled) { if let Some(ref re) = self.search_regex { let content = span.content; let mut last_pos = 0; let mut has_match = false; for m in re.find_iter(content) { has_match = true; if m.start() > last_pos { let before = &content[last_pos..m.start()]; printer.with_style(*span.attr, |printer| { printer.print((x, y), before); }); x += before.width(); } let matched = m.as_str(); // Use the same highlight theme as less(1): // - Always use black as text color // - Use original text color as background // - For no-style use white as background let bg_color = if *span.attr == Style::default() { Color::Rgb(255, 255, 255).into() } else { span.attr.color.front }; let inverted_style = ColorStyle::new(Color::Rgb(0, 0, 0), bg_color); printer.with_style(inverted_style, |printer| { printer.print((x, y), matched); }); x += matched.width(); last_pos = m.end(); } if has_match { if last_pos < content.len() { let after = &content[last_pos..]; printer.with_style(*span.attr, |printer| { printer.print((x, y), after); }); x += after.width(); } } else { printer.with_style(*span.attr, |printer| { printer.print((x, y), span.content); }); x += span.content.width(); } } else { // No match in this span or row, print normally printer.with_style(*span.attr, |printer| { printer.print((x, y), span.content); x += span.content.width(); }); } } } } } } // Write plain text content from the styled string directly to a writer fn write_plain_text(&self, writer: &mut W) -> Result<()> { let visible_count = self.visible_log_count(); for i in 0..visible_count { if let Some(log) = self.get_visible_log(i) { let mut styled = log.to_styled_string(self.cluster); styled.append("\n"); for row in LinesIterator::new(&styled, self.last_computed_width) { for span in row.resolve_stream(&styled) { writer.write_all(span.content.as_bytes())?; } writer.write_all(b"\n")?; } } } Ok(()) } } fn show_filtered_logs_popup(siv: &mut Cursive) { let context = siv.user_data::().unwrap().clone(); // Ensure filter mode is active and identifiers are extracted siv.call_on_name("logs", |base: &mut LogViewBase| { if !base.filter_mode { base.filter_mode = true; base.extract_identifiers(); base.rebuild_content_with_highlights(); } }); // Get current log entry's timestamp for time range calculation let log_time = siv.call_on_name("logs", |base: &mut LogViewBase| { let viewport = base.scroll_core.content_viewport(); let top_row = viewport.top(); if let Some((log_idx, _)) = base.display_row_to_log(top_row) && let Some(log) = base.get_visible_log(log_idx) { return Some(log.event_time_microseconds); } None }); let Some(Some(event_time)) = log_time else { siv.add_layer(Dialog::info("No log entry at current position")); return; }; // Calculate time range: ±1 minute from the log entry let start = event_time - Duration::try_minutes(1).unwrap(); let end = event_time + Duration::try_minutes(1).unwrap(); let apply_adjacent_filter = move |siv: &mut Cursive, text: &str| { let identifier = text.trim().to_string(); if identifier.is_empty() { return; } // Get the filter type for this identifier let filter_info = siv.call_on_name("logs", |base: &mut LogViewBase| { base.filter_mode = false; base.filter_identifiers.get(&identifier).cloned() }); let Some(Some(filter_type)) = filter_info else { siv.add_layer(Dialog::info(format!("Unknown identifier: {}", identifier))); return; }; // Build TextLogArguments based on filter type let (title, args) = match filter_type { FilterType::HostName(hostname) => ( format!("Logs for host: {}", hostname), TextLogArguments { query_ids: None, logger_names: None, hostname: Some(hostname), message_filter: None, max_level: None, start, end: RelativeDateTime::from(end), }, ), FilterType::QueryId(query_id) => ( format!("Logs for query: {}", query_id), TextLogArguments { query_ids: Some(vec![query_id]), logger_names: None, hostname: None, message_filter: None, max_level: None, start, end: RelativeDateTime::from(end), }, ), FilterType::LoggerName(logger_name) => ( format!("Logs for logger: {}", logger_name), TextLogArguments { query_ids: None, logger_names: Some(vec![logger_name]), hostname: None, message_filter: None, max_level: None, start, end: RelativeDateTime::from(end), }, ), FilterType::Level(level) => ( format!("Logs with level <= {}", level), TextLogArguments { query_ids: None, logger_names: None, hostname: None, message_filter: None, max_level: Some(level), start, end: RelativeDateTime::from(end), }, ), }; siv.pop_layer(); siv.add_layer( Dialog::around( TextLogView::new("filtered_logs", context.clone(), args) .with_name("filtered_logs") .full_screen(), ) .title(title), ); }; show_bottom_prompt(siv, "(popup) identifier:", apply_adjacent_filter); } pub struct LogView { inner_view: OnEventView>, } impl LogView { pub fn new( cluster: bool, wrap: bool, no_strip_hostname_suffix: bool, descending: bool, ) -> Self { let mut v = LogViewBase { needs_relayout: true, cluster, wrap, no_strip_hostname_suffix, descending, ..Default::default() }; // In descending mode the newest log goes on top, so pin the viewport there and // let incremental updates keep pushing old content down. v.scroll_core.set_scroll_strategy(if descending { ScrollStrategy::StickToTop } else { ScrollStrategy::StickToBottom }); v.scroll_core.set_scroll_x(!wrap); v.scroll_core.set_scroll_y(true); // NOTE: we cannot pass mutable ref to view in search_prompt callback, sigh. let v = v.with_name("logs"); let scroll = move |v: &mut NamedView, e: &Event| -> Option { v.get_mut().matched_row = None; return Some(scroll::on_event( &mut *v.get_mut(), e.clone(), |s: &mut LogViewBase, e| s.on_event(e), |s, si| s.important_area(si), )); }; let show_options = |siv: &mut Cursive| { let options = move |siv: &mut Cursive, text: &str| { let status = siv.call_on_name("logs", |base: &mut LogViewBase| { let status = base.set_options(text); base.compute_rows(); return status; }); siv.pop_layer(); if let Some(Err(err)) = status { siv.add_layer(Dialog::info(err.to_string())); } }; show_bottom_prompt(siv, "-", options); }; let search_prompt_impl = |siv: &mut Cursive, forward: bool| { let find = move |siv: &mut Cursive, text: &str| { let re = match Regex::new(text) { Ok(re) => re, Err(err) => { siv.pop_layer(); siv.add_layer(Dialog::info(format!("Invalid regex: {err}"))); return; } }; let found = siv.call_on_name("logs", |base: &mut LogViewBase| { base.search_regex = Some(re); base.matched_row = None; base.matched_col = None; base.matched_len = 0; base.search_direction_forward = forward; base.update_search() }); siv.pop_layer(); if let Some(false) = found { siv.add_layer(Dialog::info("Pattern not found")); } }; show_bottom_prompt(siv, "/", find); }; let search_prompt_forward = move |siv: &mut Cursive| { search_prompt_impl(siv, /* forward= */ true); }; let search_prompt_reverse = move |siv: &mut Cursive| { search_prompt_impl(siv, /* forward= */ false); }; let show_save_prompt = |siv: &mut Cursive| { let save_file_impl = |siv: &mut Cursive| { let file_path = siv .call_on_name("save_file_path", |view: &mut EditView| { view.get_content().to_string() }) .unwrap(); siv.pop_layer(); if file_path.trim().is_empty() { siv.add_layer(Dialog::info("File path cannot be empty")); return; } let result = siv.call_on_name("logs", |base: &mut LogViewBase| -> Result<()> { let mut file = fs::File::create(&file_path)?; base.write_plain_text(&mut file)?; Ok(()) }); match result { Some(Ok(_)) => { siv.add_layer(Dialog::info(format!("Logs saved to: {}", file_path))); } Some(Err(err)) => { siv.add_layer(Dialog::info(format!("Error saving file: {}", err))); } None => { siv.add_layer(Dialog::info("Error: Could not access log content")); } } }; let save_file_for_submit = { move |siv: &mut Cursive, _: &str| { save_file_impl(siv); } }; let view = EditView::new() .on_submit(save_file_for_submit) .with_name("save_file_path") .min_width(40); siv.add_layer( Dialog::around(view) .title("Save logs to file") .button("Save", save_file_impl) .button("Cancel", |siv: &mut Cursive| { siv.pop_layer(); }), ); }; let show_share_prompt = |siv: &mut Cursive| { let context = siv.user_data::().unwrap().clone(); let dialog = Dialog::text(format!( "Share logs to {} with end-to-end encryption?", context.clone().lock().unwrap().options.service.pastila_url )) .title("Share Logs") .button("Share (encrypted)", move |siv: &mut Cursive| { let context = context.clone(); siv.pop_layer(); let content = siv.call_on_name("logs", |base: &mut LogViewBase| -> Result { let mut buffer = Vec::new(); base.write_plain_text(&mut buffer)?; Ok(String::from_utf8(buffer)?) }); let content = match content { Some(Ok(c)) => c, Some(Err(e)) => { siv.add_layer(Dialog::info(format!("Error reading logs: {}", e))); return; } None => { siv.add_layer(Dialog::info("Error: Could not access log content")); return; } }; if content.trim().is_empty() { siv.add_layer(Dialog::info("No logs to share")); return; } siv.add_layer(Dialog::text("Uploading logs...").title("Please wait")); context .lock() .unwrap() .worker .send(true, crate::interpreter::WorkerEvent::ShareLogs(content)); }) .button("Cancel", |siv: &mut Cursive| { siv.pop_layer(); }); siv.add_layer(dialog); }; let toggle_filter_mode_and_prompt = |siv: &mut Cursive| { siv.call_on_name("logs", |base: &mut LogViewBase| { if base.filter_mode { base.filter_mode = false; base.active_filter = None; base.rebuild_content_normal(); } else { base.filter_mode = true; base.extract_identifiers(); base.rebuild_content_with_highlights(); } }); let should_show_prompt = siv .call_on_name("logs", |base: &mut LogViewBase| base.filter_mode) .unwrap_or(false); if should_show_prompt { let apply_filter = move |siv: &mut Cursive, text: &str| { let identifier = text.trim().to_string(); siv.pop_layer(); if identifier.is_empty() { siv.call_on_name("logs", |base: &mut LogViewBase| { base.filter_mode = false; base.active_filter = None; base.rebuild_content_normal(); }); return; } let filter_result = siv.call_on_name("logs", |base: &mut LogViewBase| { if let Some(filter_type) = base.filter_identifiers.get(&identifier) { base.filter_mode = false; base.active_filter = Some(filter_type.clone()); base.apply_filter(); Ok(()) } else { Err(format!("Unknown identifier: {}", identifier)) } }); if let Some(Err(msg)) = filter_result { siv.add_layer(Dialog::info(msg)); } }; show_bottom_prompt(siv, "identifier:", apply_filter); } }; let v = OnEventView::new(v) .on_pre_event_inner(Key::PageUp, scroll) .on_pre_event_inner(Key::PageDown, scroll) .on_pre_event_inner(Key::Left, scroll) .on_pre_event_inner(Key::Right, scroll) .on_pre_event_inner(Key::Up, scroll) .on_pre_event_inner(Key::Down, scroll) .on_pre_event_inner('j', move |v, _| scroll(v, &Event::Key(Key::Down))) .on_pre_event_inner('k', move |v, _| scroll(v, &Event::Key(Key::Up))) .on_pre_event_inner('g', move |v, _| scroll(v, &Event::Key(Key::Home))) .on_pre_event_inner(Key::End, move |v, _| { let mut base = v.get_mut(); base.matched_row = None; base.scroll_core .set_scroll_strategy(ScrollStrategy::StickToBottom); Some(EventResult::consumed()) }) .on_pre_event_inner('G', move |v, _| { let mut base = v.get_mut(); base.matched_row = None; base.scroll_core .set_scroll_strategy(ScrollStrategy::StickToBottom); Some(EventResult::consumed()) }) .on_event_inner('-', move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn(show_options)))); }) .on_event_inner('/', move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( search_prompt_forward, )))); }) .on_event_inner('?', move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( search_prompt_reverse, )))); }) .on_event_inner('n', move |v, _| { let mut base = v.get_mut(); base.search_direction_forward = true; if base.update_search_forward() { return Some(EventResult::consumed()); } else { return Some(EventResult::Consumed(Some(Callback::from_fn(|siv| { siv.add_layer(Dialog::info("Pattern not found")); })))); } }) .on_event_inner('N', move |v, _| { let mut base = v.get_mut(); base.search_direction_forward = false; if base.update_search_reverse() { return Some(EventResult::consumed()); } else { return Some(EventResult::Consumed(Some(Callback::from_fn(|siv| { siv.add_layer(Dialog::info("Pattern not found")); })))); } }) .on_event_inner('s', move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( show_save_prompt, )))); }) .on_event_inner('S', move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( show_share_prompt, )))); }) .on_event_inner(Event::CtrlChar('f'), move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( toggle_filter_mode_and_prompt, )))); }) .on_event_inner(Event::CtrlChar('s'), move |_, _| { return Some(EventResult::Consumed(Some(Callback::from_fn( show_filtered_logs_popup, )))); }); let log_view = LogView { inner_view: v }; return log_view; } pub fn push_logs(&mut self, logs: Vec) { self.inner_view.get_inner_mut().get_mut().push_logs(logs); } } impl View for LogViewBase { fn draw(&self, printer: &Printer<'_, '_>) { scroll::draw(self, printer, Self::draw_content); } fn layout(&mut self, size: Vec2) { scroll::layout( self, size.saturating_sub((0, 0)), self.needs_relayout, Self::layout_content, Self::inner_required_size, ); if let Some(matched_row) = self.matched_row { let match_start = self.matched_col.unwrap_or(0); let match_end = match_start + self.matched_len; let viewport_width = self.scroll_core.last_available_size().x; let current_offset = self.scroll_core.content_viewport().left(); // Only adjust horizontal scroll if the match is not fully visible let x_offset = if match_end > current_offset + viewport_width { // Match extends beyond right edge - scroll to show the end with max context on left match_end.saturating_sub(viewport_width) } else if match_start < current_offset { // Match starts before left edge - scroll to show start with some context match_start } else { // Match is already visible - keep current position current_offset }; self.scroll_core.set_offset((x_offset, matched_row)); } } } impl ViewWrapper for LogView { wrap_impl!(self.inner_view: OnEventView>); fn wrap_required_size(&mut self, mut req: Vec2) -> Vec2 { req = self .inner_view .get_inner_mut() .get_mut() .inner_required_size(req); // For scrollbars req.x += 1; req.y += 1; return req; } } ================================================ FILE: src/view/mod.rs ================================================ mod log_view; mod navigation; mod provider; pub mod providers; mod queries_view; mod query_view; mod registry; pub mod search_history; mod settings_view; mod sql_query_view; mod summary_view; pub mod table_view; mod text_log_view; mod utils; pub use navigation::Navigation; pub use provider::ViewProvider; pub use queries_view::QueriesView; pub use queries_view::Type as ProcessesType; pub use query_view::QueryView; pub use registry::ViewRegistry; pub use sql_query_view::Row as QueryResultRow; pub use sql_query_view::SQLQueryView; pub use summary_view::SummaryView; pub use table_view::TableViewItem; pub use log_view::LogEntry; pub use log_view::LogView; pub use text_log_view::TextLogView; pub use utils::show_bottom_prompt; ================================================ FILE: src/view/navigation.rs ================================================ use crate::utils::{fuzzy_actions, fuzzy_select_strings}; use crate::{ common::parse_datetime_or_date, interpreter::{ContextArc, WorkerEvent, clickhouse::TraceType, options::ChDigViews}, view::{self, settings_view}, }; use anyhow::Result; use chrono::{DateTime, Local}; use cursive::{ Cursive, event::{Event, EventResult, Key}, theme::{BaseColor, Color, ColorStyle, Effect, PaletteColor, Style, Theme}, utils::{markup::StyledString, span::SpannedString}, view::{IntoBoxedView, Nameable, Resizable, View}, views::{Dialog, DummyView, EditView, LinearLayout, OnEventView, SelectView, TextView}, }; use cursive_flexi_logger_view::toggle_flexi_logger_debug_console; fn toggle_debug_metrics(siv: &mut Cursive) { let ctx = siv.user_data::().unwrap().clone(); let metrics = ctx.lock().unwrap().debug_metrics.clone(); let shown = metrics.toggle_shown(); // Paint immediately on both transitions so the user sees the toggle take effect // without waiting for the next refresh tick (and so stale numbers don't linger on hide). if shown { siv.set_statusbar_debug(metrics.snapshot().to_string()); } else { siv.set_statusbar_debug(""); } } fn make_menu_text() -> StyledString { let mut text = StyledString::new(); // F1 text.append_plain("F1"); text.append_styled("Help", ColorStyle::highlight()); // F2 text.append_plain("F2"); text.append_styled("Views", ColorStyle::highlight()); // F3 text.append_plain("F3"); text.append_styled("Settings", ColorStyle::highlight()); // F8 text.append_plain("F8"); text.append_styled("Actions", ColorStyle::highlight()); return text; } pub trait Navigation { fn has_view(&mut self, name: &str) -> bool; fn make_theme_from_therminal(&mut self) -> Theme; fn pop_ui(&mut self, exit: bool); fn toggle_pause_updates(&mut self, reason: Option<&str>); fn refresh_view(&mut self); fn seek_time_frame(&mut self, is_sub: bool); fn select_time_frame(&mut self); fn initialize_global_shortcuts(&mut self, context: ContextArc); fn initialize_views_menu(&mut self, context: ContextArc); fn chdig(&mut self, context: ContextArc); fn show_help_dialog(&mut self); fn show_settings_dialog(&mut self); fn show_views(&mut self); fn show_actions(&mut self); fn show_fuzzy_actions(&mut self); fn show_server_flamegraph(&mut self, tui: bool, trace_type: Option); fn show_jemalloc_flamegraph(&mut self, tui: bool); fn show_server_perfetto(&mut self); fn show_connection_dialog(&mut self); fn drop_main_view(&mut self); fn set_main_view(&mut self, view: V); fn set_statusbar_version(&mut self, main_content: impl Into>); fn set_statusbar_content(&mut self, content: impl Into>); fn set_statusbar_connection(&mut self, content: impl Into>); fn set_statusbar_debug(&mut self, content: impl Into>); // TODO: move into separate trait fn call_on_name_or_render_error(&mut self, name: &str, callback: F) where V: View, F: FnOnce(&mut V) -> Result<()>; } impl Navigation for Cursive { fn has_view(&mut self, name: &str) -> bool { return self.focus_name(name).is_ok(); } fn make_theme_from_therminal(&mut self) -> Theme { let mut theme = self.current_theme().clone(); theme.palette[PaletteColor::Background] = Color::TerminalDefault; theme.palette[PaletteColor::View] = Color::TerminalDefault; theme.palette[PaletteColor::Primary] = Color::TerminalDefault; theme.palette[PaletteColor::Highlight] = Color::Light(BaseColor::Cyan); theme.palette[PaletteColor::HighlightText] = Color::Dark(BaseColor::Black); theme.shadow = false; return theme; } fn pop_ui(&mut self, exit: bool) { // Close left menu let mut has_left_menu = false; self.call_on_name("left_menu", |left_menu_view: &mut LinearLayout| { if !left_menu_view.is_empty() { left_menu_view .remove_child(left_menu_view.len() - 1) .expect("No child view to remove"); has_left_menu = true; } }); // Once at a time if has_left_menu { self.focus_name("main").unwrap(); return; } if self.screen_mut().len() == 1 { if exit { self.quit(); } } else { self.pop_layer(); } } fn toggle_pause_updates(&mut self, reason: Option<&str>) { let is_paused; { let mut context = self.user_data::().unwrap().lock().unwrap(); // NOTE: though it will be better to stop sending any message completely, instead of // simply ignoring them context.worker.toggle_pause(); is_paused = context.worker.is_paused(); } self.call_on_name("is_paused", |v: &mut TextView| { let mut text = StyledString::new(); if is_paused { text.append_styled(" PAUSED", Effect::Bold); if let Some(reason) = reason { text.append_styled(format!(" ({})", reason), Effect::Bold); } text.append_styled(" press P to resume", Effect::Italic); } v.set_content(text); }); } fn refresh_view(&mut self) { let context = self.user_data::().unwrap().lock().unwrap(); log::trace!("Toggle refresh"); context.trigger_view_refresh(); } fn seek_time_frame(&mut self, is_sub: bool) { let mut context = self.user_data::().unwrap().lock().unwrap(); context.shift_time_interval(is_sub, 10); context.trigger_view_refresh(); } fn select_time_frame(&mut self) { let on_submit = move |siv: &mut Cursive| { let start = siv .call_on_name("start", |view: &mut EditView| view.get_content()) .unwrap(); let end = siv .call_on_name("end", |view: &mut EditView| view.get_content()) .unwrap(); siv.pop_layer(); let new_begin = match parse_datetime_or_date(&start) { Ok(new) => new, Err(err) => { siv.add_layer(Dialog::info(err)); return; } }; let new_end = match parse_datetime_or_date(&end) { Ok(new) => new, Err(err) => { siv.add_layer(Dialog::info(err)); return; } }; log::debug!("Set time frame to ({}, {})", new_begin, new_end); let mut context = siv.user_data::().unwrap().lock().unwrap(); context.options.view.start = new_begin.into(); context.options.view.end = new_end.into(); context.trigger_view_refresh(); }; let view = OnEventView::new( Dialog::new() .title("Set the time interval") .content( LinearLayout::vertical() .child(TextView::new( "format: YYYY-MM-DDTHH:MM:SS[.ssssss][±hh:mm|Z]", )) .child(DummyView) .child(TextView::new("start:")) .child(EditView::new().with_name("start")) .child(DummyView) .child(TextView::new("end:")) .child(EditView::new().with_name("end")), ) .button("Submit", on_submit), ); self.add_layer(view); } fn chdig(&mut self, context: ContextArc) { self.set_user_data(context.clone()); self.initialize_global_shortcuts(context.clone()); self.initialize_views_menu(context.clone()); let theme = self.make_theme_from_therminal(); self.set_theme(theme); self.add_fullscreen_layer( LinearLayout::horizontal() .child(LinearLayout::vertical().with_name("left_menu")) .child( LinearLayout::vertical() .child( LinearLayout::horizontal() .child(TextView::new(make_menu_text())) .child(TextView::new("").with_name("is_paused")) // Align status to the right .child(DummyView.full_width()) // Empty until `!` toggles it — no visual cost when hidden. .child(TextView::new("").with_name("debug_status")) .child(TextView::new("").with_name("status")) .child(DummyView.fixed_width(1)) .child(TextView::new("").with_name("connection")) .child(DummyView.fixed_width(1)) .child(TextView::new("").with_name("version")), ) .child(view::SummaryView::new(context.clone()).with_name("summary")) .with_name("main"), ), ); { let ctx = context.lock().unwrap(); self.set_statusbar_version(ctx.server_version.clone()); self.set_statusbar_connection(ctx.options.clickhouse.connection_info()); } let start_view = context .lock() .unwrap() .options .start_view .unwrap_or(ChDigViews::Queries); let provider = context .lock() .unwrap() .view_registry .get_by_view_type(start_view); provider.show(self, context.clone()); } /// Ignore rustfmt max_width, otherwise callback actions looks ugly #[rustfmt::skip] fn initialize_global_shortcuts(&mut self, context: ContextArc) { let mut context = context.lock().unwrap(); context.add_global_action(self, "Show help", Key::F1, |siv| siv.show_help_dialog()); context.add_global_action(self, "Settings", Key::F3, |siv| siv.show_settings_dialog()); context.add_global_action(self, "Views", Key::F2, |siv| siv.show_views()); context.add_global_action(self, "Show actions", Key::F8, |siv| siv.show_actions()); context.add_global_action(self, "Fuzzy actions", Event::CtrlChar('p'), |siv| siv.show_fuzzy_actions()); if context.options.clickhouse.cluster.is_some() { context.add_global_action(self, "Filter by host", Event::CtrlChar('h'), |siv| siv.show_connection_dialog()); } context.add_global_action(self, "Server CPU Flamegraph", 'F', |siv| siv.show_server_flamegraph(true, Some(TraceType::CPU))); context.add_global_action_without_shortcut(self, "Server Real Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::Real))); context.add_global_action_without_shortcut(self, "Server Memory Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::Memory))); context.add_global_action_without_shortcut(self, "Server Memory Sample Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::MemorySample))); context.add_global_action_without_shortcut(self, "Server Jemalloc Sample Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::JemallocSample))); context.add_global_action_without_shortcut(self, "Server MemoryAllocatedWithoutCheck Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::MemoryAllocatedWithoutCheck))); context.add_global_action_without_shortcut(self, "Server Events Flamegraph", |siv| siv.show_server_flamegraph(true, Some(TraceType::ProfileEvent))); context.add_global_action_without_shortcut(self, "Server Live Flamegraph", |siv| siv.show_server_flamegraph(true, None)); context.add_global_action_without_shortcut(self, "Share Server CPU Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::CPU))); context.add_global_action_without_shortcut(self, "Share Server Real Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::Real))); context.add_global_action_without_shortcut(self, "Share Server Memory Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::Memory))); context.add_global_action_without_shortcut(self, "Share Server Memory Sample Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::MemorySample))); context.add_global_action_without_shortcut(self, "Share Server MemoryAllocatedWithoutCheck Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::MemoryAllocatedWithoutCheck))); context.add_global_action_without_shortcut(self, "Share Server Events Flamegraph", |siv| siv.show_server_flamegraph(false, Some(TraceType::ProfileEvent))); context.add_global_action_without_shortcut(self, "Share Server Live Flamegraph", |siv| siv.show_server_flamegraph(false, None)); context.add_global_action_without_shortcut(self, "Jemalloc", |siv| siv.show_jemalloc_flamegraph(true)); context.add_global_action_without_shortcut(self, "Share Jemalloc", |siv| siv.show_jemalloc_flamegraph(false)); context.add_global_action_without_shortcut(self, "Server Perfetto Export", |siv| siv.show_server_perfetto()); // If logging is done to file, console is always empty if context.options.service.log.is_none() { context.add_global_action( self, "chdig debug console", '~', toggle_flexi_logger_debug_console, ); } context.add_global_action(self, "Toggle debug metrics", '!', toggle_debug_metrics); context.add_global_action(self, "Back/Quit", Key::Esc, |siv| siv.pop_ui(false)); context.add_global_action(self, "Back/Quit", 'q', |siv| siv.pop_ui(true)); context.add_global_action(self, "Quit forcefully", 'Q', |siv| siv.quit()); context.add_global_action(self, "Back", Key::Backspace, |siv| siv.pop_ui(false)); context.add_global_action(self, "Toggle pause", 'p', |siv| siv.toggle_pause_updates(None)); context.add_global_action(self, "Refresh", 'r', |siv| siv.refresh_view()); // Bindings T/t inspiried by atop(1) (so as this functionality) context.add_global_action(self, "Seek 10 mins backward", 'T', |siv| siv.seek_time_frame(true)); context.add_global_action(self, "Seek 10 mins forward", 't', |siv| siv.seek_time_frame(false)); context.add_global_action(self, "Set time interval", Event::AltChar('t'), |siv| siv.select_time_frame()); } fn initialize_views_menu(&mut self, context: ContextArc) { use crate::view::providers::*; use std::sync::Arc; let mut c = context.lock().unwrap(); c.register_provider(Arc::new(ProcessesViewProvider)); c.register_provider(Arc::new(SlowQueryLogViewProvider)); c.register_provider(Arc::new(LastQueryLogViewProvider)); c.register_provider(Arc::new(MergesViewProvider)); c.register_provider(Arc::new(S3QueueViewProvider)); c.register_provider(Arc::new(AzureQueueViewProvider)); c.register_provider(Arc::new(MutationsViewProvider)); c.register_provider(Arc::new(ReplicatedFetchesViewProvider)); c.register_provider(Arc::new(ReplicationQueueViewProvider)); c.register_provider(Arc::new(ReplicasViewProvider)); c.register_provider(Arc::new(TablesViewProvider)); c.register_provider(Arc::new(BackgroundSchedulePoolViewProvider)); c.register_provider(Arc::new(BackgroundSchedulePoolLogViewProvider)); c.register_provider(Arc::new(TablePartsViewProvider)); c.register_provider(Arc::new(AsynchronousInsertsViewProvider)); c.register_provider(Arc::new(PartLogViewProvider)); c.register_provider(Arc::new(BackupsViewProvider)); c.register_provider(Arc::new(DictionariesViewProvider)); c.register_provider(Arc::new(ServerLogsViewProvider)); c.register_provider(Arc::new(LoggerNamesViewProvider)); c.register_provider(Arc::new(ErrorsViewProvider)); c.register_provider(Arc::new(ClientViewProvider)); } fn show_help_dialog(&mut self) { if self.has_view("help") { self.pop_layer(); return; } let mut text = StyledString::default(); text.append_styled( format!("chdig v{version}\n", version = env!("CARGO_PKG_VERSION")), Effect::Bold, ); { let context = self.user_data::().unwrap().lock().unwrap(); text.append_styled("\nGlobal shortcuts:\n\n", Effect::Bold); for shortcut in context.global_actions.iter() { text.append(shortcut.description.preview_styled()); } text.append_styled("\nActions:\n\n", Effect::Bold); for shortcut in context.view_actions.iter() { text.append(shortcut.description.preview_styled()); } } text.append_styled("\nExtended navigation:\n\n", Effect::Bold); text.append_styled( format!("{:>10} - reset selection/follow item in table\n", "Home"), Effect::Bold, ); text.append_plain(format!( "\nIssues and suggestions: {homepage}/issues", homepage = env!("CARGO_PKG_HOMEPAGE") )); self.add_layer(Dialog::info(text).with_name("help")); } fn show_settings_dialog(&mut self) { settings_view::show_settings_dialog(self); } fn show_views(&mut self) { let mut has_views = false; let context = self.user_data::().unwrap().clone(); self.call_on_name("left_menu", |left_menu_view: &mut LinearLayout| { if !left_menu_view.is_empty() { left_menu_view .remove_child(left_menu_view.len() - 1) .expect("No child view to remove"); } else { let mut select = SelectView::new().autojump(); { let context = context.clone(); select.set_on_submit(move |siv, selected_action: &str| { log::trace!("Switching to {:?}", selected_action); siv.focus_name("main").unwrap(); { let action_callback = context .lock() .unwrap() .views_menu_actions .iter() .find(|x| x.description.text == selected_action) .unwrap() .callback .clone(); action_callback.as_ref()(siv); }; siv.call_on_name("left_menu", |left_menu_view: &mut LinearLayout| { left_menu_view .remove_child(left_menu_view.len() - 1) .expect("No child view to remove"); }); }); } { let context = context.clone(); let context = context.lock().unwrap(); for action in context.views_menu_actions.iter() { select.add_item_str(action.description.text); } } let select = OnEventView::new(select) .on_pre_event_inner('k', |s, _| { let cb = s.select_up(1); Some(EventResult::Consumed(Some(cb))) }) .on_pre_event_inner('j', |s, _| { let cb = s.select_down(1); Some(EventResult::Consumed(Some(cb))) }) .with_name("actions_select"); left_menu_view.add_child(select); has_views = true; } }); if has_views { self.focus_name("left_menu").unwrap(); } else { self.focus_name("main").unwrap(); } } fn show_actions(&mut self) { let mut has_actions = false; let context = self.user_data::().unwrap().clone(); self.call_on_name("left_menu", |left_menu_view: &mut LinearLayout| { if !left_menu_view.is_empty() { left_menu_view .remove_child(left_menu_view.len() - 1) .expect("No child view to remove"); } else { let mut select = SelectView::new().autojump(); { let context = context.clone(); select.set_on_submit(move |siv, selected_action: &str| { log::trace!("Triggering {:?} (from actions)", selected_action); siv.focus_name("main").unwrap(); { let mut context = context.lock().unwrap(); let action_callback = context .view_actions .iter() .find(|x| x.description.text == selected_action) .unwrap() .callback .clone(); context.pending_view_callback = Some(action_callback); }; siv.on_event(Event::Refresh); siv.call_on_name("left_menu", |left_menu_view: &mut LinearLayout| { left_menu_view .remove_child(left_menu_view.len() - 1) .expect("No child view to remove"); }); }); } { let context = context.clone(); let context = context.lock().unwrap(); for action in context.view_actions.iter() { select.add_item_str(action.description.text); } if context.view_actions.is_empty() { return; } } let select = OnEventView::new(select) .on_pre_event_inner('k', |s, _| { let cb = s.select_up(1); Some(EventResult::Consumed(Some(cb))) }) .on_pre_event_inner('j', |s, _| { let cb = s.select_down(1); Some(EventResult::Consumed(Some(cb))) }) .with_name("actions_select"); left_menu_view.add_child(select); has_actions = true; } }); if has_actions { self.focus_name("left_menu").unwrap(); } else { self.focus_name("main").unwrap(); } } fn show_fuzzy_actions(&mut self) { let context = self.user_data::().unwrap().clone(); let all_actions = { let context = context.lock().unwrap(); context .global_actions .iter() .map(|x| &x.description) .chain(context.view_actions.iter().map(|x| &x.description)) .chain(context.views_menu_actions.iter().map(|x| &x.description)) .cloned() .collect() }; fuzzy_actions(self, all_actions, move |siv, action_text| { log::trace!("Triggering {:?} (from fuzzy search)", action_text); // Global callbacks { let action_callback = context .lock() .unwrap() .global_actions .iter() .find(|x| x.description.text == action_text) .map(|a| a.callback.clone()); if let Some(action_callback) = action_callback { action_callback.as_ref()(siv); } } // View callbacks { let mut context = context.lock().unwrap(); if let Some(action) = context .view_actions .iter() .find(|x| x.description.text == action_text) { context.pending_view_callback = Some(action.callback.clone()); // The pending_view_callback handling is binded to Event::Refresh event, but it // cannot be called with the context locked, so it will be called // asynchronously after Event::Refresh below // // But, we also need it to cleanup the screen (to avoid any leftovers), so, it // will be called always. } } // View menus { let action_callback = context .lock() .unwrap() .views_menu_actions .iter() .find(|x| x.description.text == action_text) .map(|a| a.callback.clone()); if let Some(action_callback) = action_callback { action_callback.as_ref()(siv); } } siv.on_event(Event::Refresh); }); } fn show_server_flamegraph(&mut self, tui: bool, trace_type: Option) { let mut context = self.user_data::().unwrap().lock().unwrap(); let start: DateTime = context.options.view.start.clone().into(); let end: DateTime = context.options.view.end.clone().into(); if let Some(trace_type) = trace_type { context.worker.send( true, WorkerEvent::ServerFlameGraph(tui, trace_type, start, end), ); } else { context .worker .send(true, WorkerEvent::LiveQueryFlameGraph(tui, None)); } } fn show_jemalloc_flamegraph(&mut self, tui: bool) { let mut context = self.user_data::().unwrap().lock().unwrap(); context .worker .send(true, WorkerEvent::JemallocFlameGraph(tui)); } fn show_server_perfetto(&mut self) { let context = self.user_data::().unwrap().clone(); let (start_str, end_str) = { let ctx = context.lock().unwrap(); ( ctx.options.view.start.to_editable_string(), ctx.options.view.end.to_editable_string(), ) }; let on_submit = move |siv: &mut Cursive| { let start_str = siv .call_on_name("perfetto_start", |view: &mut EditView| view.get_content()) .unwrap(); let end_str = siv .call_on_name("perfetto_end", |view: &mut EditView| view.get_content()) .unwrap(); let start = match start_str.parse::() { Ok(v) => v, Err(err) => { siv.add_layer(Dialog::info(format!("Invalid start: {}", err))); return; } }; let end = match end_str.parse::() { Ok(v) => v, Err(err) => { siv.add_layer(Dialog::info(format!("Invalid end: {}", err))); return; } }; siv.pop_layer(); let start_dt: DateTime = start.into(); let end_dt: DateTime = end.into(); let mut ctx = siv.user_data::().unwrap().lock().unwrap(); ctx.worker .send(true, WorkerEvent::ServerPerfettoExport(start_dt, end_dt)); }; let dialog = Dialog::new() .title("Server Perfetto Export") .content( LinearLayout::vertical() .child(TextView::new( "Warning: server-wide export is heavy (~1.5 GiB/server\nfor 2 min). Consider reducing the time range.", )) .child(DummyView) .child(TextView::new("start:")) .child( EditView::new() .content(start_str) .with_name("perfetto_start") .fixed_width(30), ) .child(DummyView) .child(TextView::new("end:")) .child( EditView::new() .content(end_str) .with_name("perfetto_end") .fixed_width(30), ), ) .button("Export", on_submit) .button("Cancel", |siv| { siv.pop_layer(); }); self.add_layer(dialog); } fn show_connection_dialog(&mut self) { let context_arc = self.user_data::().unwrap().clone(); let context = context_arc.lock().unwrap(); let cluster = context.options.clickhouse.cluster.clone(); if cluster.is_none() { drop(context); self.add_layer(Dialog::info( "Cluster mode is not enabled. Use --cluster option.", )); return; } let clickhouse = context.clickhouse.clone(); let cb_sink = context.cb_sink.clone(); drop(context); std::thread::spawn(move || { let runtime = tokio::runtime::Runtime::new().unwrap(); let hosts = runtime.block_on(async { clickhouse.get_cluster_hosts().await }); cb_sink .send(Box::new(move |siv: &mut Cursive| match hosts { Ok(hosts) if !hosts.is_empty() => { let context_arc = siv.user_data::().unwrap().clone(); let mut items: Vec<(String, String)> = Vec::with_capacity(hosts.len() + 1); items.push(("".to_string(), String::new())); for host in hosts { items.push((host.clone(), host)); } fuzzy_select_strings( siv, "Filter by host", items, move |siv, selected_host| { let current_view = { let mut context = context_arc.lock().unwrap(); let url_safe = context.options.clickhouse.url_safe.clone(); if selected_host.is_empty() { context.selected_host = None; log::info!("Reset host filter"); siv.set_statusbar_connection(url_safe); } else { context.selected_host = Some(selected_host.clone()); log::info!("Set host filter to: {}", selected_host); siv.set_statusbar_connection(format!( "{url_safe} (host: {selected_host})" )); } context .current_view .or(context.options.start_view) .unwrap_or(ChDigViews::Queries) }; log::info!("Reopen {:?} view", current_view); let provider = context_arc .lock() .unwrap() .view_registry .get_by_view_type(current_view); siv.drop_main_view(); provider.show(siv, context_arc.clone()); context_arc.lock().unwrap().trigger_view_refresh(); }, ); } Ok(_) => { siv.add_layer(Dialog::info("No hosts found in cluster")); } Err(err) => { siv.add_layer(Dialog::info(format!( "Failed to fetch cluster hosts: {}", err ))); } })) .unwrap(); }); } fn drop_main_view(&mut self) { while self.screen_mut().len() > 1 { self.pop_layer(); } self.call_on_name("main", |main_view: &mut LinearLayout| { // Views that should not be touched: // - top bar (menu text + is_paused + status) // - summary if main_view.len() > 2 { main_view .remove_child(main_view.len() - 1) .expect("No child view to remove"); } }); } fn set_main_view(&mut self, view: V) { self.call_on_name("main", |main_view: &mut LinearLayout| { main_view.add_child(view); }); } fn set_statusbar_version(&mut self, main_content: impl Into>) { self.call_on_name("version", |text_view: &mut TextView| { let content: SpannedString