├── .bumpversion.cfg ├── .flake8 ├── .github └── workflows │ ├── lint.yaml │ ├── pytest.yaml │ └── release.yaml ├── .gitignore ├── README.md ├── docs └── flameshow.gif ├── flake.lock ├── flake.nix ├── flameshow ├── __init__.py ├── colors.py ├── const.py ├── exceptions.py ├── main.py ├── models.py ├── parsers │ ├── __init__.py │ └── stackcollapse_parser.py ├── pprof_parser │ ├── __init__.py │ ├── parser.py │ └── profile_pb2.py ├── render │ ├── __init__.py │ ├── app.py │ ├── flamegraph.py │ ├── framedetail.py │ ├── header.py │ └── tabs.py ├── runtime.py └── utils.py ├── makefile ├── poetry.lock ├── proto └── profile.proto ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── pprof_data ├── goroutine.out ├── goroutine_frametree.json ├── heap.out ├── profile-10seconds.out ├── profile10s_frametree.json ├── profile10s_node_exporter.json └── sample_location_line_multiple.json ├── stackcollapse_data ├── flameshow-pyspy-dump.txt ├── perf-vertx-stacks-01-collapsed-all.txt ├── simple.txt ├── simple_square.txt └── with_comment.txt ├── test_cli_click.py ├── test_colors.py ├── test_integration └── test_app.py ├── test_main.py ├── test_models.py ├── test_pprof_parse ├── __init__.py └── test_golang_pprof.py ├── test_profile_parser.py ├── test_render ├── __init__.py ├── test_app.py ├── test_flamegraph.py ├── test_render_detail.py └── test_render_header.py ├── test_stackcollapse_parse ├── __init__.py ├── test_model.py └── test_parser.py ├── test_utils.py └── utils.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.1.4 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:flameshow/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | __pycache__, 5 | dist, 6 | ./flameshow/pprof_parser/profile_pb2.py, 7 | tests/ 8 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | flake8: 11 | name: flake8 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Cache venv 17 | uses: actions/cache@v2 18 | with: 19 | path: venv 20 | key: lintenv-flake8 21 | 22 | - name: Install Dependencies 23 | run: | 24 | python3 -m venv venv 25 | . venv/bin/activate 26 | pip install -U pip flake8 27 | 28 | - name: Flake8 test 29 | run: | 30 | . venv/bin/activate 31 | flake8 flameshow 32 | 33 | spell: 34 | name: Spell Check 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - uses: codespell-project/actions-codespell@v2 41 | with: 42 | check_filenames: true 43 | skip: "*.log,./tests/pprof_data/*,poetry.lock,./tests/stackcollapse_data" 44 | ignore_words_list: buildin 45 | 46 | - uses: actions/setup-python@v4 47 | with: 48 | ignore_words_list: hello,world 49 | python-version: "3.10" 50 | architecture: "x64" 51 | 52 | black: 53 | name: black 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Cache venv 59 | uses: actions/cache@v2 60 | with: 61 | path: venv 62 | key: lintenv-black 63 | 64 | - name: Install Dependencies 65 | run: | 66 | python3 -m venv venv 67 | . venv/bin/activate 68 | pip install -U pip black 69 | 70 | - name: Black test 71 | run: | 72 | . venv/bin/activate 73 | black --check --diff . 74 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | # os: [ubuntu-latest, macos-latest, windows-latest] 15 | os: [ubuntu-latest, macos-latest] 16 | # python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 17 | python-version: ["3.10", "3.11", "3.12"] 18 | 19 | exclude: 20 | # see https://github.com/actions/setup-python/issues/948 21 | - os: macos-latest 22 | python-version: "3.10" 23 | # see https://github.com/actions/setup-python/issues/948 24 | - os: macos-latest 25 | python-version: "3.11" 26 | 27 | # https://github.com/python/typing_extensions/issues/377 28 | - os: macos-latest 29 | python-version: "3.12" 30 | defaults: 31 | run: 32 | shell: bash 33 | steps: 34 | - uses: actions/checkout@v3.5.2 35 | - name: Install and configure Poetry # This could be cached, too... 36 | uses: snok/install-poetry@v1.3.3 37 | with: 38 | version: 1.4.2 39 | virtualenvs-in-project: true 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | architecture: x64 45 | allow-prereleases: true 46 | - name: Load cached venv 47 | id: cached-poetry-dependencies 48 | uses: actions/cache@v3 49 | with: 50 | path: .venv 51 | key: 52 | venv-${{ runner.os }}-${{ matrix.python-version }}-${{ 53 | hashFiles('**/poetry.lock') }} 54 | - name: Install dependencies 55 | run: poetry install 56 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 57 | 58 | - name: Test with pytest 59 | run: | 60 | source $VENV 61 | pytest tests -v --cov=./flameshow --cov-report=xml:./coverage.xml --cov-report term-missing 62 | 63 | - name: Upload coverage reports to Codecov 64 | uses: codecov/codecov-action@v3 65 | env: 66 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 67 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release-pypi: 10 | name: release-pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v4 16 | - name: Install Dependencies 17 | run: | 18 | python3 -m venv venv 19 | . venv/bin/activate 20 | pip install -U pip 21 | pip install poetry 22 | poetry install 23 | python -c "import sys; print(sys.version)" 24 | pip list 25 | 26 | - name: Poetry Build 27 | run: | 28 | . venv/bin/activate 29 | poetry build 30 | 31 | - name: Test Build 32 | run: | 33 | python3 -m venv fresh_env 34 | . fresh_env/bin/activate 35 | pip install dist/*.whl 36 | 37 | flameshow --version 38 | 39 | - name: Upload to Pypi 40 | env: 41 | PASSWORD: ${{ secrets.FLAMESHOW_PYPI_TOKEN }} 42 | run: | 43 | . venv/bin/activate 44 | poetry publish --username __token__ --password ${PASSWORD} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | *.log 162 | goroutine.json 163 | debug/ 164 | flameshow/pprof_parser/_libpprofparser.cpython-310-darwin.h 165 | *.svg 166 | *.out 167 | flameshow/pprof_parser/_libpprofparser* 168 | *.tar.gz 169 | a.json 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flameshow 2 | 3 | [![tests](https://github.com/laixintao/flameshow/actions/workflows/pytest.yaml/badge.svg?branch=main)](https://github.com/laixintao/flameshow/actions/workflows/pytest.yaml) 4 | [![codecov](https://codecov.io/gh/laixintao/flameshow/graph/badge.svg?token=XQCGN9GBL4)](https://codecov.io/gh/laixintao/flameshow) 5 | [![PyPI](https://img.shields.io/pypi/v/flameshow.svg?logo=pypi&label=PyPI&logoColor=gold)](https://pypi.org/project/flameshow/) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flameshow?logo=python&logoColor=gold) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/flameshow) 8 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 9 | 10 | Flameshow is a terminal Flamegraph viewer. 11 | 12 | ![](./docs/flameshow.gif) 13 | 14 | ## Features 15 | 16 | - Renders Flamegraphs in your terminal 17 | - Supports zooming in and displaying percentages 18 | - Keyboard input is prioritized 19 | - All operations can also be performed using the mouse. 20 | - Can switch to different sample types 21 | 22 | ## Install 23 | 24 | Flameshow is written in pure Python, so you can install via `pip`: 25 | 26 | ```shell 27 | pip install flameshow 28 | ``` 29 | 30 | But you can also run it through [nix](https://nixos.org/): 31 | 32 | ```shell 33 | nix run github:laixintao/flameshow 34 | # Or if you want to install it imperatively: 35 | nix profile install github:laixintao/flameshow 36 | ``` 37 | 38 | ## Usage 39 | 40 | View golang's goroutine dump: 41 | 42 | ```shell 43 | $ curl http://localhost:9100/debug/pprof/goroutine -o goroutine.out 44 | $ flameshow goroutine.out 45 | ``` 46 | 47 | After entering the TUI, the available actions are listed on Footer: 48 | 49 | - q for quit 50 | - h j k l or 51 | for moving around, and Enter 52 | for zoom in, then Esc for zoom out. 53 | - You can also use a mouse, hover on a span will show it details, and click will 54 | zoom it. 55 | 56 | ## Supported Formats 57 | 58 | As far as I know, there is no standard specification for profiles. Different 59 | languages or tools might generate varying profile formats. I'm actively working 60 | on supporting more formats. Admittedly, I might not be familiar with every tool 61 | and its specific format. So, if you'd like Flameshow to integrate with a tool 62 | you love, please feel free to reach out and submit an issue. 63 | 64 | - Golang pprof 65 | - [Brendan Gregg's Flamegraph](https://www.brendangregg.com/flamegraphs.html) 66 | - Python [Austin](https://github.com/P403n1x87/austin) 67 | 68 | ## Development 69 | 70 | If you want to dive into the code and make some changes, start with: 71 | 72 | ```shell 73 | git clone git@github.com:laixintao/flameshow.git 74 | cd flameshow 75 | pip install poetry 76 | poetry install 77 | ``` 78 | 79 | --- 80 | 81 | This project is proudly powered by 82 | [textual](https://github.com/Textualize/textual). 83 | -------------------------------------------------------------------------------- /docs/flameshow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/docs/flameshow.gif -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1701680307, 9 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "flake-utils_2": { 22 | "inputs": { 23 | "systems": "systems_2" 24 | }, 25 | "locked": { 26 | "lastModified": 1694529238, 27 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 28 | "owner": "numtide", 29 | "repo": "flake-utils", 30 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "numtide", 35 | "repo": "flake-utils", 36 | "type": "github" 37 | } 38 | }, 39 | "nix-github-actions": { 40 | "inputs": { 41 | "nixpkgs": [ 42 | "poetry2nix", 43 | "nixpkgs" 44 | ] 45 | }, 46 | "locked": { 47 | "lastModified": 1698974481, 48 | "narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=", 49 | "owner": "nix-community", 50 | "repo": "nix-github-actions", 51 | "rev": "4bb5e752616262457bc7ca5882192a564c0472d2", 52 | "type": "github" 53 | }, 54 | "original": { 55 | "owner": "nix-community", 56 | "repo": "nix-github-actions", 57 | "type": "github" 58 | } 59 | }, 60 | "nixpkgs": { 61 | "locked": { 62 | "lastModified": 1704626572, 63 | "narHash": "sha256-VwRTEKzK4wSSv64G+g3RLF3t6yBHrhR2VK3kZ5UWisU=", 64 | "owner": "NixOS", 65 | "repo": "nixpkgs", 66 | "rev": "24fe8bb4f552ad3926274d29e083b79d84707da6", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "id": "nixpkgs", 71 | "ref": "nixpkgs-unstable", 72 | "type": "indirect" 73 | } 74 | }, 75 | "poetry2nix": { 76 | "inputs": { 77 | "flake-utils": "flake-utils_2", 78 | "nix-github-actions": "nix-github-actions", 79 | "nixpkgs": [ 80 | "nixpkgs" 81 | ], 82 | "systems": "systems_3", 83 | "treefmt-nix": "treefmt-nix" 84 | }, 85 | "locked": { 86 | "lastModified": 1704540236, 87 | "narHash": "sha256-VKQ7JUjINd34sYhH7DKTtqnARvRySJ808cW9hoYA8NQ=", 88 | "owner": "nix-community", 89 | "repo": "poetry2nix", 90 | "rev": "74921da7e0cc8918adc2e9989bd3e9c127b25ff6", 91 | "type": "github" 92 | }, 93 | "original": { 94 | "owner": "nix-community", 95 | "repo": "poetry2nix", 96 | "type": "github" 97 | } 98 | }, 99 | "root": { 100 | "inputs": { 101 | "flake-utils": "flake-utils", 102 | "nixpkgs": "nixpkgs", 103 | "poetry2nix": "poetry2nix" 104 | } 105 | }, 106 | "systems": { 107 | "locked": { 108 | "lastModified": 1681028828, 109 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 110 | "owner": "nix-systems", 111 | "repo": "default", 112 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 113 | "type": "github" 114 | }, 115 | "original": { 116 | "owner": "nix-systems", 117 | "repo": "default", 118 | "type": "github" 119 | } 120 | }, 121 | "systems_2": { 122 | "locked": { 123 | "lastModified": 1681028828, 124 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 125 | "owner": "nix-systems", 126 | "repo": "default", 127 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 128 | "type": "github" 129 | }, 130 | "original": { 131 | "owner": "nix-systems", 132 | "repo": "default", 133 | "type": "github" 134 | } 135 | }, 136 | "systems_3": { 137 | "locked": { 138 | "lastModified": 1681028828, 139 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 140 | "owner": "nix-systems", 141 | "repo": "default", 142 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 143 | "type": "github" 144 | }, 145 | "original": { 146 | "id": "systems", 147 | "type": "indirect" 148 | } 149 | }, 150 | "treefmt-nix": { 151 | "inputs": { 152 | "nixpkgs": [ 153 | "poetry2nix", 154 | "nixpkgs" 155 | ] 156 | }, 157 | "locked": { 158 | "lastModified": 1699786194, 159 | "narHash": "sha256-3h3EH1FXQkIeAuzaWB+nK0XK54uSD46pp+dMD3gAcB4=", 160 | "owner": "numtide", 161 | "repo": "treefmt-nix", 162 | "rev": "e82f32aa7f06bbbd56d7b12186d555223dc399d1", 163 | "type": "github" 164 | }, 165 | "original": { 166 | "owner": "numtide", 167 | "repo": "treefmt-nix", 168 | "type": "github" 169 | } 170 | } 171 | }, 172 | "root": "root", 173 | "version": 7 174 | } 175 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A terminal Flamegraph viewer."; 3 | 4 | inputs = { 5 | nixpkgs.url = "nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | poetry2nix = { 8 | url = "github:nix-community/poetry2nix"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | }; 12 | 13 | outputs = { self, nixpkgs, flake-utils, poetry2nix }: 14 | flake-utils.lib.eachDefaultSystem (system: let 15 | pkgs = import nixpkgs { inherit system; }; 16 | inherit (poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }) mkPoetryApplication; 17 | in { 18 | packages = { 19 | flameshow = mkPoetryApplication { projectDir = self; }; 20 | default = self.packages.${system}.flameshow; 21 | }; 22 | 23 | devShells.default = pkgs.mkShell { 24 | packages = 25 | (with pkgs; [ nil python3 mypy ruff poetry ]) 26 | ++ 27 | (with pkgs.python311Packages; [ pip python-lsp-server pylsp-mypy python-lsp-ruff ]); 28 | }; 29 | } 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /flameshow/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.4" 2 | -------------------------------------------------------------------------------- /flameshow/colors.py: -------------------------------------------------------------------------------- 1 | import random 2 | import logging 3 | 4 | from textual.color import Color 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ColorPlatteBase: 10 | def __init__(self): 11 | self.assigned_color = {} 12 | 13 | def get_color(self, key): 14 | if key not in self.assigned_color: 15 | self.assigned_color[key] = self.assign_color(key) 16 | return self.assigned_color[key] 17 | 18 | def assign_color(self, key): 19 | raise NotImplementedError 20 | 21 | 22 | class LinaerColorPlatte(ColorPlatteBase): 23 | def __init__( 24 | self, 25 | start_color=Color.parse("#CD0000"), 26 | end_color=Color.parse("#FFE637"), 27 | ) -> None: 28 | super().__init__() 29 | self.assigned_color = {} 30 | self.start_color = start_color 31 | self.end_color = end_color 32 | self.index = 0 33 | self.platte = self.generate_platte() 34 | 35 | def assign_color(self, key): 36 | color = self.platte[self.index] 37 | self.index += 1 38 | if self.index == len(self.platte): 39 | self.index = 0 40 | 41 | logger.debug("assign color=%s", color) 42 | return color 43 | 44 | def generate_platte(self): 45 | color_platte = [] 46 | for factor in range(0, 100, 5): 47 | color_platte.append( 48 | self.start_color.blend(self.end_color, factor / 100) 49 | ) 50 | return color_platte 51 | 52 | 53 | class FlameGraphRandomColorPlatte(ColorPlatteBase): 54 | def __init__(self) -> None: 55 | super().__init__() 56 | self.assigned_color = {} 57 | 58 | def assign_color(self, *args): 59 | return Color( 60 | 205 + int(50 * random.random()), 61 | 0 + int(230 * random.random()), 62 | 0 + int(55 * random.random()), 63 | ) 64 | 65 | 66 | flamegraph_random_color_platte = FlameGraphRandomColorPlatte() 67 | linaer_color_platte = LinaerColorPlatte() 68 | -------------------------------------------------------------------------------- /flameshow/const.py: -------------------------------------------------------------------------------- 1 | VIEW_INFO_COLOR = "#ffffff" 2 | VIEW_INFO_OTHER_COLOR = "#8884FF" 3 | SELECTED_PARENTS_BG_COLOR_BLEND_TO = "#8b0000" 4 | SELECTED_PARENTS_BG_COLOR_BLEND_FACTOR = 0.5 5 | -------------------------------------------------------------------------------- /flameshow/exceptions.py: -------------------------------------------------------------------------------- 1 | class FlameshowException(Exception): 2 | """FlameShow base Exception""" 3 | 4 | 5 | class ProfileParseException(FlameshowException): 6 | """Can not parse the profile""" 7 | 8 | 9 | class UsageError(FlameshowException): 10 | """Usage Error""" 11 | 12 | 13 | class RenderException(FlameshowException): 14 | """Got error when render, this usually means code bug of Flameshow, you can open an issue""" # noqa: E501 15 | -------------------------------------------------------------------------------- /flameshow/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import time 4 | 5 | import sys 6 | 7 | import click 8 | 9 | from flameshow import __version__ 10 | from flameshow.parsers import parse 11 | from flameshow.render import FlameshowApp 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def setup_log(enabled, level, loglocation): 18 | if enabled: 19 | logging.basicConfig( 20 | filename=os.path.expanduser(loglocation), 21 | filemode="a", 22 | format=( 23 | "%(asctime)s %(levelname)5s (%(module)sL%(lineno)d)" 24 | " %(message)s" 25 | ), 26 | level=level, 27 | ) 28 | else: 29 | logging.disable(logging.CRITICAL) 30 | logger.info("------ flameshow ------") 31 | 32 | 33 | LOG_LEVEL = { 34 | 0: logging.CRITICAL, 35 | 1: logging.WARNING, 36 | 2: logging.INFO, 37 | 3: logging.DEBUG, 38 | } 39 | 40 | 41 | def ensure_tty(): 42 | if os.isatty(0): 43 | return 44 | 45 | logger.info("stdin is not a tty, replace it to fd=2") 46 | sys.stdin.close() 47 | sys.stdin = os.fdopen(2) 48 | 49 | 50 | def run_app(verbose, log_to, profile_f, _debug_exit_after_rednder): 51 | log_level = LOG_LEVEL[verbose] 52 | setup_log(log_to is not None, log_level, log_to) 53 | 54 | t0 = time.time() 55 | profile_data = profile_f.read() 56 | 57 | profile = parse(profile_data, profile_f.name) 58 | 59 | t01 = time.time() 60 | logger.info("Parse profile, took %.3fs", t01 - t0) 61 | 62 | ensure_tty() 63 | app = FlameshowApp( 64 | profile, 65 | _debug_exit_after_rednder, 66 | ) 67 | app.run() 68 | 69 | 70 | def print_version(ctx, _, value): 71 | if not value or ctx.resilient_parsing: 72 | return 73 | click.echo(__version__) 74 | ctx.exit() 75 | 76 | 77 | @click.command() 78 | @click.option( 79 | "-v", 80 | "--verbose", 81 | count=True, 82 | default=2, 83 | help="Add log verbose level, using -v, -vv, -vvv for printing more logs.", 84 | ) 85 | @click.option( 86 | "-l", 87 | "--log-to", 88 | type=click.Path(), 89 | default=None, 90 | help="Printing logs to a file, for debugging, default is no logs.", 91 | ) 92 | @click.option( 93 | "--version", 94 | is_flag=True, 95 | callback=print_version, 96 | expose_value=False, 97 | is_eager=True, 98 | ) 99 | @click.argument("profile", type=click.File("rb")) 100 | def main(verbose, log_to, profile): 101 | run_app(verbose, log_to, profile, _debug_exit_after_rednder=False) 102 | 103 | 104 | if __name__ == "__main__": 105 | main() 106 | -------------------------------------------------------------------------------- /flameshow/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | import datetime 3 | import logging 4 | import time 5 | from typing import Dict, List, Set 6 | from typing_extensions import Self 7 | 8 | from rich.style import Style 9 | from rich.text import Text 10 | 11 | from flameshow.utils import sizeof 12 | 13 | from .runtime import r 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class Frame: 20 | def __init__( 21 | self, name, _id, children=None, parent=None, values=None, root=None 22 | ) -> None: 23 | self.name = name 24 | self._id = _id 25 | if children: 26 | self.children = children 27 | else: 28 | self.children = [] 29 | self.parent = parent 30 | if not values: 31 | self.values = [] 32 | else: 33 | self.values = values 34 | 35 | self.root = root 36 | 37 | def pile_up(self, childstack: Self): 38 | childstack.parent = self 39 | 40 | for exist_child in self.children: 41 | # added to exist, no need to create one 42 | if exist_child.name == childstack.name: 43 | # some cases, childstack.children total value not equal to 44 | # childstack.values 45 | # so, we need to add values of "parent" instead of add values 46 | # by every child 47 | exist_child.values = list( 48 | map(sum, zip(exist_child.values, childstack.values)) 49 | ) 50 | 51 | for new_child in childstack.children: 52 | exist_child.pile_up(new_child) 53 | return 54 | 55 | self.children.append(childstack) 56 | 57 | def __eq__(self, other): 58 | if isinstance(other, Frame): 59 | return self._id == other._id 60 | return False 61 | 62 | @property 63 | def display_color(self): 64 | return r.get_color(self.color_key) 65 | 66 | def humanize(self, sample_unit, value): 67 | display_value = value 68 | if sample_unit == "bytes": 69 | display_value = sizeof(value) 70 | 71 | return display_value 72 | 73 | def __repr__(self) -> str: 74 | return f"" 75 | 76 | def render_detail(self, sample_index: int, sample_unit: str): 77 | """ 78 | render stacked information 79 | """ 80 | detail = [] 81 | frame = self 82 | while frame: 83 | lines = self.render_one_frame_detail( 84 | frame, sample_index, sample_unit 85 | ) 86 | for line in lines: 87 | detail.append( 88 | Text.assemble( 89 | (" ", Style(bgcolor=frame.display_color.rich_color)), 90 | " ", 91 | line, 92 | ) 93 | ) 94 | frame = frame.parent 95 | 96 | return Text.assemble(*detail) 97 | 98 | def render_one_frame_detail( 99 | self, frame, sample_index: int, sample_unit: str 100 | ): 101 | raise NotImplementedError 102 | 103 | @property 104 | def title(self) -> Text: 105 | """Full name which will be displayed in the frame detail panel""" 106 | return Text(self.name) 107 | 108 | @property 109 | def color_key(self): 110 | """Same key will get the same color""" 111 | return self.name 112 | 113 | @property 114 | def display_name(self): 115 | """The name display on the flamegraph""" 116 | return self.name 117 | 118 | 119 | @dataclass 120 | class SampleType: 121 | sample_type: str = "" 122 | sample_unit: str = "" 123 | 124 | 125 | @dataclass 126 | class Profile: 127 | # required 128 | filename: str 129 | root_stack: Frame 130 | highest_lines: int 131 | # total samples is one top most sample, it's a list that contains all 132 | # its parents all the way up 133 | total_sample: int 134 | sample_types: List[SampleType] 135 | # int id mapping to Frame 136 | id_store: Dict[int, Frame] 137 | 138 | # optional 139 | default_sample_type_index: int = -1 140 | period_type: SampleType | None = None 141 | period: int = 0 142 | created_at: datetime.datetime | None = None 143 | 144 | # init by post_init 145 | lines: List = field(init=False) 146 | 147 | frameid_to_lineno: Dict[int, int] = field(init=False) 148 | 149 | # Frame grouped by same name 150 | name_aggr: Dict[str, List[Frame]] = field(init=False) 151 | 152 | def __post_init__(self): 153 | """ 154 | init_lines must be called before render 155 | """ 156 | t1 = time.time() 157 | logger.info("start to create lines...") 158 | 159 | root = self.root_stack 160 | 161 | lines = [ 162 | [root], 163 | ] 164 | frameid_to_lineno = {0: 0} 165 | current = root.children 166 | line_no = 1 167 | 168 | while len(current) > 0: 169 | line = [] 170 | next_line = [] 171 | 172 | for child in current: 173 | line.append(child) 174 | frameid_to_lineno[child._id] = line_no 175 | next_line.extend(child.children) 176 | 177 | lines.append(line) 178 | line_no += 1 179 | current = next_line 180 | 181 | t2 = time.time() 182 | logger.info("create lines done, took %.2f seconds", t2 - t1) 183 | self.lines = lines 184 | self.frameid_to_lineno = frameid_to_lineno 185 | 186 | self.name_aggr = self.get_name_aggr(self.root_stack) 187 | 188 | def get_name_aggr( 189 | self, start_frame: Frame, names: Set[str] | None = None 190 | ) -> Dict[str, List[Frame]]: 191 | name = start_frame.name 192 | 193 | result = {} 194 | if names is None: 195 | names = set() 196 | if name not in names: 197 | result[name] = [start_frame] 198 | 199 | for child in start_frame.children: 200 | name_aggr = self.get_name_aggr(child, names | set([name])) 201 | for key, value in name_aggr.items(): 202 | result.setdefault(key, []).extend(value) 203 | 204 | return result 205 | -------------------------------------------------------------------------------- /flameshow/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flameshow.exceptions import ProfileParseException 3 | 4 | from flameshow.pprof_parser.parser import ProfileParser as PprofParser 5 | 6 | from .stackcollapse_parser import StackCollapseParser 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | ALL_PARSERS = [PprofParser, StackCollapseParser] 12 | 13 | 14 | def choose_parser(content: bytes): 15 | for p in ALL_PARSERS: 16 | if p.validate(content): 17 | return p 18 | raise ProfileParseException("Can not match any parser") 19 | 20 | 21 | def parse(filecontent: bytes, filename): 22 | parser_cls = choose_parser(filecontent) 23 | logger.info("Using %s...", parser_cls) 24 | parser = parser_cls(filename) 25 | profile = parser.parse(filecontent) 26 | return profile 27 | -------------------------------------------------------------------------------- /flameshow/parsers/stackcollapse_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from typing import Dict 5 | 6 | from rich.text import Text 7 | 8 | from flameshow.models import Frame, Profile, SampleType 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class StackCollapseFrame(Frame): 14 | def render_one_frame_detail( 15 | self, frame, sample_index: int, sample_unit: str 16 | ): 17 | return [Text(f"{frame.name}\n")] 18 | 19 | 20 | class StackCollapseParser: 21 | def __init__(self, filename) -> None: 22 | self.filename = filename 23 | self.next_id = 0 24 | self.root = StackCollapseFrame( 25 | "root", _id=self.idgenerator(), values=[0] 26 | ) 27 | self.root.root = self.root 28 | 29 | self.highest = 0 30 | self.id_store: Dict[int, Frame] = {self.root._id: self.root} 31 | self.line_regex = r"(.*?) (\d+)$" 32 | self.line_matcher = re.compile(self.line_regex) 33 | 34 | def idgenerator(self): 35 | i = self.next_id 36 | self.next_id += 1 37 | 38 | return i 39 | 40 | def parse(self, text_data): 41 | text_data = text_data.decode() 42 | lines = text_data.split(os.linesep) 43 | for line in lines: 44 | self.parse_line(line) 45 | 46 | logger.info("root: %s, %s", self.root, self.root.values) 47 | logger.debug("root.children: %s", self.root.children) 48 | 49 | profile = Profile( 50 | filename=self.filename, 51 | root_stack=self.root, 52 | highest_lines=self.highest, 53 | total_sample=len(lines), 54 | sample_types=[SampleType("samples", "count")], 55 | id_store=self.id_store, 56 | ) 57 | logger.info("profile.lines = %s", profile.lines) 58 | logger.info("profile.id_store = %s", profile.id_store) 59 | return profile 60 | 61 | def parse_line(self, line) -> None: 62 | line = line.strip() 63 | if not line: 64 | return 65 | if line.startswith("#"): # comments 66 | return 67 | matcher = self.line_matcher.match(line) 68 | if not matcher: 69 | logger.warning( 70 | "Can not parse {} with regex {}".format(line, self.line_regex) 71 | ) 72 | return 73 | frame_str = matcher.group(1) 74 | count = int(matcher.group(2)) 75 | frame_names = frame_str.split(";") 76 | logger.info("frame names:%s, count: %s", frame_names, count) 77 | 78 | pre = None 79 | head = None 80 | for name in frame_names: 81 | frame = StackCollapseFrame( 82 | name, 83 | self.idgenerator(), 84 | children=[], 85 | parent=pre, 86 | values=[count], 87 | root=self.root, 88 | ) 89 | self.id_store[frame._id] = frame 90 | if pre: 91 | pre.children = [frame] 92 | frame.parent = pre 93 | if not head: 94 | head = frame 95 | pre = frame 96 | 97 | if head: 98 | self.root.pile_up(head) 99 | self.root.values[0] += head.values[0] 100 | 101 | if len(frame_names) > self.highest: 102 | self.highest = len(frame_names) 103 | logger.debug("over") 104 | 105 | @classmethod 106 | def validate(cls, content: bytes) -> bool: 107 | try: 108 | to_check = content.decode("utf-8") 109 | except: # noqa E722 110 | return False 111 | 112 | # only validate the first 100 lines 113 | lines = to_check.split(os.linesep) 114 | to_validate_lines = [ 115 | line.strip() for line in lines[:100] if line.strip() 116 | ] 117 | 118 | if not to_validate_lines: 119 | logger.info("The file is empty, skip StackCollapseParser") 120 | return False 121 | 122 | for index, line in enumerate(to_validate_lines): 123 | if line.startswith("#"): # comments 124 | continue 125 | if not re.match(r"(.* )?\d+", line): 126 | logger.info( 127 | "line %d not match regex, line:%s not suitable for" 128 | " StackCollapseParser!", 129 | index + 1, 130 | line, 131 | ) 132 | return False 133 | 134 | return True 135 | -------------------------------------------------------------------------------- /flameshow/pprof_parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .parser import parse_profile 2 | 3 | __all__ = ["parse_profile"] 4 | -------------------------------------------------------------------------------- /flameshow/pprof_parser/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Parse golang's pprof format into flameshow.models which can be rendered. 3 | 4 | Ref: 5 | https://github.com/google/pprof/tree/main/proto 6 | """ 7 | 8 | from dataclasses import dataclass, field 9 | import datetime 10 | import gzip 11 | import logging 12 | from typing import Dict, List 13 | 14 | from rich.text import Text 15 | 16 | from flameshow.models import Frame, Profile, SampleType 17 | 18 | from . import profile_pb2 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | @dataclass 24 | class Function: 25 | id: int = 0 26 | filename: str = "" 27 | name: str = "" 28 | start_line: int = 0 29 | system_name: str = "" 30 | 31 | 32 | @dataclass 33 | class Line: 34 | line_no: int = 0 35 | function: Function = field(default_factory=Function) 36 | 37 | 38 | @dataclass 39 | class Mapping: 40 | id: int = 0 41 | memory_start: int = 0 42 | memory_limit: int = 0 43 | file_offset: int = 0 44 | 45 | filename: str = "" 46 | build_id: str = "" 47 | 48 | has_functions: bool | None = None 49 | has_filenames: bool | None = None 50 | has_line_numbers: bool | None = None 51 | has_inline_frames: bool | None = None 52 | 53 | 54 | @dataclass 55 | class Location: 56 | id: int = 0 57 | mapping: Mapping = field(default_factory=Mapping) 58 | address: int = 0 59 | lines: List[Line] = field(default_factory=list) 60 | is_folded: bool = False 61 | 62 | 63 | class PprofFrame(Frame): 64 | def __init__( 65 | self, 66 | name, 67 | _id, 68 | children=None, 69 | parent=None, 70 | values=None, 71 | root=None, 72 | line=None, 73 | mapping=None, 74 | ) -> None: 75 | super().__init__(name, _id, children, parent, values, root) 76 | 77 | parts = self.name.split("/") 78 | if len(parts) > 1: 79 | self.golang_package = "/".join(parts[:-1]) 80 | else: 81 | self.golang_package = "buildin" 82 | 83 | self.golang_module_function = parts[-1] 84 | 85 | self.golang_module = self.golang_module_function.split(".")[0] 86 | 87 | self.mapping_file = "" 88 | self.line = line 89 | self.mapping = mapping 90 | 91 | @property 92 | def color_key(self): 93 | return self.golang_module 94 | 95 | @property 96 | def display_name(self): 97 | return self.golang_module_function 98 | 99 | def render_one_frame_detail(self, frame, sample_index, sample_unit): 100 | if frame._id == 0: # root 101 | total = sum([c.values[sample_index] for c in frame.children]) 102 | value = frame.humanize(sample_unit, total) 103 | if frame.children: 104 | binary_name = f"Binary: {frame.children[0].mapping.filename}" 105 | else: 106 | binary_name = "root" 107 | detail = f"{binary_name} [b red]{value}[/b red]\n" 108 | return [Text.from_markup(detail)] 109 | 110 | value = frame.humanize(sample_unit, frame.values[sample_index]) 111 | line1 = f"{frame.line.function.name}: [b red]{value}[/b red]\n" 112 | 113 | line2 = ( 114 | f" [grey37]{frame.line.function.filename}, [b]line" 115 | f" {frame.line.line_no}[/b][/grey37]\n" 116 | ) 117 | return [Text.from_markup(line1), Text.from_markup(line2)] 118 | 119 | @property 120 | def title(self) -> str: 121 | return self.display_name 122 | 123 | 124 | def unmarshal(content) -> profile_pb2.Profile: 125 | if len(content) < 2: 126 | raise Exception( 127 | "Profile content length is too short: {} bytes".format( 128 | len(content) 129 | ) 130 | ) 131 | is_gzip = content[0] == 31 and content[1] == 139 132 | 133 | if is_gzip: 134 | content = gzip.decompress(content) 135 | 136 | profile = profile_pb2.Profile() 137 | profile.ParseFromString(content) 138 | 139 | return profile 140 | 141 | 142 | class ProfileParser: 143 | def __init__(self, filename): 144 | self.filename = filename 145 | # uniq id 146 | self.next_id = 0 147 | 148 | self.root = PprofFrame("root", _id=self.idgenerator()) 149 | # need to set PprofFrame.root for every frame 150 | self.root.root = self.root 151 | 152 | # store the pprof's string table 153 | self._t = [] 154 | # parse cached locations, profile do not need this, so only store 155 | # them on the parser 156 | self.locations = [] 157 | self.highest = 0 158 | 159 | self.id_store: Dict[int, Frame] = {self.root._id: self.root} 160 | 161 | def idgenerator(self): 162 | i = self.next_id 163 | self.next_id += 1 164 | 165 | return i 166 | 167 | def s(self, index): 168 | return self._t[index] 169 | 170 | def parse_internal_data(self, pbdata): 171 | self._t = pbdata.string_table 172 | self.functions = self.parse_functions(pbdata.function) 173 | self.mappings = self.parse_mapping(pbdata.mapping) 174 | self.locations = self.parse_location(pbdata.location) 175 | 176 | def parse(self, binary_data): 177 | pbdata = unmarshal(binary_data) 178 | self.parse_internal_data(pbdata) 179 | 180 | sample_types = self.parse_sample_types(pbdata.sample_type) 181 | 182 | root = self.root 183 | root.values = [0] * len(sample_types) 184 | for pbsample in pbdata.sample: 185 | child_frame = self.parse_sample(pbsample) 186 | if not child_frame: 187 | continue 188 | root.values = list(map(sum, zip(root.values, child_frame.values))) 189 | root.pile_up(child_frame) 190 | 191 | pprof_profile = Profile( 192 | filename=self.filename, 193 | root_stack=root, 194 | highest_lines=self.highest, 195 | total_sample=len(pbdata.sample), 196 | sample_types=sample_types, 197 | id_store=self.id_store, 198 | ) 199 | 200 | # default is 0, by the doc, default should be the last one 201 | if pbdata.default_sample_type: 202 | pprof_profile.default_sample_type_index = ( 203 | pbdata.default_sample_type 204 | ) 205 | 206 | pprof_profile.created_at = self.parse_created_at(pbdata.time_nanos) 207 | pprof_profile.period = pbdata.period 208 | pprof_profile.period_type = self.to_smaple_type(pbdata.period_type) 209 | 210 | return pprof_profile 211 | 212 | def parse_sample(self, sample) -> PprofFrame | None: 213 | values = sample.value 214 | locations = list( 215 | reversed([self.locations[i] for i in sample.location_id]) 216 | ) 217 | 218 | my_depth = sum(len(loc.lines) for loc in locations) 219 | self.highest = max(my_depth, self.highest) 220 | 221 | current_parent = None 222 | head = None 223 | for location in locations: 224 | for line in location.lines: 225 | frame = self.line2frame(location, line, values) 226 | 227 | if current_parent: 228 | frame.parent = current_parent 229 | current_parent.children = [frame] 230 | if not head: 231 | head = frame 232 | 233 | current_parent = frame 234 | 235 | return head 236 | 237 | def line2frame(self, location: Location, line: Line, values) -> PprofFrame: 238 | frame = PprofFrame( 239 | name=line.function.name, 240 | _id=self.idgenerator(), 241 | values=values, 242 | root=self.root, 243 | mapping=location.mapping, 244 | ) 245 | frame.line = line 246 | self.id_store[frame._id] = frame 247 | return frame 248 | 249 | def parse_location(self, pblocations): 250 | parsed_locations = {} 251 | for pl in pblocations: 252 | loc = Location() 253 | loc.id = pl.id 254 | loc.mapping = self.mappings[pl.mapping_id] 255 | loc.address = pl.address 256 | loc.lines = self.parse_line(pl.line) 257 | loc.is_folded = pl.is_folded 258 | parsed_locations[loc.id] = loc 259 | 260 | return parsed_locations 261 | 262 | def parse_mapping(self, pbmappings): 263 | mappings = {} 264 | for pbm in pbmappings: 265 | m = Mapping() 266 | m.id = pbm.id 267 | m.memory_start = pbm.memory_start 268 | m.memory_limit = pbm.memory_limit 269 | m.file_offset = pbm.file_offset 270 | m.filename = self.s(pbm.filename) 271 | m.build_id = self.s(pbm.build_id) 272 | m.has_functions = pbm.has_functions 273 | m.has_filenames = pbm.has_filenames 274 | m.has_line_numbers = pbm.has_line_numbers 275 | m.has_inline_frames = pbm.has_inline_frames 276 | 277 | mappings[m.id] = m 278 | 279 | return mappings 280 | 281 | def parse_line(self, pblines) -> List[Line]: 282 | lines = [] 283 | for pl in reversed(pblines): 284 | line = Line( 285 | line_no=pl.line, 286 | function=self.functions[pl.function_id], 287 | ) 288 | lines.append(line) 289 | return lines 290 | 291 | def parse_functions(self, pfs): 292 | functions = {} 293 | for pf in pfs: 294 | functions[pf.id] = Function( 295 | id=pf.id, 296 | filename=self.s(pf.filename), 297 | name=self.s(pf.name), 298 | system_name=self.s(pf.system_name), 299 | start_line=pf.start_line, 300 | ) 301 | return functions 302 | 303 | def parse_created_at(self, time_nanos): 304 | date = datetime.datetime.fromtimestamp( 305 | time_nanos / 1e9, tz=datetime.timezone.utc 306 | ) 307 | return date 308 | 309 | def parse_sample_types(self, sample_types): 310 | result = [] 311 | for st in sample_types: 312 | result.append(self.to_smaple_type(st)) 313 | 314 | return result 315 | 316 | def to_smaple_type(self, st): 317 | return SampleType(self.s(st.type), self.s(st.unit)) 318 | 319 | @classmethod 320 | def validate(cls, content: bytes) -> bool: 321 | try: 322 | unmarshal(content) 323 | except: # noqa E722 324 | logger.info("Error when parse content as Pprof") 325 | return False 326 | return True 327 | 328 | 329 | def get_frame_tree(root_frame): 330 | """ 331 | only for testing and debugging 332 | """ 333 | 334 | def _get_child(frame): 335 | return {c.name: _get_child(c) for c in frame.children} 336 | 337 | return {"root": _get_child(root_frame)} 338 | 339 | 340 | def parse_profile(binary_data, filename): 341 | parser = ProfileParser(filename) 342 | profile = parser.parse(binary_data) 343 | 344 | # import ipdb; ipdb.set_trace() 345 | return profile 346 | 347 | 348 | if __name__ == "__main__": 349 | with open("tests/pprof_data/goroutine.out", "rb") as f: 350 | content = f.read() 351 | 352 | parse_profile(content, "abc") 353 | -------------------------------------------------------------------------------- /flameshow/pprof_parser/profile_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: profile.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf.internal import builder as _builder 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rprofile.proto\x12\x12perftools.profiles\"\xd5\x03\n\x07Profile\x12\x32\n\x0bsample_type\x18\x01 \x03(\x0b\x32\x1d.perftools.profiles.ValueType\x12*\n\x06sample\x18\x02 \x03(\x0b\x32\x1a.perftools.profiles.Sample\x12,\n\x07mapping\x18\x03 \x03(\x0b\x32\x1b.perftools.profiles.Mapping\x12.\n\x08location\x18\x04 \x03(\x0b\x32\x1c.perftools.profiles.Location\x12.\n\x08\x66unction\x18\x05 \x03(\x0b\x32\x1c.perftools.profiles.Function\x12\x14\n\x0cstring_table\x18\x06 \x03(\t\x12\x13\n\x0b\x64rop_frames\x18\x07 \x01(\x03\x12\x13\n\x0bkeep_frames\x18\x08 \x01(\x03\x12\x12\n\ntime_nanos\x18\t \x01(\x03\x12\x16\n\x0e\x64uration_nanos\x18\n \x01(\x03\x12\x32\n\x0bperiod_type\x18\x0b \x01(\x0b\x32\x1d.perftools.profiles.ValueType\x12\x0e\n\x06period\x18\x0c \x01(\x03\x12\x0f\n\x07\x63omment\x18\r \x03(\x03\x12\x1b\n\x13\x64\x65\x66\x61ult_sample_type\x18\x0e \x01(\x03\"\'\n\tValueType\x12\x0c\n\x04type\x18\x01 \x01(\x03\x12\x0c\n\x04unit\x18\x02 \x01(\x03\"V\n\x06Sample\x12\x13\n\x0blocation_id\x18\x01 \x03(\x04\x12\r\n\x05value\x18\x02 \x03(\x03\x12(\n\x05label\x18\x03 \x03(\x0b\x32\x19.perftools.profiles.Label\"@\n\x05Label\x12\x0b\n\x03key\x18\x01 \x01(\x03\x12\x0b\n\x03str\x18\x02 \x01(\x03\x12\x0b\n\x03num\x18\x03 \x01(\x03\x12\x10\n\x08num_unit\x18\x04 \x01(\x03\"\xdd\x01\n\x07Mapping\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x14\n\x0cmemory_start\x18\x02 \x01(\x04\x12\x14\n\x0cmemory_limit\x18\x03 \x01(\x04\x12\x13\n\x0b\x66ile_offset\x18\x04 \x01(\x04\x12\x10\n\x08\x66ilename\x18\x05 \x01(\x03\x12\x10\n\x08\x62uild_id\x18\x06 \x01(\x03\x12\x15\n\rhas_functions\x18\x07 \x01(\x08\x12\x15\n\rhas_filenames\x18\x08 \x01(\x08\x12\x18\n\x10has_line_numbers\x18\t \x01(\x08\x12\x19\n\x11has_inline_frames\x18\n \x01(\x08\"v\n\x08Location\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x12\n\nmapping_id\x18\x02 \x01(\x04\x12\x0f\n\x07\x61\x64\x64ress\x18\x03 \x01(\x04\x12&\n\x04line\x18\x04 \x03(\x0b\x32\x18.perftools.profiles.Line\x12\x11\n\tis_folded\x18\x05 \x01(\x08\")\n\x04Line\x12\x13\n\x0b\x66unction_id\x18\x01 \x01(\x04\x12\x0c\n\x04line\x18\x02 \x01(\x03\"_\n\x08\x46unction\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\x03\x12\x13\n\x0bsystem_name\x18\x03 \x01(\x03\x12\x10\n\x08\x66ilename\x18\x04 \x01(\x03\x12\x12\n\nstart_line\x18\x05 \x01(\x03\x42-\n\x1d\x63om.google.perftools.profilesB\x0cProfileProtob\x06proto3') 17 | 18 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) 19 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'profile_pb2', globals()) 20 | if _descriptor._USE_C_DESCRIPTORS == False: 21 | 22 | DESCRIPTOR._options = None 23 | DESCRIPTOR._serialized_options = b'\n\035com.google.perftools.profilesB\014ProfileProto' 24 | _PROFILE._serialized_start=38 25 | _PROFILE._serialized_end=507 26 | _VALUETYPE._serialized_start=509 27 | _VALUETYPE._serialized_end=548 28 | _SAMPLE._serialized_start=550 29 | _SAMPLE._serialized_end=636 30 | _LABEL._serialized_start=638 31 | _LABEL._serialized_end=702 32 | _MAPPING._serialized_start=705 33 | _MAPPING._serialized_end=926 34 | _LOCATION._serialized_start=928 35 | _LOCATION._serialized_end=1046 36 | _LINE._serialized_start=1048 37 | _LINE._serialized_end=1089 38 | _FUNCTION._serialized_start=1091 39 | _FUNCTION._serialized_end=1186 40 | # @@protoc_insertion_point(module_scope) 41 | -------------------------------------------------------------------------------- /flameshow/render/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import FlameshowApp 2 | 3 | __all__ = ["FlameshowApp"] 4 | -------------------------------------------------------------------------------- /flameshow/render/app.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import logging 3 | from typing import ClassVar 4 | 5 | from textual import on 6 | from textual.app import App, ComposeResult 7 | from textual.binding import Binding, BindingType 8 | from textual.containers import VerticalScroll 9 | from textual.css.query import NoMatches 10 | from textual.reactive import reactive 11 | from textual.widgets import Footer, Static, Tabs, Tab 12 | 13 | from flameshow import __version__ 14 | from flameshow.render.framedetail import FrameDetail, InformaionScreen 15 | from flameshow.render.header import FlameshowHeader 16 | from flameshow.render.tabs import SampleTabs 17 | 18 | from .flamegraph import FlameGraph 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class FlameGraphScroll( 24 | VerticalScroll, inherit_bindings=False, can_focus=False 25 | ): 26 | BINDINGS: ClassVar[list[BindingType]] = [ 27 | Binding("b", "page_up", "Scroll Page Up", show=True, key_display="B"), 28 | Binding( 29 | "f,space", 30 | "page_down", 31 | "Scroll Page Down", 32 | show=True, 33 | key_display="F", 34 | ), 35 | Binding("home", "scroll_home", "Scroll Home", show=False), 36 | Binding("end", "scroll_end", "Scroll End", show=False), 37 | Binding("pageup", "page_up", "Page Up", show=False), 38 | Binding("pagedown", "page_down", "Page Down", show=False), 39 | ] 40 | 41 | def scroll_to_make_line_center(self, line_no): 42 | height = self.size.height 43 | start_line = max(0, line_no - round(height / 2)) 44 | self.scroll_to(y=start_line) 45 | return start_line 46 | 47 | 48 | class FlameshowApp(App): 49 | BINDINGS = [ 50 | Binding("d", "toggle_dark", "Toggle dark mode", show=False), 51 | Binding( 52 | "tab,n", 53 | "switch_sample_type", 54 | "Switch Sample Type", 55 | priority=True, 56 | show=True, 57 | key_display="tab", 58 | ), 59 | Binding("ctrl+c,q", "quit", "Quit", show=True, key_display="Q"), 60 | Binding("o", "debug"), 61 | Binding("i", "information_screen", "Toggle view stack", show=True), 62 | ] 63 | 64 | DEFAULT_CSS = """ 65 | #sample-type-radio { 66 | width: 20%; 67 | height: 1fr; 68 | } 69 | 70 | #profile-detail-info { 71 | text-align: right; 72 | color: grey; 73 | } 74 | 75 | Tabs { 76 | margin-bottom: 0; 77 | } 78 | """ 79 | 80 | focused_stack_id = reactive(0) 81 | sample_index = reactive(None, init=False) 82 | view_frame = reactive(None, init=False) 83 | 84 | def __init__( 85 | self, 86 | profile, 87 | _debug_exit_after_rednder=False, 88 | *args, 89 | **kwargs, 90 | ): 91 | super().__init__(*args, **kwargs) 92 | self.profile = profile 93 | self.root_stack = profile.root_stack 94 | 95 | self._debug_exit_after_rednder = _debug_exit_after_rednder 96 | 97 | self.parents_that_only_one_child = [] 98 | 99 | self.filename = self.profile.filename 100 | 101 | if profile.default_sample_type_index < 0: 102 | self.sample_index = ( 103 | len(profile.sample_types) + profile.default_sample_type_index 104 | ) 105 | else: 106 | self.sample_index = profile.default_sample_type_index 107 | 108 | fg = FlameGraph( 109 | self.profile, 110 | self.focused_stack_id, 111 | self.sample_index, 112 | self.root_stack, 113 | ) 114 | fg.styles.height = self.profile.highest_lines + 1 115 | self.flamegraph_widget = fg 116 | 117 | tabs = [ 118 | Tab(f"{s.sample_type}, {s.sample_unit}", id=f"sample-{index}") 119 | for index, s in enumerate(profile.sample_types) 120 | ] 121 | active_tab = tabs[self.sample_index].id 122 | self.tabs_widget = SampleTabs(*tabs, active=active_tab) 123 | 124 | self.frame_detail = FrameDetail( 125 | profile=profile, 126 | frame=self.root_stack, 127 | sample_index=self.sample_index, 128 | ) 129 | self.show_information_screen = False 130 | 131 | def on_mount(self): 132 | logger.info("mounted") 133 | self.title = "flameshow" 134 | self.sub_title = f"v{__version__}" 135 | 136 | self.view_frame = self.root_stack 137 | 138 | def compose(self) -> ComposeResult: 139 | """Create child widgets for the app.""" 140 | 141 | center_text = self._center_header_text(self.sample_index) 142 | yield FlameshowHeader(center_text) 143 | 144 | yield self.tabs_widget 145 | 146 | yield FlameGraphScroll( 147 | self.flamegraph_widget, 148 | id="flamegraph-out-container", 149 | ) 150 | 151 | yield self.frame_detail 152 | 153 | yield self._profile_info(self.profile.created_at) 154 | yield Footer() 155 | 156 | def _center_header_text(self, sample_index): 157 | chosen_sample_type = self.profile.sample_types[sample_index] 158 | center_header = ( 159 | f"{self.filename}: ({chosen_sample_type.sample_type}," 160 | f" {chosen_sample_type.sample_unit})" 161 | ) 162 | return center_header 163 | 164 | def _profile_info(self, created_at: datetime): 165 | if not created_at: 166 | return Static("") 167 | 168 | datetime_str = created_at.astimezone().strftime( 169 | "Dumped at %Y %b %d(%A) %H:%M:%S %Z" 170 | ) 171 | return Static(datetime_str, id="profile-detail-info") 172 | 173 | @on(FlameGraph.ViewFrameChanged) 174 | async def handle_view_frame_changed(self, e): 175 | logger.debug("app handle_view_frame_changed...") 176 | new_frame = e.frame 177 | by_mouse = e.by_mouse 178 | self.view_frame = new_frame 179 | 180 | self.flamegraph_widget.view_frame = new_frame 181 | 182 | if not by_mouse: 183 | frame_line_no = self.profile.frameid_to_lineno[new_frame._id] 184 | container = self.query_one("#flamegraph-out-container") 185 | container.scroll_to_make_line_center(line_no=frame_line_no) 186 | 187 | async def watch_sample_index(self, sample_index): 188 | logger.info("sample index changed to %d", sample_index) 189 | 190 | center_text = self._center_header_text(self.sample_index) 191 | try: 192 | header = self.query_one("FlameshowHeader") 193 | except NoMatches: 194 | logger.warning( 195 | "FlameshowHeader not found, might be not composed yet." 196 | ) 197 | return 198 | header.center_text = center_text 199 | 200 | self.flamegraph_widget.sample_index = sample_index 201 | self.frame_detail.sample_index = sample_index 202 | 203 | if self.show_information_screen: 204 | information_screen = self.query_one("InformaionScreen") 205 | information_screen.sample_index = sample_index 206 | 207 | async def watch_view_frame(self, old, new_frame): 208 | logger.debug( 209 | "view info stack changed: old: %s, new: %s", 210 | old, 211 | new_frame, 212 | ) 213 | self.frame_detail.frame = new_frame 214 | 215 | async def watch_focused_stack_id( 216 | self, 217 | focused_stack_id, 218 | ): 219 | logger.info(f"{focused_stack_id=} changed") 220 | self.flamegraph_widget.focused_stack_id = focused_stack_id 221 | 222 | def action_switch_sample_type(self): 223 | self.tabs_widget.action_next_tab() 224 | 225 | @property 226 | def sample_unit(self): 227 | return self.profile.sample_types[self.sample_index].sample_unit 228 | 229 | def action_debug(self): 230 | logger.info("currently focused on: %s", self.focused) 231 | 232 | @on(Tabs.TabActivated) 233 | def handle_sample_type_changed(self, event: Tabs.TabActivated): 234 | logger.info("Tab changed: %s", event) 235 | chosen_index = event.tab.id.split("-")[1] 236 | self.sample_index = int(chosen_index) 237 | 238 | def action_information_screen(self): 239 | if self.show_information_screen: 240 | self.pop_screen() 241 | else: 242 | self.push_screen( 243 | InformaionScreen( 244 | self.view_frame, 245 | self.sample_index, 246 | self.sample_unit, 247 | self.profile, 248 | ) 249 | ) 250 | self.show_information_screen = not self.show_information_screen 251 | 252 | @on(InformaionScreen.InformaionScreenPopped) 253 | def handle_inforamtion_screen_pop(self, event): 254 | logger.info("Information screen popped, event=%s", event) 255 | if self.show_information_screen: 256 | self.pop_screen() 257 | self.show_information_screen = False 258 | -------------------------------------------------------------------------------- /flameshow/render/flamegraph.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from functools import lru_cache 3 | import logging 4 | import time 5 | from typing import Dict, List, Union 6 | 7 | import iteround 8 | from rich.segment import Segment 9 | from rich.style import Style 10 | from textual import on 11 | from textual.binding import Binding 12 | from textual.color import Color 13 | from textual.events import Click, MouseEvent, MouseMove 14 | from textual.message import Message 15 | from textual.reactive import reactive 16 | from textual.strip import Strip 17 | from textual.widget import Widget 18 | 19 | from flameshow.const import ( 20 | SELECTED_PARENTS_BG_COLOR_BLEND_FACTOR, 21 | SELECTED_PARENTS_BG_COLOR_BLEND_TO, 22 | VIEW_INFO_COLOR, 23 | VIEW_INFO_OTHER_COLOR, 24 | ) 25 | from flameshow.exceptions import RenderException 26 | from flameshow.models import Frame 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | FrameMap = namedtuple("FrameMap", "offset width") 32 | 33 | 34 | def add_array(arr1, arr2): 35 | return list(map(sum, zip(arr1, arr2))) 36 | 37 | 38 | class FlameGraph(Widget, can_focus=True): 39 | BINDINGS = [ 40 | Binding("j,down", "move_down", "Down", key_display="↓"), 41 | Binding("k,up", "move_up", "Up", key_display="↑"), 42 | Binding("l,right", "move_right", "Right", key_display="→"), 43 | Binding("h,left", "move_left", "Left", key_display="←"), 44 | Binding("enter", "zoom_in", "Zoom In"), 45 | Binding("escape", "zoom_out", "Zoom Out", show=False), 46 | ] 47 | 48 | focused_stack_id = reactive(0) 49 | sample_index = reactive(0, init=False) 50 | view_frame = reactive(None, init=False) 51 | 52 | class ViewFrameChanged(Message): 53 | """View Frame changed""" 54 | 55 | def __init__(self, frame, by_mouse=False) -> None: 56 | super().__init__() 57 | self.frame = frame 58 | self.by_mouse = by_mouse 59 | 60 | def __repr__(self) -> str: 61 | return f"ViewFrameChanged({self.frame=})" 62 | 63 | def __init__( 64 | self, 65 | profile, 66 | focused_stack_id, 67 | sample_index, 68 | view_frame: Frame, 69 | *args, 70 | **kwargs, 71 | ): 72 | super().__init__(*args, **kwargs) 73 | self.profile = profile 74 | # +1 is extra "root" node 75 | self.focused_stack_id = focused_stack_id 76 | self.sample_index = sample_index 77 | self.view_frame = view_frame 78 | 79 | # pre-render 80 | self.frame_maps = None 81 | 82 | def render_lines(self, crop): 83 | my_width = crop.size.width 84 | self.frame_maps = self.generate_frame_maps( 85 | my_width, self.focused_stack_id 86 | ) 87 | 88 | logger.info("render crop: %s", crop) 89 | 90 | return super().render_lines(crop) 91 | 92 | @lru_cache 93 | def generate_frame_maps(self, width, focused_stack_id): 94 | """ 95 | compute attributes for render for every frame 96 | 97 | only re-computes with width, focused_stack changing 98 | """ 99 | t1 = time.time() 100 | logger.info( 101 | "lru cache miss, Generates frame map, for width=%d," 102 | " focused_stack_id=%s", 103 | width, 104 | focused_stack_id, 105 | ) 106 | frame_maps: Dict[int, List[FrameMap]] = {} 107 | current_focused_stack = self.profile.id_store[focused_stack_id] 108 | st_count = len(current_focused_stack.values) 109 | logger.debug("values count: %s", st_count) 110 | 111 | # set me to 100% and siblins to 0 112 | me = current_focused_stack 113 | while me: 114 | frame_maps[me._id] = [FrameMap(0, width) for _ in range(st_count)] 115 | me = me.parent 116 | 117 | logger.info("frame maps: %s", frame_maps) 118 | 119 | def _generate_for_children(frame): 120 | # generate for children 121 | my_maps = frame_maps[frame._id] 122 | for sample_i, my_map in enumerate(my_maps): 123 | parent_width = my_map.width 124 | if frame.values[sample_i] <= 0: 125 | child_widthes = [0.0 for _ in frame.children] 126 | else: 127 | child_widthes = [ 128 | child.values[sample_i] 129 | / frame.values[sample_i] 130 | * parent_width 131 | for child in frame.children 132 | ] 133 | 134 | # the tail_spaces here only for iteround, in the case that 135 | # child total is not 100% of parent, so tail need to be here 136 | # to take some spaces 137 | tail_spaces = float(parent_width - sum(child_widthes)) 138 | if tail_spaces > 0: 139 | child_widthes.append(tail_spaces) 140 | rounded_child_widthes = iteround.saferound( 141 | child_widthes, 0, topline=parent_width 142 | ) 143 | 144 | offset = my_map.offset 145 | for index, child in enumerate(frame.children): 146 | child_width = int(rounded_child_widthes[index]) 147 | frame_maps.setdefault(child._id, []).append( 148 | FrameMap( 149 | offset=offset, 150 | width=child_width, 151 | ) 152 | ) 153 | offset += child_width 154 | 155 | for child in frame.children: 156 | _generate_for_children(child) 157 | 158 | _generate_for_children(current_focused_stack) 159 | t2 = time.time() 160 | logger.info("Generates frame maps done, took %.4f seconds", t2 - t1) 161 | return frame_maps 162 | 163 | def render_line(self, y: int) -> Strip: 164 | # logger.info("container_size: %s", self.container_size) 165 | line = self.profile.lines[y] 166 | 167 | if not self.frame_maps: 168 | # never should happen 169 | # pragma: no cover 170 | raise RenderException("frame_maps is not init yet!") 171 | 172 | segments = [] 173 | cursor = 0 174 | for frame in line: 175 | frame_maps = self.frame_maps.get(frame._id) 176 | if not frame_maps: 177 | # never should happen 178 | continue # pragma: no cover 179 | frame_map = frame_maps[self.sample_index] 180 | my_width = frame_map.width 181 | if not my_width: 182 | continue 183 | 184 | text = "▏" + frame.display_name 185 | offset = frame_map.offset 186 | pre_pad = offset - cursor 187 | 188 | if pre_pad > 0: 189 | segments.append(Segment(" " * pre_pad)) 190 | cursor += pre_pad 191 | elif pre_pad < 0: 192 | # never should happen 193 | raise Exception( 194 | "Prepad is negative! {}".format(pre_pad) 195 | ) # pragma: no cover 196 | 197 | if len(text) < my_width: 198 | text += " " * (my_width - len(text)) 199 | if len(text) > my_width: 200 | text = text[:my_width] 201 | 202 | display_color = frame.display_color 203 | bold = False 204 | 205 | expand_before_line = self.profile.frameid_to_lineno[ 206 | self.focused_stack_id 207 | ] 208 | if y <= expand_before_line: 209 | display_color = display_color.blend( 210 | Color.parse(SELECTED_PARENTS_BG_COLOR_BLEND_TO), 211 | SELECTED_PARENTS_BG_COLOR_BLEND_FACTOR, 212 | ) 213 | 214 | if frame is self.view_frame: 215 | display_color = Color.parse(VIEW_INFO_COLOR) 216 | bold = True 217 | elif frame.name == self.view_frame.name: 218 | display_color = Color.parse(VIEW_INFO_OTHER_COLOR) 219 | 220 | if my_width > 0: 221 | # | is always default color 222 | segments.append( 223 | Segment( 224 | text[0], 225 | Style( 226 | bgcolor=display_color.rich_color, 227 | ), 228 | ) 229 | ) 230 | if my_width > 1: 231 | segments.append( 232 | Segment( 233 | text[1:], 234 | Style( 235 | color=display_color.get_contrast_text().rich_color, 236 | bgcolor=display_color.rich_color, 237 | bold=bold, 238 | ), 239 | ) 240 | ) 241 | cursor += my_width 242 | 243 | strip = Strip(segments) 244 | return strip 245 | 246 | def action_zoom_in(self): 247 | logger.info("Zoom in!") 248 | self.focused_stack_id = self.view_frame._id 249 | 250 | def action_zoom_out(self): 251 | logger.info("Zoom out!") 252 | self.focused_stack_id = self.profile.root_stack._id 253 | 254 | def action_move_down(self): 255 | logger.debug("move down") 256 | view_frame = self.view_frame 257 | children = view_frame.children 258 | 259 | if not children: 260 | logger.debug("no more children") 261 | return 262 | 263 | # go to the biggest value 264 | new_view_info_frame = self._get_biggest_exist_child(children) 265 | self.post_message(self.ViewFrameChanged(new_view_info_frame)) 266 | 267 | def _get_biggest_exist_child(self, stacks): 268 | biggest = max(stacks, key=lambda s: s.values[self.sample_index]) 269 | return biggest 270 | 271 | def action_move_up(self): 272 | logger.debug("move up") 273 | parent = self.view_frame.parent 274 | 275 | if not parent: 276 | logger.debug("no more children") 277 | return 278 | 279 | self.post_message(self.ViewFrameChanged(parent)) 280 | 281 | def action_move_right(self): 282 | logger.debug("move right") 283 | 284 | right = self._find_right_sibling(self.view_frame) 285 | 286 | logger.debug("found right sibling: %s", right) 287 | if not right: 288 | logger.debug("Got no right sibling") 289 | return 290 | 291 | self.post_message(self.ViewFrameChanged(right)) 292 | 293 | def _find_right_sibling(self, me): 294 | my_parent = me.parent 295 | while my_parent: 296 | siblings = my_parent.children 297 | if len(siblings) >= 2: 298 | choose_index = siblings.index(me) 299 | while choose_index < len(siblings): 300 | choose_index = choose_index + 1 301 | if ( 302 | choose_index < len(siblings) 303 | and siblings[choose_index].values[self.sample_index] 304 | > 0 305 | ): 306 | return siblings[choose_index] 307 | 308 | me = my_parent 309 | my_parent = my_parent.parent 310 | 311 | def action_move_left(self): 312 | logger.debug("move left") 313 | 314 | left = self._find_left_sibling(self.view_frame) 315 | 316 | if not left: 317 | logger.debug("Got no left sibling") 318 | return 319 | 320 | self.post_message(self.ViewFrameChanged(left)) 321 | 322 | def _find_left_sibling(self, me): 323 | """ 324 | Find left. 325 | Even not currently displayed, still can be viewed on detail. 326 | No need to check if the fgid is currently rendered. 327 | """ 328 | my_parent = me.parent 329 | while my_parent: 330 | siblings = my_parent.children 331 | if len(siblings) >= 2: 332 | choose_index = siblings.index(me) 333 | # move left, until: 334 | # got a sibling while value is not 0 (0 won't render) 335 | # and index >= 0 336 | while choose_index >= 0: 337 | choose_index = choose_index - 1 338 | if ( 339 | choose_index >= 0 340 | and siblings[choose_index].values[self.sample_index] 341 | > 0 342 | ): 343 | return siblings[choose_index] 344 | 345 | me = my_parent 346 | my_parent = my_parent.parent 347 | 348 | def on_mouse_move(self, event: MouseMove) -> None: 349 | hover_frame = self.get_frame_under_mouse(event) 350 | if hover_frame: 351 | logger.info("mouse hover on %s", hover_frame) 352 | self.post_message( 353 | self.ViewFrameChanged(hover_frame, by_mouse=True) 354 | ) 355 | 356 | @on(Click) 357 | def handle_click_frame(self, event: Click): 358 | frame = self.get_frame_under_mouse(event) 359 | if frame: 360 | self.focused_stack_id = frame._id 361 | 362 | def get_frame_under_mouse(self, event: MouseEvent) -> Union[None, Frame]: 363 | line_no = event.y 364 | x = event.x 365 | 366 | if line_no >= len(self.profile.lines): 367 | return 368 | 369 | line = self.profile.lines[line_no] 370 | 371 | for frame in line: 372 | frame_maps = self.frame_maps.get(frame._id) 373 | if not frame_maps: 374 | # this frame not exist in current render 375 | continue 376 | frame_map = frame_maps[self.sample_index] 377 | offset = frame_map.offset 378 | width = frame_map.width 379 | 380 | if offset <= x < offset + width: # find it! 381 | return frame 382 | -------------------------------------------------------------------------------- /flameshow/render/framedetail.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from textual.binding import Binding 4 | from textual.containers import Vertical, VerticalScroll 5 | from textual.css.query import NoMatches 6 | from textual.message import Message 7 | from textual.reactive import reactive 8 | from textual.screen import Screen 9 | from textual.widget import Widget 10 | from textual.widgets import Footer, Static 11 | 12 | from flameshow.models import Frame 13 | from flameshow.render.header import FlameshowHeader 14 | from flameshow.utils import sizeof 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def humanize(sample_unit, value): 20 | display_value = value 21 | if sample_unit == "bytes": 22 | display_value = sizeof(value) 23 | 24 | return str(display_value) 25 | 26 | 27 | class FrameStatThis(Widget): 28 | frame = reactive(None, init=False) 29 | sample_index = reactive(None, init=False) 30 | 31 | # width = 998.1MiB|998.1MiB 32 | DEFAULT_CSS = """ 33 | FrameStatThis { 34 | height: 5; 35 | padding: 0 1; 36 | border: round $secondary; 37 | border-title-align: center; 38 | 39 | layout: grid; 40 | grid-size: 2 3; 41 | grid-rows: 1fr; 42 | grid-columns: 1fr; 43 | grid-gutter: 0 1; 44 | } 45 | 46 | #stat-this-total-label { 47 | background: $primary-background; 48 | } 49 | #stat-this-self-label { 50 | background: $secondary-background; 51 | } 52 | """ 53 | 54 | def __init__(self, frame, profile, sample_index, *args, **kwargs): 55 | self.composed = False 56 | super().__init__(*args, **kwargs) 57 | 58 | self.profile = profile 59 | self.border_title = "This Instance" 60 | self.frame = frame 61 | self.sample_index = sample_index 62 | 63 | def compose(self): 64 | yield Static("Total", id="stat-this-total-label") 65 | yield Static("Self", id="stat-this-self-label") 66 | yield Static( 67 | self.frame_this_total_value_humanize, id="stat-this-total-value" 68 | ) 69 | yield Static(self.frame_self_value_humanize, id="stat-this-self-value") 70 | yield Static(self.frame_total_percent, id="stat-this-total-percent") 71 | yield Static(self.frame_self_percent, id="stat-this-self-percent") 72 | self.composed = True 73 | 74 | def watch_frame(self, _: Frame): 75 | self._rerender() 76 | 77 | def watch_sample_index(self, _: int): 78 | self._rerender() 79 | 80 | def _rerender(self): 81 | if not self.composed: 82 | return 83 | logger.info(f"rerender --> {self.frame=} {self.sample_index=}") 84 | 85 | total_value_widget = self.query_one("#stat-this-total-value") 86 | total_value_widget.update(self.frame_this_total_value_humanize) 87 | 88 | total_percent_widget = self.query_one("#stat-this-total-percent") 89 | total_percent_widget.update(self.frame_total_percent) 90 | 91 | self_value_widget = self.query_one("#stat-this-self-value") 92 | self_value_widget.update(self.frame_self_value_humanize) 93 | 94 | self_percent_widget = self.query_one("#stat-this-self-percent") 95 | self_percent_widget.update(self.frame_self_percent) 96 | 97 | # TODO value should be rendered as different color based on total value 98 | @property 99 | def frame_this_total_value_humanize(self): 100 | logger.info("this instance: %s", self.frame) 101 | 102 | logger.info( 103 | "this instance name: %s, values=%s", 104 | self.frame.display_name, 105 | self.frame.values, 106 | ) 107 | value = self.frame.values[self.sample_index] 108 | value_display = humanize(self.sample_unit, value) 109 | return value_display 110 | 111 | @property 112 | def frame_self_value(self): 113 | value = self.frame.values[self.sample_index] 114 | self_value = value 115 | child_value = 0 116 | if self.frame.children: 117 | for child in self.frame.children: 118 | child_value += child.values[self.sample_index] 119 | 120 | self_value -= child_value 121 | return self_value 122 | 123 | @property 124 | def frame_self_value_humanize(self): 125 | value_display = humanize(self.sample_unit, self.frame_self_value) 126 | return value_display 127 | 128 | @property 129 | def frame_self_percent(self): 130 | frame = self.frame 131 | sample_index = self.sample_index 132 | 133 | if not frame.root.values[sample_index]: 134 | p_root = 0 135 | else: 136 | p_root = ( 137 | self.frame_self_value / frame.root.values[sample_index] * 100 138 | ) 139 | 140 | return f"{p_root:.2f}%" 141 | 142 | @property 143 | def frame_total_percent(self): 144 | frame = self.frame 145 | sample_index = self.sample_index 146 | 147 | if not frame.root.values[sample_index]: 148 | p_root = 0 149 | else: 150 | p_root = ( 151 | frame.values[sample_index] 152 | / frame.root.values[sample_index] 153 | * 100 154 | ) 155 | 156 | return f"{p_root:.2f}%" 157 | 158 | @property 159 | def sample_unit(self): 160 | return self.profile.sample_types[self.sample_index].sample_unit 161 | 162 | 163 | class FrameStatAll(Widget): 164 | frame = reactive(None) 165 | sample_index = reactive(None) 166 | 167 | # width = 998.1MiB|998.1MiB 168 | DEFAULT_CSS = """ 169 | FrameStatAll { 170 | height: 5; 171 | padding: 0 1; 172 | border: round $secondary; 173 | border-title-align: center; 174 | 175 | layout: grid; 176 | grid-size: 2 3; 177 | grid-rows: 1fr; 178 | grid-columns: 1fr; 179 | grid-gutter: 0 1; 180 | } 181 | 182 | #stat-all-total-label { 183 | background: $primary-background; 184 | } 185 | #stat-all-self-label { 186 | background: $secondary-background; 187 | } 188 | """ 189 | 190 | def __init__(self, frame, profile, sample_index, *args, **kwargs): 191 | self.composed = False 192 | super().__init__(*args, **kwargs) 193 | self.frame = frame 194 | self.sample_index = sample_index 195 | self.profile = profile 196 | self.border_title = "All Instances" 197 | self.name_to_frame = profile.name_aggr 198 | 199 | def compose(self): 200 | yield Static("Total", id="stat-all-total-label") 201 | yield Static("Self", id="stat-all-self-label") 202 | yield Static( 203 | self.frame_all_total_value_humanize, id="stat-all-total-value" 204 | ) 205 | yield Static( 206 | self.frame_all_self_value_humanize, id="stat-all-self-value" 207 | ) 208 | yield Static(self.frame_all_total_percent, id="stat-all-total-percent") 209 | yield Static(self.frame_all_self_percent, id="stat-all-self-percent") 210 | self.composed = True 211 | 212 | def watch_frame(self, _: Frame): 213 | self._rerender() 214 | 215 | def watch_sample_index(self, _: int): 216 | self._rerender() 217 | 218 | def _rerender(self): 219 | if not self.composed: 220 | return 221 | logger.info(f"rerender --> {self.frame=} {self.sample_index=}") 222 | 223 | total_value_widget = self.query_one("#stat-all-total-value") 224 | total_value_widget.update(self.frame_all_total_value_humanize) 225 | 226 | total_percent_widget = self.query_one("#stat-all-total-percent") 227 | total_percent_widget.update(self.frame_all_total_percent) 228 | 229 | self_value_widget = self.query_one("#stat-all-self-value") 230 | self_value_widget.update(self.frame_all_self_value_humanize) 231 | 232 | self_percent_widget = self.query_one("#stat-all-self-percent") 233 | self_percent_widget.update(self.frame_all_self_percent) 234 | 235 | @property 236 | def frame_all_self_value(self): 237 | frames_same_name = self.name_to_frame[self.frame.name] 238 | total_value = 0 239 | i = self.sample_index 240 | for instance in frames_same_name: 241 | self_value = instance.values[i] 242 | 243 | if instance.children: 244 | child_total = sum( 245 | child.values[i] for child in instance.children 246 | ) 247 | self_value -= child_total 248 | 249 | total_value += self_value 250 | 251 | return total_value 252 | 253 | @property 254 | def frame_all_self_value_humanize(self): 255 | value_display = humanize(self.sample_unit, self.frame_all_self_value) 256 | return value_display 257 | 258 | @property 259 | def frame_all_total_value(self): 260 | frames_same_name = self.name_to_frame[self.frame.name] 261 | total_value = sum( 262 | f.values[self.sample_index] for f in frames_same_name 263 | ) 264 | return total_value 265 | 266 | @property 267 | def frame_all_total_value_humanize(self): 268 | value_display = humanize(self.sample_unit, self.frame_all_total_value) 269 | return value_display 270 | 271 | @property 272 | def frame_all_self_percent(self): 273 | frame = self.frame 274 | sample_index = self.sample_index 275 | 276 | if not frame.root.values[sample_index]: 277 | p_root = 0 278 | else: 279 | p_root = ( 280 | self.frame_all_self_value 281 | / frame.root.values[sample_index] 282 | * 100 283 | ) 284 | 285 | return f"{p_root:.2f}%" 286 | 287 | @property 288 | def frame_all_total_percent(self): 289 | frame = self.frame 290 | sample_index = self.sample_index 291 | 292 | if not frame.root.values[sample_index]: 293 | p_root = 0 294 | else: 295 | p_root = ( 296 | self.frame_all_total_value 297 | / frame.root.values[sample_index] 298 | * 100 299 | ) 300 | 301 | return f"{p_root:.2f}%" 302 | 303 | @property 304 | def sample_unit(self): 305 | return self.profile.sample_types[self.sample_index].sample_unit 306 | 307 | 308 | class FrameDetail(Widget): 309 | DEFAULT_CSS = """ 310 | FrameDetail { 311 | layout: horizontal; 312 | height: 10; 313 | } 314 | 315 | #span-stack-container { 316 | width: 1fr; 317 | height: 1fr; 318 | padding-left: 1; 319 | padding-right: 0; 320 | border: round $secondary; 321 | content-align-vertical: middle; 322 | } 323 | 324 | #stat-container { 325 | width: 20%; 326 | max-width: 25; 327 | } 328 | """ 329 | frame = reactive(None, init=False) 330 | sample_index = reactive(None, init=False) 331 | 332 | def __init__(self, frame, profile, sample_index, *args, **kwargs): 333 | self.composed = False 334 | super().__init__(*args, **kwargs) 335 | self.frame = frame 336 | self.sample_index = sample_index 337 | self.profile = profile 338 | 339 | def compose(self): 340 | yield Vertical( 341 | FrameStatThis(self.frame, self.profile, self.sample_index), 342 | FrameStatAll(self.frame, self.profile, self.sample_index), 343 | id="stat-container", 344 | ) 345 | content = self.frame.render_detail(self.sample_index, self.sample_unit) 346 | span_detail = Static( 347 | content, 348 | id="span-detail", 349 | ) 350 | span_stack_container = VerticalScroll( 351 | span_detail, id="span-stack-container" 352 | ) 353 | span_stack_container.border_title = self.frame.title 354 | yield span_stack_container 355 | self.composed = True 356 | 357 | def _rerender(self): 358 | if not self.composed: 359 | return 360 | try: 361 | span_detail = self.query_one("#span-detail") 362 | span_stack_container = self.query_one("#span-stack-container") 363 | except NoMatches: 364 | return 365 | span_stack_container.border_title = self.frame.title 366 | content = self.frame.render_detail(self.sample_index, self.sample_unit) 367 | span_detail.update(content) 368 | 369 | try: 370 | frame_this_widget = self.query_one("FrameStatThis") 371 | except NoMatches: 372 | logger.warning("Can not find FrameStatThis when _rerender detail") 373 | else: 374 | frame_this_widget.frame = self.frame 375 | frame_this_widget.sample_index = self.sample_index 376 | 377 | try: 378 | frame_all_widget = self.query_one("FrameStatAll") 379 | except NoMatches: 380 | logger.warning("Can not find FrameStatAll when _rerender detail") 381 | else: 382 | frame_all_widget.frame = self.frame 383 | frame_all_widget.sample_index = self.sample_index 384 | 385 | @property 386 | def sample_unit(self): 387 | return self.profile.sample_types[self.sample_index].sample_unit 388 | 389 | def watch_frame(self, new_frame): 390 | logger.info("detailed frame changed to: %s", new_frame) 391 | self._rerender() 392 | 393 | def watch_sample_index(self, new_sample_index): 394 | logger.info("sample index changed to: %s", new_sample_index) 395 | self._rerender() 396 | 397 | 398 | class InformaionScreen(Screen): 399 | BINDINGS = [ 400 | Binding("escape", "exit_screen", "Close detail screen", show=True), 401 | ] 402 | sample_index = reactive(None, init=False) 403 | 404 | class InformaionScreenPopped(Message): 405 | pass 406 | 407 | def __init__( 408 | self, frame, sample_index, sample_unit, profile, *args, **kwargs 409 | ) -> None: 410 | self.composed = False 411 | super().__init__(*args, **kwargs) 412 | self.frame = frame 413 | self.sample_index = sample_index 414 | self.profile = profile 415 | 416 | def compose(self): 417 | center_text = "Stack detail information" 418 | yield FlameshowHeader(center_text) 419 | content = self.frame.render_detail(self.sample_index, self.sample_unit) 420 | span_detail = Static( 421 | content, 422 | id="span-detail", 423 | ) 424 | span_stack_container = VerticalScroll( 425 | span_detail, id="span-stack-container" 426 | ) 427 | span_stack_container.border_title = self.frame.title 428 | yield span_stack_container 429 | yield Footer() 430 | self.composed = True 431 | 432 | def action_exit_screen(self): 433 | self.post_message(self.InformaionScreenPopped()) 434 | 435 | def watch_sample_index(self, new_sample_index): 436 | logger.info("sample index change: %s", new_sample_index) 437 | self._rerender() 438 | 439 | def _rerender(self): 440 | if not self.composed: 441 | return 442 | content = self.frame.render_detail(self.sample_index, self.sample_unit) 443 | try: 444 | span_detail = self.query_one("#span-detail") 445 | except NoMatches: 446 | logger.warning("Didn't found #span-detail") 447 | return 448 | span_detail.update(content) 449 | 450 | @property 451 | def sample_unit(self): 452 | return self.profile.sample_types[self.sample_index].sample_unit 453 | -------------------------------------------------------------------------------- /flameshow/render/header.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rich.text import Text 4 | from textual.app import RenderResult 5 | from textual.containers import Horizontal 6 | from textual.css.query import NoMatches 7 | from textual.reactive import Reactive, reactive 8 | from textual.widget import Widget 9 | from textual.widgets import Header 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class HeaderIcon(Widget): 16 | """Display an 'icon' on the left of the header.""" 17 | 18 | DEFAULT_CSS = """ 19 | HeaderIcon { 20 | dock: left; 21 | padding-right: 1; 22 | width: 3; 23 | content-align: left middle; 24 | } 25 | """ 26 | 27 | icon = reactive("🔥") 28 | """The character to use as the icon within the header.""" 29 | 30 | def render(self) -> RenderResult: 31 | """Render the header icon. 32 | 33 | Returns: 34 | The rendered icon. 35 | """ 36 | return self.icon 37 | 38 | 39 | class HeaderTitle(Widget): 40 | """Display the title / subtitle in the header.""" 41 | 42 | DEFAULT_CSS = """ 43 | HeaderTitle { 44 | content-align: left middle; 45 | width: 20; 46 | } 47 | """ 48 | 49 | text: Reactive[str] = Reactive("") 50 | """The main title text.""" 51 | 52 | sub_text = Reactive("") 53 | """The sub-title text.""" 54 | 55 | def render(self) -> RenderResult: 56 | """Render the title and sub-title. 57 | 58 | Returns: 59 | The value to render. 60 | """ 61 | text = Text(self.text, no_wrap=True, overflow="ellipsis") 62 | if self.sub_text: 63 | text.append(" — ") 64 | text.append(self.sub_text, "dim") 65 | return text 66 | 67 | 68 | class HeaderOpenedFilename(Widget): 69 | DEFAULT_CSS = """ 70 | HeaderOpenedFilename { 71 | content-align: center middle; 72 | width: 70%; 73 | } 74 | """ 75 | 76 | filename = reactive("") 77 | 78 | def __init__(self, filename, *args, **kwargs): 79 | super().__init__(*args, **kwargs) 80 | self.filename = filename 81 | 82 | def render(self) -> RenderResult: 83 | text = Text(self.filename, no_wrap=True, overflow="ellipsis") 84 | logger.info("header filename: %s", self.filename) 85 | return text 86 | 87 | 88 | class FlameshowHeader(Header): 89 | center_text = reactive("", init=False) 90 | 91 | def __init__(self, filename, *args, **kwargs): 92 | super().__init__(*args, **kwargs) 93 | self.center_text = filename 94 | 95 | def compose(self): 96 | yield HeaderIcon() 97 | yield Horizontal( 98 | HeaderTitle(), 99 | HeaderOpenedFilename(self.center_text, id="header-center-text"), 100 | ) 101 | 102 | def watch_center_text(self, newtext): 103 | try: 104 | headero = self.query_one("#header-center-text") 105 | except NoMatches: 106 | pass 107 | else: 108 | headero.filename = newtext 109 | -------------------------------------------------------------------------------- /flameshow/render/tabs.py: -------------------------------------------------------------------------------- 1 | from textual.widgets import Tabs 2 | 3 | 4 | class SampleTabs(Tabs, can_focus=False): 5 | pass 6 | -------------------------------------------------------------------------------- /flameshow/runtime.py: -------------------------------------------------------------------------------- 1 | """ 2 | Holds the run time configs. 3 | Can be changed dynamically. 4 | """ 5 | 6 | from dataclasses import dataclass 7 | import logging 8 | 9 | from .colors import flamegraph_random_color_platte 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @dataclass 15 | class Runtime: 16 | color_platte = flamegraph_random_color_platte 17 | 18 | def get_color(self, key): 19 | return self.color_platte.get_color(key) 20 | 21 | 22 | r = Runtime() 23 | -------------------------------------------------------------------------------- /flameshow/utils.py: -------------------------------------------------------------------------------- 1 | def sizeof(num, suffix="B"): 2 | """ 3 | credit: Fred Cirera 4 | - https://stackoverflow.com/questions/1094841/get-human-readable-version-of-file-size 5 | - https://web.archive.org/web/20111010015624/http://blogmag.net/blog/read/38/Print_human_readable_file_size 6 | """ # noqa: E501 7 | f = "{num:.1f}{unit}{suffix}" 8 | for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"): 9 | if abs(num) < 1024.0: 10 | return f.format(num=num, unit=unit, suffix=suffix) 11 | num /= 1024.0 12 | return f.format(num=num, unit="Yi", suffix=suffix) 13 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: proto run-test 2 | 3 | proto: 4 | docker run --rm -v${PWD}:${PWD} -w${PWD} namely/protoc-all --proto_path=${PWD}/proto \ 5 | --python_out=${PWD}/flameshow/pprof_parser/ ${PWD}/proto/profile.proto 6 | bump_patch: 7 | bumpversion patch 8 | 9 | bump_minor: 10 | bumpversion minor 11 | 12 | patch: bump_patch 13 | rm -rf dist 14 | poetry build 15 | poetry publish 16 | 17 | _perf_startup: 18 | sudo py-spy record python _perf_main.py 19 | 20 | run-test: 21 | rm -rf htmlcov && pytest --cov-report html --cov=flameshow -vv --disable-warnings 22 | flake8 . 23 | black . 24 | open htmlcov/index.html 25 | -------------------------------------------------------------------------------- /proto/profile.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Google Inc. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Profile is a common stacktrace profile format. 16 | // 17 | // Measurements represented with this format should follow the 18 | // following conventions: 19 | // 20 | // - Consumers should treat unset optional fields as if they had been 21 | // set with their default value. 22 | // 23 | // - When possible, measurements should be stored in "unsampled" form 24 | // that is most useful to humans. There should be enough 25 | // information present to determine the original sampled values. 26 | // 27 | // - On-disk, the serialized proto must be gzip-compressed. 28 | // 29 | // - The profile is represented as a set of samples, where each sample 30 | // references a sequence of locations, and where each location belongs 31 | // to a mapping. 32 | // - There is a N->1 relationship from sample.location_id entries to 33 | // locations. For every sample.location_id entry there must be a 34 | // unique Location with that id. 35 | // - There is an optional N->1 relationship from locations to 36 | // mappings. For every nonzero Location.mapping_id there must be a 37 | // unique Mapping with that id. 38 | 39 | syntax = "proto3"; 40 | 41 | package perftools.profiles; 42 | 43 | option java_package = "com.google.perftools.profiles"; 44 | option java_outer_classname = "ProfileProto"; 45 | 46 | message Profile { 47 | // A description of the samples associated with each Sample.value. 48 | // For a cpu profile this might be: 49 | // [["cpu","nanoseconds"]] or [["wall","seconds"]] or [["syscall","count"]] 50 | // For a heap profile, this might be: 51 | // [["allocations","count"], ["space","bytes"]], 52 | // If one of the values represents the number of events represented 53 | // by the sample, by convention it should be at index 0 and use 54 | // sample_type.unit == "count". 55 | repeated ValueType sample_type = 1; 56 | // The set of samples recorded in this profile. 57 | repeated Sample sample = 2; 58 | // Mapping from address ranges to the image/binary/library mapped 59 | // into that address range. mapping[0] will be the main binary. 60 | repeated Mapping mapping = 3; 61 | // Locations referenced by samples. 62 | repeated Location location = 4; 63 | // Functions referenced by locations. 64 | repeated Function function = 5; 65 | // A common table for strings referenced by various messages. 66 | // string_table[0] must always be "". 67 | repeated string string_table = 6; 68 | // frames with Function.function_name fully matching the following 69 | // regexp will be dropped from the samples, along with their successors. 70 | int64 drop_frames = 7; // Index into string table. 71 | // frames with Function.function_name fully matching the following 72 | // regexp will be kept, even if it matches drop_frames. 73 | int64 keep_frames = 8; // Index into string table. 74 | 75 | // The following fields are informational, do not affect 76 | // interpretation of results. 77 | 78 | // Time of collection (UTC) represented as nanoseconds past the epoch. 79 | int64 time_nanos = 9; 80 | // Duration of the profile, if a duration makes sense. 81 | int64 duration_nanos = 10; 82 | // The kind of events between sampled occurrences. 83 | // e.g [ "cpu","cycles" ] or [ "heap","bytes" ] 84 | ValueType period_type = 11; 85 | // The number of events between sampled occurrences. 86 | int64 period = 12; 87 | // Free-form text associated with the profile. The text is displayed as is 88 | // to the user by the tools that read profiles (e.g. by pprof). This field 89 | // should not be used to store any machine-readable information, it is only 90 | // for human-friendly content. The profile must stay functional if this field 91 | // is cleaned. 92 | repeated int64 comment = 13; // Indices into string table. 93 | // Index into the string table of the type of the preferred sample 94 | // value. If unset, clients should default to the last sample value. 95 | int64 default_sample_type = 14; 96 | } 97 | 98 | // ValueType describes the semantics and measurement units of a value. 99 | message ValueType { 100 | int64 type = 1; // Index into string table. 101 | int64 unit = 2; // Index into string table. 102 | } 103 | 104 | // Each Sample records values encountered in some program 105 | // context. The program context is typically a stack trace, perhaps 106 | // augmented with auxiliary information like the thread-id, some 107 | // indicator of a higher level request being handled etc. 108 | message Sample { 109 | // The ids recorded here correspond to a Profile.location.id. 110 | // The leaf is at location_id[0]. 111 | repeated uint64 location_id = 1; 112 | // The type and unit of each value is defined by the corresponding 113 | // entry in Profile.sample_type. All samples must have the same 114 | // number of values, the same as the length of Profile.sample_type. 115 | // When aggregating multiple samples into a single sample, the 116 | // result has a list of values that is the element-wise sum of the 117 | // lists of the originals. 118 | repeated int64 value = 2; 119 | // label includes additional context for this sample. It can include 120 | // things like a thread id, allocation size, etc. 121 | // 122 | // NOTE: While possible, having multiple values for the same label key is 123 | // strongly discouraged and should never be used. Most tools (e.g. pprof) do 124 | // not have good (or any) support for multi-value labels. And an even more 125 | // discouraged case is having a string label and a numeric label of the same 126 | // name on a sample. Again, possible to express, but should not be used. 127 | repeated Label label = 3; 128 | } 129 | 130 | message Label { 131 | int64 key = 1; // Index into string table 132 | 133 | // At most one of the following must be present 134 | int64 str = 2; // Index into string table 135 | int64 num = 3; 136 | 137 | // Should only be present when num is present. 138 | // Specifies the units of num. 139 | // Use arbitrary string (for example, "requests") as a custom count unit. 140 | // If no unit is specified, consumer may apply heuristic to deduce the unit. 141 | // Consumers may also interpret units like "bytes" and "kilobytes" as memory 142 | // units and units like "seconds" and "nanoseconds" as time units, 143 | // and apply appropriate unit conversions to these. 144 | int64 num_unit = 4; // Index into string table 145 | } 146 | 147 | message Mapping { 148 | // Unique nonzero id for the mapping. 149 | uint64 id = 1; 150 | // Address at which the binary (or DLL) is loaded into memory. 151 | uint64 memory_start = 2; 152 | // The limit of the address range occupied by this mapping. 153 | uint64 memory_limit = 3; 154 | // Offset in the binary that corresponds to the first mapped address. 155 | uint64 file_offset = 4; 156 | // The object this entry is loaded from. This can be a filename on 157 | // disk for the main binary and shared libraries, or virtual 158 | // abstractions like "[vdso]". 159 | int64 filename = 5; // Index into string table 160 | // A string that uniquely identifies a particular program version 161 | // with high probability. E.g., for binaries generated by GNU tools, 162 | // it could be the contents of the .note.gnu.build-id field. 163 | int64 build_id = 6; // Index into string table 164 | 165 | // The following fields indicate the resolution of symbolic info. 166 | bool has_functions = 7; 167 | bool has_filenames = 8; 168 | bool has_line_numbers = 9; 169 | bool has_inline_frames = 10; 170 | } 171 | 172 | // Describes function and line table debug information. 173 | message Location { 174 | // Unique nonzero id for the location. A profile could use 175 | // instruction addresses or any integer sequence as ids. 176 | uint64 id = 1; 177 | // The id of the corresponding profile.Mapping for this location. 178 | // It can be unset if the mapping is unknown or not applicable for 179 | // this profile type. 180 | uint64 mapping_id = 2; 181 | // The instruction address for this location, if available. It 182 | // should be within [Mapping.memory_start...Mapping.memory_limit] 183 | // for the corresponding mapping. A non-leaf address may be in the 184 | // middle of a call instruction. It is up to display tools to find 185 | // the beginning of the instruction if necessary. 186 | uint64 address = 3; 187 | // Multiple line indicates this location has inlined functions, 188 | // where the last entry represents the caller into which the 189 | // preceding entries were inlined. 190 | // 191 | // E.g., if memcpy() is inlined into printf: 192 | // line[0].function_name == "memcpy" 193 | // line[1].function_name == "printf" 194 | repeated Line line = 4; 195 | // Provides an indication that multiple symbols map to this location's 196 | // address, for example due to identical code folding by the linker. In that 197 | // case the line information above represents one of the multiple 198 | // symbols. This field must be recomputed when the symbolization state of the 199 | // profile changes. 200 | bool is_folded = 5; 201 | } 202 | 203 | message Line { 204 | // The id of the corresponding profile.Function for this line. 205 | uint64 function_id = 1; 206 | // Line number in source code. 207 | int64 line = 2; 208 | } 209 | 210 | message Function { 211 | // Unique nonzero id for the function. 212 | uint64 id = 1; 213 | // Name of the function, in human-readable form if available. 214 | int64 name = 2; // Index into string table 215 | // Name of the function, as identified by the system. 216 | // For instance, it can be a C++ mangled name. 217 | int64 system_name = 3; // Index into string table 218 | // Source file containing the function. 219 | int64 filename = 4; // Index into string table 220 | // Line number in source file. 221 | int64 start_line = 5; 222 | } 223 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "flameshow" 3 | version = "1.1.4" 4 | homepage = "https://github.com/laixintao/flameshow" 5 | description = "A Terminal Flamegraph Viewer" 6 | authors = ["laixintao "] 7 | maintainers = ["laixintao "] 8 | readme = "README.md" 9 | license = "GPLv3" 10 | keywords = ["terminal", "golang", "pprof", "flamegraph"] 11 | packages = [{include = "flameshow"}] 12 | include = [ 13 | { path = "docs", format = "sdist" }, 14 | { path = "tests", format = "sdist" }, 15 | { path = "proto", format = "sdist" }, 16 | ] 17 | 18 | classifiers = [ 19 | "Development Status :: 5 - Production/Stable", 20 | "Environment :: Console", 21 | "Environment :: Console :: Curses", 22 | "Intended Audience :: Developers", 23 | "Operating System :: Microsoft :: Windows :: Windows 10", 24 | "Operating System :: Microsoft :: Windows :: Windows 11", 25 | "Operating System :: MacOS", 26 | "Operating System :: POSIX :: Linux", 27 | # "Programming Language :: Python :: 3.7", 28 | # "Programming Language :: Python :: 3.8", 29 | # "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Typing :: Typed", 34 | ] 35 | 36 | [tool.poetry.urls] 37 | "Bug Tracker" = "https://github.com/laixintao/flameshow/issues" 38 | 39 | [tool.poetry.dependencies] 40 | python = "^3.10" 41 | typing-extensions = "^4.7.1" 42 | textual = "^0.37.1" 43 | click = "^8.1.7" 44 | protobuf = "^4.25" 45 | iteround = "^1.0.4" 46 | 47 | [tool.poetry.scripts] 48 | flameshow = 'flameshow.main:main' 49 | 50 | [tool.poetry.group.dev.dependencies] 51 | pytest = "^7.4.2" 52 | pytest-asyncio = "^0.21.1" 53 | ipdb = "^0.13.13" 54 | flake8 = "^6.1.0" 55 | pytest-cov = "^2.12.1" 56 | 57 | [build-system] 58 | requires = ["poetry-core"] 59 | build-backend = "poetry.core.masonry.api" 60 | 61 | [tool.black] 62 | line-length = 79 63 | target-version = ['py37'] 64 | preview = true 65 | extend-exclude = ''' 66 | # A regex preceded with ^/ will apply only to files and directories 67 | # in the root of the project. 68 | ( 69 | .*_pb2.py # exclude autogenerated Protocol Buffer files anywhere in the project 70 | ) 71 | ''' 72 | 73 | [tool.pytest.ini_options] 74 | asyncio_mode = "auto" 75 | 76 | [tool.coverage.run] 77 | omit = [ 78 | "flameshow/pprof_parser/profile_pb2.py", 79 | "flameshow/exceptions.py", 80 | ] 81 | 82 | [tool.coverage.report] 83 | exclude_lines = [ 84 | 'pragma: no cover', 85 | 'if TYPE_CHECKING:', 86 | 'if __name__ == "__main__":', 87 | '@overload', 88 | '__rich_repr__', 89 | '@abstractmethod', 90 | 'NotImplementedError', 91 | ] 92 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pathlib 3 | from flameshow.pprof_parser import parse_profile 4 | 5 | 6 | pytest_plugins = ("pytest_asyncio",) 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def data_dir(): 11 | return pathlib.Path(__file__).parent / "pprof_data" 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def goroutine_pprof(): 16 | with open( 17 | pathlib.Path(__file__).parent / "pprof_data/goroutine.out", "rb" 18 | ) as f: 19 | return f.read() 20 | 21 | 22 | @pytest.fixture(scope="session") 23 | def profile10s(): 24 | with open( 25 | pathlib.Path(__file__).parent / "pprof_data/profile-10seconds.out", 26 | "rb", 27 | ) as f: 28 | return f.read() 29 | 30 | 31 | @pytest.fixture(scope="session") 32 | def profile10s_profile(): 33 | with open( 34 | pathlib.Path(__file__).parent / "pprof_data/profile-10seconds.out", 35 | "rb", 36 | ) as f: 37 | return parse_profile(f.read(), "pprof_data/profile-10seconds.out") 38 | 39 | 40 | @pytest.fixture(scope="session") 41 | def simple_collapse_data(): 42 | with open( 43 | pathlib.Path(__file__).parent / "stackcollapse_data/simple.txt", 44 | "rb", 45 | ) as f: 46 | return f.read() 47 | 48 | 49 | @pytest.fixture(scope="session") 50 | def collapse_data_with_comment(): 51 | with open( 52 | pathlib.Path(__file__).parent / "stackcollapse_data/with_comment.txt", 53 | "rb", 54 | ) as f: 55 | return f.read() 56 | -------------------------------------------------------------------------------- /tests/pprof_data/goroutine.out: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/pprof_data/goroutine.out -------------------------------------------------------------------------------- /tests/pprof_data/goroutine_frametree.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "net/http.(*conn).serve": { 4 | "net/http.(*conn).readRequest": { 5 | "net/http.readRequest": { 6 | "net/textproto.(*Reader).ReadLine": { 7 | "net/textproto.(*Reader).readLineSlice": { 8 | "bufio.(*Reader).ReadLine": { 9 | "bufio.(*Reader).ReadSlice": { 10 | "bufio.(*Reader).fill": { 11 | "net/http.(*connReader).Read": { 12 | "net.(*conn).Read": { 13 | "net.(*netFD).Read": { 14 | "internal/poll.(*FD).Read": { 15 | "internal/poll.(*pollDesc).waitRead": { 16 | "internal/poll.(*pollDesc).wait": { 17 | "internal/poll.runtime_pollWait": { 18 | "runtime.netpollblock": { 19 | "runtime.gopark": {} 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | "net/http.serverHandler.ServeHTTP": { 36 | "net/http.(*ServeMux).ServeHTTP": { 37 | "main.(*handler).ServeHTTP": { 38 | "net/http.HandlerFunc.ServeHTTP": { 39 | "github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerCounter.func1": { 40 | "net/http.HandlerFunc.ServeHTTP": { 41 | "github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerInFlight.func1": { 42 | "net/http.HandlerFunc.ServeHTTP": { 43 | "github.com/prometheus/client_golang/prometheus/promhttp.HandlerFor.func1": { 44 | "github.com/prometheus/client_golang/prometheus.Gatherers.Gather": { 45 | "github.com/prometheus/client_golang/prometheus.(*Registry).Gather": { 46 | "runtime.selectgo": { "runtime.gopark": {} } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | }, 56 | "net/http.HandlerFunc.ServeHTTP": { 57 | "net/http/pprof.Index": { 58 | "net/http/pprof.handler.ServeHTTP": { 59 | "runtime/pprof.(*Profile).WriteTo": { 60 | "runtime/pprof.writeGoroutine": { 61 | "runtime/pprof.writeRuntimeProfile": { 62 | "runtime/pprof.runtime_goroutineProfileWithLabels": {} 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | }, 72 | "github.com/godbus/dbus.(*Conn).inWorker": { 73 | "github.com/godbus/dbus.(*unixTransport).ReadMessage": { 74 | "io.ReadFull": { 75 | "io.ReadAtLeast": { 76 | "github.com/godbus/dbus.(*oobReader).Read": { 77 | "net.(*UnixConn).ReadMsgUnix": { 78 | "net.(*UnixConn).readMsg": { 79 | "net.(*netFD).readMsg": { 80 | "internal/poll.(*FD).ReadMsg": { 81 | "internal/poll.(*pollDesc).waitRead": { 82 | "internal/poll.(*pollDesc).wait": { 83 | "internal/poll.runtime_pollWait": { 84 | "runtime.netpollblock": { "runtime.gopark": {} } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | }, 97 | "github.com/prometheus/client_golang/prometheus.(*Registry).Gather.func2": { 98 | "sync.(*WaitGroup).Wait": { 99 | "sync.runtime_Semacquire": { 100 | "runtime.semacquire1": { 101 | "runtime.goparkunlock": { "runtime.gopark": {} } 102 | } 103 | } 104 | } 105 | }, 106 | "github.com/prometheus/client_golang/prometheus.(*Registry).Gather.func1": { 107 | "github.com/prometheus/node_exporter/collector.NodeCollector.Collect": { 108 | "sync.(*WaitGroup).Wait": { 109 | "sync.runtime_Semacquire": { 110 | "runtime.semacquire1": { 111 | "runtime.goparkunlock": { "runtime.gopark": {} } 112 | } 113 | } 114 | } 115 | } 116 | }, 117 | "github.com/prometheus/node_exporter/collector.NodeCollector.Collect.func1": { 118 | "github.com/prometheus/node_exporter/collector.execute": { 119 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).Update": { 120 | "github.com/prometheus/node_exporter/collector.newSystemdDbusConn": { 121 | "github.com/coreos/go-systemd/dbus.New": { 122 | "github.com/coreos/go-systemd/dbus.NewSystemdConnection": { 123 | "github.com/coreos/go-systemd/dbus.NewConnection": { 124 | "github.com/coreos/go-systemd/dbus.NewSystemdConnection.func1": { 125 | "github.com/coreos/go-systemd/dbus.dbusAuthConnection": { 126 | "github.com/godbus/dbus.(*Conn).Auth": { 127 | "github.com/godbus/dbus.(*Conn).tryAuth": { 128 | "github.com/godbus/dbus.authReadLine": { 129 | "bufio.(*Reader).ReadBytes": { 130 | "bufio.(*Reader).collectFragments": { 131 | "bufio.(*Reader).ReadSlice": { 132 | "bufio.(*Reader).fill": { 133 | "net.(*conn).Read": { 134 | "net.(*netFD).Read": { 135 | "internal/poll.(*FD).Read": { 136 | "internal/poll.(*pollDesc).waitRead": { 137 | "internal/poll.(*pollDesc).wait": { 138 | "internal/poll.runtime_pollWait": { 139 | "runtime.netpollblock": { 140 | "runtime.gopark": {} 141 | } 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | } 152 | } 153 | }, 154 | "github.com/godbus/dbus.authReadLine": { 155 | "bufio.(*Reader).ReadBytes": { 156 | "bufio.(*Reader).collectFragments": { 157 | "bufio.(*Reader).ReadSlice": { 158 | "bufio.(*Reader).fill": { 159 | "net.(*conn).Read": { 160 | "net.(*netFD).Read": { 161 | "internal/poll.(*FD).Read": { 162 | "internal/poll.(*pollDesc).waitRead": { 163 | "internal/poll.(*pollDesc).wait": { 164 | "internal/poll.runtime_pollWait": { 165 | "runtime.netpollblock": { 166 | "runtime.gopark": {} 167 | } 168 | } 169 | } 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | } 177 | } 178 | } 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | }, 186 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).getAllUnits": { 187 | "github.com/coreos/go-systemd/dbus.(*Conn).ListUnits": { 188 | "github.com/godbus/dbus.(*Object).Call": { 189 | "runtime.chanrecv1": { 190 | "runtime.chanrecv": { "runtime.gopark": {} } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | } 197 | }, 198 | "github.com/godbus/dbus.(*Object).createCall.func2": { 199 | "runtime.chanrecv1": { "runtime.chanrecv": { "runtime.gopark": {} } } 200 | }, 201 | "github.com/coreos/go-systemd/dbus.(*Conn).dispatch.func1": { 202 | "runtime.chanrecv2": { "runtime.chanrecv": { "runtime.gopark": {} } } 203 | }, 204 | "net/http.(*connReader).backgroundRead": { 205 | "net.(*conn).Read": { 206 | "net.(*netFD).Read": { 207 | "internal/poll.(*FD).Read": { 208 | "internal/poll.(*pollDesc).waitRead": { 209 | "internal/poll.(*pollDesc).wait": { 210 | "internal/poll.runtime_pollWait": { 211 | "runtime.netpollblock": { "runtime.gopark": {} } 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | }, 219 | "runtime.main": { 220 | "main.main": { 221 | "github.com/prometheus/exporter-toolkit/web.ListenAndServe": { 222 | "github.com/prometheus/exporter-toolkit/web.Serve": { 223 | "net/http.(*Server).Serve": { 224 | "net.(*TCPListener).Accept": { 225 | "net.(*TCPListener).accept": { 226 | "net.(*netFD).accept": { 227 | "internal/poll.(*FD).Accept": { 228 | "internal/poll.(*pollDesc).waitRead": { 229 | "internal/poll.(*pollDesc).wait": { 230 | "internal/poll.runtime_pollWait": { 231 | "runtime.netpollblock": { "runtime.gopark": {} } 232 | } 233 | } 234 | } 235 | } 236 | } 237 | } 238 | } 239 | } 240 | } 241 | } 242 | } 243 | } 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /tests/pprof_data/heap.out: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/pprof_data/heap.out -------------------------------------------------------------------------------- /tests/pprof_data/profile-10seconds.out: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/pprof_data/profile-10seconds.out -------------------------------------------------------------------------------- /tests/pprof_data/profile10s_frametree.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": { 3 | "github.com/prometheus/node_exporter/collector.NodeCollector.Collect.func1": { 4 | "github.com/prometheus/node_exporter/collector.execute": { 5 | "github.com/prometheus/node_exporter/collector.(*meminfoCollector).Update": { 6 | "github.com/prometheus/node_exporter/collector.(*meminfoCollector).getMemInfo": { 7 | "github.com/prometheus/node_exporter/collector.procFilePath": { 8 | "path/filepath.Join": { 9 | "path/filepath.join": { 10 | "strings.Join": { 11 | "strings.(*Builder).Grow": { 12 | "strings.(*Builder).grow": { 13 | "runtime.makeslice": { 14 | "runtime.newstack": { 15 | "runtime.copystack": { "runtime.gentraceback": {} } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | } 24 | } 25 | }, 26 | "github.com/prometheus/node_exporter/collector.(*netClassCollector).Update": { 27 | "github.com/prometheus/node_exporter/collector.(*netClassCollector).getNetClassInfo": { 28 | "github.com/prometheus/procfs/sysfs.FS.NetClassByIface": { 29 | "github.com/prometheus/procfs/sysfs.parseNetClassIface": { 30 | "io/ioutil.ReadDir": { 31 | "os.(*File).Readdir": { 32 | "os.(*File).readdir": { 33 | "os.Lstat": { 34 | "os.lstatNolog": { 35 | "os.ignoringEINTR": { 36 | "os.lstatNolog.func1": { 37 | "syscall.Lstat": { 38 | "syscall.fstatat": { "syscall.Syscall6": {} } 39 | } 40 | } 41 | } 42 | } 43 | }, 44 | "os.direntReclen": { 45 | "os.readInt": { "os.readIntLE": {} } 46 | } 47 | } 48 | } 49 | }, 50 | "github.com/prometheus/procfs/internal/util.SysReadFile": { 51 | "os.Open": { 52 | "os.OpenFile": { 53 | "os.openFileNolog": { 54 | "os.newFile": { 55 | "syscall.SetNonblock": { 56 | "syscall.fcntl": { 57 | "syscall.Syscall": { "runtime.exitsyscall": {} } 58 | } 59 | }, 60 | "internal/poll.(*FD).Init": { 61 | "internal/poll.(*pollDesc).init": { 62 | "internal/poll.runtime_pollOpen": { 63 | "runtime.netpollopen": { 64 | "runtime.epollctl": {} 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | }, 73 | "os.(*File).Fd": { 74 | "internal/poll.(*FD).SetBlocking": { 75 | "syscall.SetNonblock": { 76 | "syscall.fcntl": { "syscall.Syscall": {} } 77 | } 78 | } 79 | } 80 | } 81 | } 82 | } 83 | } 84 | }, 85 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).Update": { 86 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).getAllUnits": { 87 | "github.com/coreos/go-systemd/dbus.(*Conn).ListUnits": { 88 | "github.com/coreos/go-systemd/dbus.(*Conn).listUnitsInternal": { 89 | "github.com/godbus/dbus.(*Call).Store": { 90 | "github.com/godbus/dbus.Store": { 91 | "github.com/godbus/dbus.storeInterfaces": { 92 | "github.com/godbus/dbus.store": { 93 | "github.com/godbus/dbus.store": { 94 | "github.com/godbus/dbus.storeSlice": { 95 | "github.com/godbus/dbus.storeSliceIntoSlice": { 96 | "github.com/godbus/dbus.store": { 97 | "github.com/godbus/dbus.storeSlice": { 98 | "github.com/godbus/dbus.storeSliceIntoSlice": { 99 | "github.com/godbus/dbus.getVariantValue": { 100 | "github.com/godbus/dbus.isVariant": { 101 | "runtime.ifaceeq": {} 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | }, 114 | "github.com/godbus/dbus.Store": { 115 | "github.com/godbus/dbus.storeInterfaces": { 116 | "github.com/godbus/dbus.store": { 117 | "github.com/godbus/dbus.store": { 118 | "github.com/godbus/dbus.storeSlice": { 119 | "github.com/godbus/dbus.storeStruct": { 120 | "github.com/godbus/dbus.Store": { 121 | "github.com/godbus/dbus.storeInterfaces": { 122 | "github.com/godbus/dbus.store": { 123 | "github.com/godbus/dbus.store": { 124 | "github.com/godbus/dbus.storeBase": { 125 | "github.com/godbus/dbus.setDest": { 126 | "github.com/godbus/dbus.isVariant": { 127 | "runtime.ifaceeq": {} 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } 143 | } 144 | }, 145 | "github.com/prometheus/node_exporter/collector.(*udpQueuesCollector).Update": { 146 | "github.com/prometheus/procfs.FS.NetUDPSummary": { 147 | "github.com/prometheus/procfs.newNetUDPSummary": { 148 | "github.com/prometheus/procfs.newNetIPSocketSummary": { 149 | "bufio.(*Scanner).Scan": { 150 | "io.(*LimitedReader).Read": { 151 | "os.(*File).Read": { 152 | "os.(*File).read": { 153 | "internal/poll.(*FD).Read": { 154 | "internal/poll.ignoringEINTRIO": { 155 | "syscall.Read": { 156 | "syscall.read": { "syscall.Syscall": {} } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | } 169 | }, 170 | "runtime.gcBgMarkWorker": { 171 | "runtime.gcMarkDone": { 172 | "runtime.systemstack": { 173 | "runtime.gcMarkDone.func1": { 174 | "runtime.forEachP": { 175 | "runtime.preemptall": { 176 | "runtime.preemptone": { 177 | "runtime.preemptM": { 178 | "runtime.signalM": { "runtime.tgkill": {} } 179 | } 180 | } 181 | }, 182 | "runtime.notetsleep": { 183 | "runtime.notetsleep_internal": { 184 | "runtime.futexsleep": { "runtime.futex": {} } 185 | } 186 | } 187 | } 188 | } 189 | } 190 | }, 191 | "runtime.systemstack": { 192 | "runtime.gcBgMarkWorker.func2": { 193 | "runtime.gcDrain": { 194 | "runtime.scanobject": { 195 | "runtime.greyobject": { "runtime.pageIndexOf": {} } 196 | } 197 | } 198 | } 199 | } 200 | }, 201 | "runtime.mcall": { 202 | "runtime.gosched_m": { 203 | "runtime.goschedImpl": { 204 | "runtime.schedule": { 205 | "runtime.findrunnable": { "runtime.(*randomEnum).next": {} } 206 | } 207 | } 208 | }, 209 | "runtime.park_m": { 210 | "runtime.schedule": { 211 | "runtime.findrunnable": { 212 | "runtime.(*randomEnum).next": {}, 213 | "runtime.netpoll": { "runtime.epollwait": {} }, 214 | "runtime.runqget": {}, 215 | "runtime.checkTimers": {}, 216 | "runtime.pMask.read": {}, 217 | "runtime.stopm": { 218 | "runtime.mPark": { 219 | "runtime.notesleep": { 220 | "runtime.futexsleep": { "runtime.futex": {} } 221 | } 222 | } 223 | }, 224 | "runtime.(*randomOrder).start": {}, 225 | "runtime.(*randomEnum).position": {}, 226 | "runtime.(*randomEnum).done": {} 227 | }, 228 | "runtime.resetspinning": { 229 | "runtime.wakep": { 230 | "runtime.startm": { 231 | "runtime.notewakeup": { 232 | "runtime.futexwakeup": { "runtime.futex": {} } 233 | } 234 | } 235 | } 236 | } 237 | } 238 | }, 239 | "runtime.goexit0": { 240 | "runtime.schedule": { 241 | "runtime.findrunnable": { 242 | "runtime.netpoll": { "runtime.epollwait": {} } 243 | } 244 | } 245 | } 246 | }, 247 | "github.com/godbus/dbus.(*Conn).inWorker": { 248 | "github.com/godbus/dbus.(*unixTransport).ReadMessage": { 249 | "github.com/godbus/dbus.DecodeMessage": { 250 | "github.com/godbus/dbus.(*decoder).Decode": { 251 | "github.com/godbus/dbus.(*decoder).decode": { 252 | "github.com/godbus/dbus.(*decoder).decode": { 253 | "github.com/godbus/dbus.(*decoder).decode": { 254 | "github.com/godbus/dbus.alignment": { "runtime.ifaceeq": {} }, 255 | "github.com/godbus/dbus.(*decoder).decode": { 256 | "github.com/godbus/dbus.(*decoder).binread": { 257 | "encoding/binary.Read": {} 258 | } 259 | } 260 | } 261 | } 262 | } 263 | }, 264 | "runtime.mapassign": { 265 | "runtime.newobject": { 266 | "runtime.mallocgc": { "runtime.heapBitsSetType": {} } 267 | } 268 | } 269 | }, 270 | "io.ReadFull": { 271 | "io.ReadAtLeast": { 272 | "github.com/godbus/dbus.(*oobReader).Read": { 273 | "net.(*UnixConn).ReadMsgUnix": { 274 | "net.(*UnixConn).readMsg": { 275 | "net.(*netFD).readMsg": { 276 | "internal/poll.(*FD).ReadMsg": { 277 | "syscall.Recvmsg": { 278 | "syscall.recvmsg": { "syscall.Syscall": {} } 279 | } 280 | } 281 | } 282 | } 283 | } 284 | } 285 | } 286 | } 287 | } 288 | }, 289 | "net/http.(*conn).serve": { 290 | "net/http.serverHandler.ServeHTTP": { 291 | "net/http.(*ServeMux).ServeHTTP": { 292 | "main.(*handler).ServeHTTP": { 293 | "net/http.HandlerFunc.ServeHTTP": { 294 | "github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerCounter.func1": { 295 | "net/http.HandlerFunc.ServeHTTP": { 296 | "github.com/prometheus/client_golang/prometheus/promhttp.InstrumentHandlerInFlight.func1": { 297 | "net/http.HandlerFunc.ServeHTTP": { 298 | "github.com/prometheus/client_golang/prometheus/promhttp.HandlerFor.func1": { 299 | "github.com/prometheus/client_golang/prometheus.Gatherers.Gather": { 300 | "github.com/prometheus/client_golang/prometheus/internal.NormalizeMetricFamilies": { 301 | "sort.Sort": { 302 | "sort.quickSort": { 303 | "sort.quickSort": { "sort.doPivot": {} } 304 | } 305 | } 306 | }, 307 | "github.com/prometheus/client_golang/prometheus.(*Registry).Gather": { 308 | "github.com/prometheus/client_golang/prometheus.processMetric": { 309 | "github.com/prometheus/client_golang/prometheus.checkMetricConsistency": { 310 | "runtime.mapassign_fast64": { 311 | "runtime.growWork_fast64": { 312 | "runtime.evacuate_fast64": {} 313 | } 314 | } 315 | }, 316 | "runtime.mapaccess2_faststr": { "memeqbody": {} } 317 | }, 318 | "github.com/prometheus/client_golang/prometheus/internal.NormalizeMetricFamilies": { 319 | "sort.Sort": { 320 | "sort.quickSort": { 321 | "sort.doPivot": { 322 | "github.com/prometheus/client_golang/prometheus/internal.metricSorter.Less": { 323 | "runtime.memequal": {} 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | }, 331 | "github.com/prometheus/common/expfmt.encoderCloser.Encode": { 332 | "github.com/prometheus/common/expfmt.NewEncoder.func7": { 333 | "github.com/prometheus/common/expfmt.MetricFamilyToText": { 334 | "github.com/prometheus/common/expfmt.MetricFamilyToText.func1": { 335 | "bufio.(*Writer).Flush": { 336 | "compress/gzip.(*Writer).Write": { 337 | "compress/flate.(*Writer).Write": { 338 | "compress/flate.(*compressor).write": { 339 | "compress/flate.(*compressor).deflate": { 340 | "compress/flate.(*compressor).findMatch": { 341 | "compress/flate.matchLen": {} 342 | }, 343 | "runtime.asyncPreempt": {} 344 | } 345 | } 346 | } 347 | } 348 | } 349 | }, 350 | "github.com/prometheus/common/expfmt.writeSample": { 351 | "github.com/prometheus/common/expfmt.writeLabelPairs": { 352 | "github.com/prometheus/common/expfmt.writeEscapedString": { 353 | "strings.(*Replacer).WriteString": { 354 | "strings.(*byteStringReplacer).WriteString": { 355 | "bufio.(*Writer).WriteString": {} 356 | } 357 | } 358 | } 359 | } 360 | } 361 | } 362 | } 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | } 370 | } 371 | } 372 | }, 373 | "net/http.(*conn).readRequest": { 374 | "net/http.readRequest": { 375 | "net/textproto.(*Reader).ReadLine": { 376 | "net/textproto.(*Reader).readLineSlice": { 377 | "bufio.(*Reader).ReadLine": { 378 | "bufio.(*Reader).ReadSlice": { 379 | "bufio.(*Reader).fill": { 380 | "net/http.(*connReader).Read": { 381 | "net.(*conn).Read": { 382 | "net.(*netFD).Read": { 383 | "internal/poll.(*FD).Read": { 384 | "internal/poll.ignoringEINTRIO": { 385 | "syscall.Read": { 386 | "syscall.read": { "syscall.Syscall": {} } 387 | } 388 | } 389 | } 390 | } 391 | } 392 | } 393 | } 394 | } 395 | } 396 | } 397 | } 398 | } 399 | } 400 | }, 401 | "runtime.morestack": { 402 | "runtime.newstack": { 403 | "runtime.gopreempt_m": { 404 | "runtime.goschedImpl": { 405 | "runtime.lock": { 406 | "runtime.lockWithRank": { 407 | "runtime.lock2": { "runtime.procyield": {} } 408 | } 409 | } 410 | } 411 | } 412 | } 413 | }, 414 | "golang.org/x/sync/errgroup.(*Group).Go.func1": { 415 | "github.com/prometheus/procfs/sysfs.FS.SystemCpufreq.func1": { 416 | "github.com/prometheus/procfs/sysfs.parseCpufreqCpuinfo": { 417 | "github.com/prometheus/procfs/internal/util.SysReadFile": { 418 | "os.(*File).Close": { 419 | "os.(*file).close": { 420 | "internal/poll.(*FD).Close": { 421 | "internal/poll.(*FD).decref": { 422 | "internal/poll.(*FD).destroy": { 423 | "syscall.Close": { "syscall.Syscall": {} } 424 | } 425 | } 426 | } 427 | } 428 | } 429 | }, 430 | "runtime.newobject": { 431 | "runtime.mallocgc": { "runtime.heapBitsSetType": {} } 432 | }, 433 | "github.com/prometheus/procfs/internal/util.ReadUintFromFile": { 434 | "io/ioutil.ReadFile": { 435 | "os.ReadFile": { 436 | "os.(*File).Close": { 437 | "os.(*file).close": { 438 | "runtime.SetFinalizer": { 439 | "runtime.systemstack": { 440 | "runtime.SetFinalizer.func1": { 441 | "runtime.removefinalizer": { 442 | "runtime.removespecial": { 443 | "runtime.(*mspan).ensureSwept": { 444 | "runtime.osyield": {} 445 | } 446 | } 447 | } 448 | } 449 | } 450 | } 451 | } 452 | }, 453 | "os.Open": { 454 | "os.OpenFile": { 455 | "os.openFileNolog": { 456 | "syscall.Open": { 457 | "syscall.openat": { "syscall.Syscall6": {} } 458 | } 459 | } 460 | } 461 | } 462 | } 463 | } 464 | } 465 | } 466 | } 467 | }, 468 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).Update.func1": { 469 | "github.com/prometheus/node_exporter/collector.(*systemdCollector).collectUnitStatusMetrics": { 470 | "github.com/prometheus/client_golang/prometheus.MustNewConstMetric": { 471 | "github.com/prometheus/client_golang/prometheus.NewConstMetric": { 472 | "github.com/prometheus/client_golang/prometheus.validateLabelValues": { 473 | "unicode/utf8.ValidString": {} 474 | } 475 | } 476 | }, 477 | "github.com/coreos/go-systemd/dbus.(*Conn).GetUnitTypeProperty": { 478 | "github.com/coreos/go-systemd/dbus.(*Conn).getProperty": { 479 | "github.com/godbus/dbus.(*Object).Call": { 480 | "github.com/godbus/dbus.(*Object).createCall": { 481 | "github.com/godbus/dbus.(*Conn).sendMessageAndIfClosed": { 482 | "github.com/godbus/dbus.(*outputHandler).sendAndIfClosed": { 483 | "github.com/godbus/dbus.(*unixTransport).SendMessage": { 484 | "github.com/godbus/dbus.(*Message).EncodeTo": { 485 | "github.com/godbus/dbus.(*encoder).Encode": { 486 | "github.com/godbus/dbus.(*encoder).encode": { 487 | "github.com/godbus/dbus.(*encoder).encode": { 488 | "github.com/godbus/dbus.(*encoder).encode": { 489 | "github.com/godbus/dbus.(*encoder).encode": { 490 | "bytes.(*Buffer).Write": { 491 | "bytes.(*Buffer).grow": { 492 | "bytes.makeSlice": { 493 | "runtime.makeslice": { 494 | "runtime.mallocgc": { 495 | "runtime.(*mcache).nextFree": { 496 | "runtime.(*mcache).refill": { 497 | "runtime.(*mcentral).cacheSpan": { 498 | "runtime.(*mcentral).grow": { 499 | "runtime.(*mheap).alloc": { 500 | "runtime.memclrNoHeapPointers": {} 501 | } 502 | } 503 | } 504 | } 505 | }, 506 | "runtime.memclrNoHeapPointers": {} 507 | } 508 | } 509 | } 510 | } 511 | } 512 | } 513 | } 514 | } 515 | } 516 | } 517 | } 518 | } 519 | } 520 | } 521 | } 522 | } 523 | } 524 | } 525 | } 526 | }, 527 | "runtime.bgsweep": { 528 | "runtime.sweepone": { 529 | "runtime.(*mspan).sweep": { 530 | "runtime.newMarkBits": { "runtime.(*gcBitsArena).tryAlloc": {} } 531 | } 532 | } 533 | } 534 | } 535 | } 536 | -------------------------------------------------------------------------------- /tests/pprof_data/sample_location_line_multiple.json: -------------------------------------------------------------------------------- 1 | { 2 | "SampleType": [ 3 | { 4 | "Type": "goroutine", 5 | "Unit": "count" 6 | } 7 | ], 8 | "Sample": [ 9 | { 10 | "Label": null, 11 | "Location": [ 12 | { 13 | "Address": 4582655, 14 | "ID": 1, 15 | "IsFolded": false, 16 | "Line": [ 17 | { 18 | "Function": { 19 | "Filename": "/usr/local/go/src/runtime/traceback.go", 20 | "ID": 1, 21 | "Name": "runtime.gentraceback", 22 | "StartLine": 0, 23 | "SystemName": "runtime.gentraceback" 24 | }, 25 | "Line": 177 26 | } 27 | ], 28 | "Mapping": { 29 | "BuildID": "", 30 | "File": "/usr/bin/node-exporter", 31 | "HasFilenames": false, 32 | "HasFunctions": true, 33 | "HasInlineFrames": false, 34 | "HasLineNumbers": false, 35 | "ID": 1, 36 | "KernelRelocationSymbol": "", 37 | "Limit": 11280384, 38 | "Offset": 0, 39 | "Start": 4194304 40 | } 41 | }, 42 | { 43 | "Address": 4532272, 44 | "ID": 2, 45 | "IsFolded": false, 46 | "Line": [ 47 | { 48 | "Function": { 49 | "Filename": "/usr/local/go/src/runtime/stack.go", 50 | "ID": 2, 51 | "Name": "runtime.copystack", 52 | "StartLine": 0, 53 | "SystemName": "runtime.copystack" 54 | }, 55 | "Line": 908 56 | } 57 | ], 58 | "Mapping": { 59 | "BuildID": "", 60 | "File": "/usr/bin/node-exporter", 61 | "HasFilenames": false, 62 | "HasFunctions": true, 63 | "HasInlineFrames": false, 64 | "HasLineNumbers": false, 65 | "ID": 1, 66 | "KernelRelocationSymbol": "", 67 | "Limit": 11280384, 68 | "Offset": 0, 69 | "Start": 4194304 70 | } 71 | }, 72 | { 73 | "Address": 4533174, 74 | "ID": 3, 75 | "IsFolded": false, 76 | "Line": [ 77 | { 78 | "Function": { 79 | "Filename": "/usr/local/go/src/runtime/stack.go", 80 | "ID": 3, 81 | "Name": "runtime.newstack", 82 | "StartLine": 0, 83 | "SystemName": "runtime.newstack" 84 | }, 85 | "Line": 1078 86 | } 87 | ], 88 | "Mapping": { 89 | "BuildID": "", 90 | "File": "/usr/bin/node-exporter", 91 | "HasFilenames": false, 92 | "HasFunctions": true, 93 | "HasInlineFrames": false, 94 | "HasLineNumbers": false, 95 | "ID": 1, 96 | "KernelRelocationSymbol": "", 97 | "Limit": 11280384, 98 | "Offset": 0, 99 | "Start": 4194304 100 | } 101 | }, 102 | { 103 | "Address": 4523594, 104 | "ID": 4, 105 | "IsFolded": false, 106 | "Line": [ 107 | { 108 | "Function": { 109 | "Filename": "/usr/local/go/src/runtime/slice.go", 110 | "ID": 4, 111 | "Name": "runtime.makeslice", 112 | "StartLine": 0, 113 | "SystemName": "runtime.makeslice" 114 | }, 115 | "Line": 83 116 | } 117 | ], 118 | "Mapping": { 119 | "BuildID": "", 120 | "File": "/usr/bin/node-exporter", 121 | "HasFilenames": false, 122 | "HasFunctions": true, 123 | "HasInlineFrames": false, 124 | "HasLineNumbers": false, 125 | "ID": 1, 126 | "KernelRelocationSymbol": "", 127 | "Limit": 11280384, 128 | "Offset": 0, 129 | "Start": 4194304 130 | } 131 | }, 132 | { 133 | "Address": 5331654, 134 | "ID": 5, 135 | "IsFolded": false, 136 | "Line": [ 137 | { 138 | "Function": { 139 | "Filename": "/usr/local/go/src/strings/builder.go", 140 | "ID": 5, 141 | "Name": "strings.(*Builder).grow", 142 | "StartLine": 0, 143 | "SystemName": "strings.(*Builder).grow" 144 | }, 145 | "Line": 68 146 | }, 147 | { 148 | "Function": { 149 | "Filename": "/usr/local/go/src/strings/builder.go", 150 | "ID": 6, 151 | "Name": "strings.(*Builder).Grow", 152 | "StartLine": 0, 153 | "SystemName": "strings.(*Builder).Grow" 154 | }, 155 | "Line": 82 156 | }, 157 | { 158 | "Function": { 159 | "Filename": "/usr/local/go/src/strings/strings.go", 160 | "ID": 7, 161 | "Name": "strings.Join", 162 | "StartLine": 0, 163 | "SystemName": "strings.Join" 164 | }, 165 | "Line": 434 166 | } 167 | ], 168 | "Mapping": { 169 | "BuildID": "", 170 | "File": "/usr/bin/node-exporter", 171 | "HasFilenames": false, 172 | "HasFunctions": true, 173 | "HasInlineFrames": false, 174 | "HasLineNumbers": false, 175 | "ID": 1, 176 | "KernelRelocationSymbol": "", 177 | "Limit": 11280384, 178 | "Offset": 0, 179 | "Start": 4194304 180 | } 181 | }, 182 | { 183 | "Address": 5810086, 184 | "ID": 6, 185 | "IsFolded": false, 186 | "Line": [ 187 | { 188 | "Function": { 189 | "Filename": "/usr/local/go/src/path/filepath/path_unix.go", 190 | "ID": 8, 191 | "Name": "path/filepath.join", 192 | "StartLine": 0, 193 | "SystemName": "path/filepath.join" 194 | }, 195 | "Line": 45 196 | } 197 | ], 198 | "Mapping": { 199 | "BuildID": "", 200 | "File": "/usr/bin/node-exporter", 201 | "HasFilenames": false, 202 | "HasFunctions": true, 203 | "HasInlineFrames": false, 204 | "HasLineNumbers": false, 205 | "ID": 1, 206 | "KernelRelocationSymbol": "", 207 | "Limit": 11280384, 208 | "Offset": 0, 209 | "Start": 4194304 210 | } 211 | }, 212 | { 213 | "Address": 10874332, 214 | "ID": 7, 215 | "IsFolded": false, 216 | "Line": [ 217 | { 218 | "Function": { 219 | "Filename": "/usr/local/go/src/path/filepath/path.go", 220 | "ID": 9, 221 | "Name": "path/filepath.Join", 222 | "StartLine": 0, 223 | "SystemName": "path/filepath.Join" 224 | }, 225 | "Line": 213 226 | }, 227 | { 228 | "Function": { 229 | "Filename": "/go/src/node_exporter/collector/paths.go", 230 | "ID": 10, 231 | "Name": "github.com/prometheus/node_exporter/collector.procFilePath", 232 | "StartLine": 0, 233 | "SystemName": "github.com/prometheus/node_exporter/collector.procFilePath" 234 | }, 235 | "Line": 32 236 | }, 237 | { 238 | "Function": { 239 | "Filename": "/go/src/node_exporter/collector/meminfo_linux.go", 240 | "ID": 11, 241 | "Name": "github.com/prometheus/node_exporter/collector.(*meminfoCollector).getMemInfo", 242 | "StartLine": 0, 243 | "SystemName": "github.com/prometheus/node_exporter/collector.(*meminfoCollector).getMemInfo" 244 | }, 245 | "Line": 34 246 | } 247 | ], 248 | "Mapping": { 249 | "BuildID": "", 250 | "File": "/usr/bin/node-exporter", 251 | "HasFilenames": false, 252 | "HasFunctions": true, 253 | "HasInlineFrames": false, 254 | "HasLineNumbers": false, 255 | "ID": 1, 256 | "KernelRelocationSymbol": "", 257 | "Limit": 11280384, 258 | "Offset": 0, 259 | "Start": 4194304 260 | } 261 | }, 262 | { 263 | "Address": 10872900, 264 | "ID": 8, 265 | "IsFolded": false, 266 | "Line": [ 267 | { 268 | "Function": { 269 | "Filename": "/go/src/node_exporter/collector/meminfo.go", 270 | "ID": 12, 271 | "Name": "github.com/prometheus/node_exporter/collector.(*meminfoCollector).Update", 272 | "StartLine": 0, 273 | "SystemName": "github.com/prometheus/node_exporter/collector.(*meminfoCollector).Update" 274 | }, 275 | "Line": 50 276 | } 277 | ], 278 | "Mapping": { 279 | "BuildID": "", 280 | "File": "/usr/bin/node-exporter", 281 | "HasFilenames": false, 282 | "HasFunctions": true, 283 | "HasInlineFrames": false, 284 | "HasLineNumbers": false, 285 | "ID": 1, 286 | "KernelRelocationSymbol": "", 287 | "Limit": 11280384, 288 | "Offset": 0, 289 | "Start": 4194304 290 | } 291 | }, 292 | { 293 | "Address": 10682787, 294 | "ID": 9, 295 | "IsFolded": false, 296 | "Line": [ 297 | { 298 | "Function": { 299 | "Filename": "/go/src/node_exporter/collector/collector.go", 300 | "ID": 13, 301 | "Name": "github.com/prometheus/node_exporter/collector.execute", 302 | "StartLine": 0, 303 | "SystemName": "github.com/prometheus/node_exporter/collector.execute" 304 | }, 305 | "Line": 161 306 | } 307 | ], 308 | "Mapping": { 309 | "BuildID": "", 310 | "File": "/usr/bin/node-exporter", 311 | "HasFilenames": false, 312 | "HasFunctions": true, 313 | "HasInlineFrames": false, 314 | "HasLineNumbers": false, 315 | "ID": 1, 316 | "KernelRelocationSymbol": "", 317 | "Limit": 11280384, 318 | "Offset": 0, 319 | "Start": 4194304 320 | } 321 | }, 322 | { 323 | "Address": 11206640, 324 | "ID": 10, 325 | "IsFolded": false, 326 | "Line": [ 327 | { 328 | "Function": { 329 | "Filename": "/go/src/node_exporter/collector/collector.go", 330 | "ID": 14, 331 | "Name": "github.com/prometheus/node_exporter/collector.NodeCollector.Collect.func1", 332 | "StartLine": 0, 333 | "SystemName": "github.com/prometheus/node_exporter/collector.NodeCollector.Collect.func1" 334 | }, 335 | "Line": 152 336 | } 337 | ], 338 | "Mapping": { 339 | "BuildID": "", 340 | "File": "/usr/bin/node-exporter", 341 | "HasFilenames": false, 342 | "HasFunctions": true, 343 | "HasInlineFrames": false, 344 | "HasLineNumbers": false, 345 | "ID": 1, 346 | "KernelRelocationSymbol": "", 347 | "Limit": 11280384, 348 | "Offset": 0, 349 | "Start": 4194304 350 | } 351 | } 352 | ], 353 | "NumLabel": null, 354 | "NumUnit": null, 355 | "Value": [1, 10000000] 356 | } 357 | ] 358 | } 359 | -------------------------------------------------------------------------------- /tests/stackcollapse_data/simple.txt: -------------------------------------------------------------------------------- 1 | a;b;c 1 2 | a;b;c 1 3 | a;b;d 4 4 | a;b;c 3 5 | a;b 5 6 | -------------------------------------------------------------------------------- /tests/stackcollapse_data/simple_square.txt: -------------------------------------------------------------------------------- 1 | a;b;[c] 1 2 | a;[b];d 4 3 | -------------------------------------------------------------------------------- /tests/stackcollapse_data/with_comment.txt: -------------------------------------------------------------------------------- 1 | # austin: 3.7.0 2 | # interval: 100 3 | # mode: wall 4 | # python: 3.12.7 5 | # duration: 828637 6 | # sampling: 3,35,6057 7 | # saturation: 39/5305 8 | # errors: 820/5305 9 | 10 | 11 | P245906;T0:245906 16905 12 | P245906;T0:245906;::1546 158 13 | -------------------------------------------------------------------------------- /tests/test_cli_click.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock, patch 3 | 4 | from click.testing import CliRunner 5 | 6 | from flameshow.main import ensure_tty, main 7 | 8 | 9 | def test_print_version(): 10 | runner = CliRunner() 11 | with patch("flameshow.main.__version__", "1.2.3"): 12 | result = runner.invoke(main, ["--version"]) 13 | assert result.exit_code == 0 14 | assert result.output == "1.2.3\n" 15 | 16 | 17 | @patch("flameshow.main.os") 18 | def test_run_app(mock_os, data_dir): 19 | mock_os.isatty.return_value = True 20 | runner = CliRunner() 21 | result = runner.invoke( 22 | main, [str(data_dir / "profile-10seconds.out")], input="q" 23 | ) 24 | 25 | assert result.exit_code == 0 26 | 27 | 28 | @patch("flameshow.main.sys") 29 | @patch("flameshow.main.os") 30 | def test_ensure_tty_when_its_not(mock_os, mock_sys): 31 | mock_os.isatty.return_value = False 32 | opened_fd = object() 33 | mock_os.fdopen.return_value = opened_fd 34 | 35 | fake_stdin = MagicMock() 36 | mock_sys.stdin = fake_stdin 37 | 38 | ensure_tty() 39 | 40 | fake_stdin.close.assert_called() 41 | assert hasattr(mock_sys, "stdin") 42 | assert mock_sys.stdin is opened_fd 43 | -------------------------------------------------------------------------------- /tests/test_colors.py: -------------------------------------------------------------------------------- 1 | from flameshow.colors import LinaerColorPlatte, flamegraph_random_color_platte 2 | from textual.color import Color 3 | 4 | 5 | def test_linaer_color_platte(): 6 | platte = LinaerColorPlatte() 7 | 8 | color1 = platte.get_color("1") 9 | color3 = platte.get_color("1") 10 | 11 | color2 = platte.get_color("2") 12 | assert color1 is color3 13 | 14 | assert isinstance(color2, Color) 15 | 16 | for key in range(999): 17 | platte.get_color(key) 18 | 19 | 20 | def test_flamegraph_random_color_platte(): 21 | platte = flamegraph_random_color_platte 22 | 23 | color1 = platte.get_color("1") 24 | color3 = platte.get_color("1") 25 | 26 | color2 = platte.get_color("2") 27 | assert color1 is color3 28 | 29 | assert isinstance(color2, Color) 30 | -------------------------------------------------------------------------------- /tests/test_integration/test_app.py: -------------------------------------------------------------------------------- 1 | from flameshow.render.app import FlameshowApp 2 | 3 | 4 | async def test_app_startup(profile10s_profile): 5 | app = FlameshowApp(profile10s_profile) 6 | async with app.run_test() as pilot: 7 | center_text = app.query_one("HeaderOpenedFilename") 8 | assert ( 9 | center_text.filename 10 | == "pprof_data/profile-10seconds.out: (cpu, nanoseconds)" 11 | ) 12 | 13 | # test moving around 14 | await pilot.press("j") 15 | flamegraph_widget = app.query_one("FlameGraph") 16 | assert flamegraph_widget.view_frame._id == 34 17 | assert flamegraph_widget.view_frame.name == "runtime.mcall" 18 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flameshow.main import setup_log 3 | import os 4 | 5 | from unittest.mock import patch 6 | 7 | 8 | def test_run_app_with_verbose_logging(data_dir): 9 | # cleanup logfile first 10 | path = data_dir / "._pytest_flameshow.log" 11 | try: 12 | os.remove(path) 13 | except: # noqa 14 | pass 15 | 16 | with patch.object(logging, "basicConfig") as mock_config: 17 | setup_log(True, logging.DEBUG, path) 18 | 19 | mock_config.assert_called_once() 20 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | 4 | from flameshow.models import Profile, SampleType 5 | from flameshow.pprof_parser.parser import Frame, PprofFrame, ProfileParser 6 | 7 | 8 | def test_parse_max_depth_when_have_multiple_lines(profile10s): 9 | parser = ProfileParser("abc") 10 | 11 | profile = parser.parse(profile10s) 12 | assert profile.highest_lines == 26 13 | 14 | 15 | def test_pile_up(): 16 | root = Frame("root", 0, values=[5]) 17 | s1 = Frame("s1", 1, values=[4], parent=root) 18 | s2 = Frame("s2", 2, values=[4], parent=s1) 19 | 20 | root.children = [s1] 21 | s1.children = [s2] 22 | 23 | s1 = Frame("s1", 3, values=[3], parent=None) 24 | s2 = Frame("s2", 4, values=[2], parent=s1) 25 | s1.children = [s2] 26 | 27 | root.pile_up(s1) 28 | assert root.children[0].values == [7] 29 | assert root.children[0].children[0].values == [6] 30 | 31 | 32 | def test_profile_creataion(): 33 | root = Frame("root", 0, values=[5]) 34 | s1 = Frame("s1", 1, values=[4], parent=root) 35 | s2 = Frame("s2", 2, values=[1], parent=s1) 36 | s3 = Frame("s3", 3, values=[2], parent=s1) 37 | 38 | root.children = [s1] 39 | s1.children = [s2, s3] 40 | 41 | p = Profile( 42 | filename="abc", 43 | root_stack=root, 44 | highest_lines=1, 45 | total_sample=2, 46 | sample_types=[SampleType("goroutine", "count")], 47 | id_store={ 48 | 0: root, 49 | 1: s1, 50 | 2: s2, 51 | 3: s3, 52 | }, 53 | ) 54 | assert p.lines == [[root], [s1], [s2, s3]] 55 | 56 | 57 | def test_frame(): 58 | f1 = Frame("foo", 12) 59 | f2 = Frame("bar", 12) 60 | 61 | assert f1 == f2 62 | assert f1 != Frame("a", 13) 63 | assert f1 != 123 64 | 65 | f4 = Frame("has_child", 14, [f1]) 66 | assert f4.children == [f1] 67 | 68 | assert str(f1) == "" 69 | 70 | 71 | def test_frame_get_color(): 72 | with patch("flameshow.models.r") as mock_r: 73 | mock_r.get_color.return_value = "#1122ff" 74 | f1 = Frame("foo", 12) 75 | assert f1.display_color == "#1122ff" 76 | mock_r.get_color.assert_called_with("foo") 77 | 78 | 79 | def test_frame_get_color_full_model_path(): 80 | with patch("flameshow.models.r") as mock_r: 81 | mock_r.get_color.return_value = "#1122ff" 82 | f1 = PprofFrame( 83 | "github.com/prometheus/common/expfmt.MetricFamilyToText.func1", 12 84 | ) 85 | assert f1.display_color == "#1122ff" 86 | mock_r.get_color.assert_called_with("expfmt") 87 | -------------------------------------------------------------------------------- /tests/test_pprof_parse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/test_pprof_parse/__init__.py -------------------------------------------------------------------------------- /tests/test_pprof_parse/test_golang_pprof.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from flameshow.models import Frame, Profile, SampleType 4 | 5 | from flameshow.pprof_parser.parser import ProfileParser 6 | from flameshow.pprof_parser.parser import ( 7 | Function, 8 | Line, 9 | Location, 10 | Mapping, 11 | ProfileParser, 12 | get_frame_tree, 13 | parse_profile, 14 | unmarshal, 15 | ) 16 | 17 | from ..utils import create_frame 18 | 19 | 20 | def test_python_protobuf_goroutine_check_frame_tree(goroutine_pprof, data_dir): 21 | profile = parse_profile(goroutine_pprof, "profile10s.out") 22 | frame_tree = get_frame_tree(profile.root_stack) 23 | 24 | with open(data_dir / "goroutine_frametree.json") as f: 25 | expected = json.load(f) 26 | 27 | assert frame_tree == expected 28 | 29 | 30 | def test_golang_goroutine_parse_using_protobuf(goroutine_pprof): 31 | profile = parse_profile(goroutine_pprof, "goroutine.out") 32 | assert len(profile.sample_types) == 1 33 | 34 | st = profile.sample_types[0] 35 | assert st.sample_type == "goroutine" 36 | assert st.sample_unit == "count" 37 | 38 | assert profile.created_at == datetime.datetime( 39 | 2023, 9, 9, 7, 8, 29, 664363, tzinfo=datetime.timezone.utc 40 | ) 41 | 42 | assert profile.period_type.sample_type == "goroutine" 43 | assert profile.period_type.sample_unit == "count" 44 | assert profile.period == 1 45 | assert profile.default_sample_type_index == -1 46 | assert profile.highest_lines == 24 47 | 48 | 49 | def test_golang_profile10s_parse_using_protobuf(profile10s): 50 | profile = parse_profile(profile10s, "profile10s.out") 51 | assert len(profile.sample_types) == 2 52 | 53 | st = profile.sample_types[0] 54 | assert st.sample_type == "samples" 55 | assert st.sample_unit == "count" 56 | st = profile.sample_types[1] 57 | assert st.sample_type == "cpu" 58 | assert st.sample_unit == "nanoseconds" 59 | 60 | assert profile.created_at == datetime.datetime( 61 | 2023, 9, 9, 7, 8, 29, 866118, tzinfo=datetime.timezone.utc 62 | ) 63 | 64 | assert profile.period_type.sample_type == "cpu" 65 | assert profile.period_type.sample_unit == "nanoseconds" 66 | assert profile.period == 10000000 67 | 68 | assert profile.default_sample_type_index == -1 69 | 70 | 71 | def test_protobuf_parse_gorouting_mapping(goroutine_pprof): 72 | pb_binary = unmarshal(goroutine_pprof) 73 | parser = ProfileParser("goroutine.out") 74 | parser.parse_internal_data(pb_binary) 75 | assert parser.mappings == { 76 | 1: Mapping( 77 | id=1, 78 | memory_start=4194304, 79 | memory_limit=11280384, 80 | file_offset=0, 81 | filename="/usr/bin/node-exporter", 82 | build_id="", 83 | has_functions=True, 84 | has_filenames=False, 85 | has_line_numbers=False, 86 | has_inline_frames=False, 87 | ), 88 | 2: Mapping( 89 | id=2, 90 | memory_start=140721318682624, 91 | memory_limit=140721318690816, 92 | file_offset=0, 93 | filename="[vdso]", 94 | build_id="", 95 | has_functions=False, 96 | has_filenames=False, 97 | has_line_numbers=False, 98 | has_inline_frames=False, 99 | ), 100 | 3: Mapping( 101 | id=3, 102 | memory_start=18446744073699065856, 103 | memory_limit=18446744073699069952, 104 | file_offset=0, 105 | filename="[vsyscall]", 106 | build_id="", 107 | has_functions=False, 108 | has_filenames=False, 109 | has_line_numbers=False, 110 | has_inline_frames=False, 111 | ), 112 | } 113 | assert parser.locations[1] == Location( 114 | id=1, 115 | mapping=Mapping( 116 | id=1, 117 | memory_start=4194304, 118 | memory_limit=11280384, 119 | file_offset=0, 120 | filename="/usr/bin/node-exporter", 121 | build_id="", 122 | has_functions=True, 123 | has_filenames=False, 124 | has_line_numbers=False, 125 | has_inline_frames=False, 126 | ), 127 | address=4435364, 128 | lines=[ 129 | Line( 130 | line_no=336, 131 | function=Function( 132 | id=1, 133 | filename="/usr/local/go/src/runtime/proc.go", 134 | name="runtime.gopark", 135 | start_line=0, 136 | system_name="runtime.gopark", 137 | ), 138 | ) 139 | ], 140 | is_folded=False, 141 | ) 142 | assert parser.functions[1] == Function( 143 | id=1, 144 | filename="/usr/local/go/src/runtime/proc.go", 145 | name="runtime.gopark", 146 | start_line=0, 147 | system_name="runtime.gopark", 148 | ) 149 | 150 | 151 | def test_parser_get_name_aggr(): 152 | root = create_frame( 153 | { 154 | "id": 0, 155 | "values": [10], 156 | "children": [ 157 | {"id": 1, "values": [3], "children": []}, 158 | {"id": 2, "values": [4], "children": []}, 159 | ], 160 | } 161 | ) 162 | p = Profile( 163 | filename="abc", 164 | root_stack=root, 165 | highest_lines=1, 166 | total_sample=2, 167 | sample_types=[SampleType("goroutine", "count")], 168 | id_store={}, 169 | ) 170 | name_aggr = p.name_aggr 171 | assert name_aggr["node-0"] == [Frame("", 0)] 172 | assert name_aggr["node-1"] == [Frame("", 1)] 173 | assert name_aggr["node-2"] == [Frame("", 2)] 174 | 175 | 176 | def test_parser_get_name_aggr_with_nested(): 177 | root = create_frame( 178 | { 179 | "id": 0, 180 | "values": [10], 181 | "children": [ 182 | { 183 | "id": 1, 184 | "name": "foo", 185 | "values": [3], 186 | "children": [ 187 | { 188 | "id": 10, 189 | "values": [3], 190 | "children": [ 191 | { 192 | "id": 11, 193 | "values": [3], 194 | "children": [ 195 | { 196 | "id": 21, 197 | "values": [3], 198 | "children": [], 199 | "name": "bar", 200 | }, 201 | ], 202 | "name": "foo", 203 | }, 204 | ], 205 | }, 206 | ], 207 | }, 208 | {"id": 2, "values": [4], "children": [], "name": "foo"}, 209 | ], 210 | } 211 | ) 212 | 213 | p = Profile( 214 | filename="abc", 215 | root_stack=root, 216 | highest_lines=1, 217 | total_sample=2, 218 | sample_types=[SampleType("goroutine", "count")], 219 | id_store={}, 220 | ) 221 | 222 | name_aggr = p.name_aggr 223 | assert name_aggr["node-0"] == [Frame("", 0)] 224 | assert name_aggr["foo"] == [Frame("", 1), Frame("", 2)] 225 | assert name_aggr["bar"] == [Frame("", 21)] 226 | 227 | 228 | def test_parser_get_name_aggr_with_previous_occrance(): 229 | root = create_frame( 230 | { 231 | "id": 0, 232 | "values": [10], 233 | "children": [ 234 | { 235 | "id": 1, 236 | "name": "foo", 237 | "values": [3], 238 | "children": [], 239 | }, 240 | { 241 | "id": 2, 242 | "values": [4], 243 | "children": [ 244 | { 245 | "id": 3, 246 | "name": "foo", 247 | "values": [2], 248 | "children": [], 249 | }, 250 | ], 251 | "name": "bar", 252 | }, 253 | ], 254 | } 255 | ) 256 | 257 | p = Profile( 258 | filename="abc", 259 | root_stack=root, 260 | highest_lines=1, 261 | total_sample=2, 262 | sample_types=[SampleType("goroutine", "count")], 263 | id_store={}, 264 | ) 265 | 266 | name_aggr = p.name_aggr 267 | assert name_aggr["foo"] == [Frame("", 1), Frame("", 3)] 268 | -------------------------------------------------------------------------------- /tests/test_profile_parser.py: -------------------------------------------------------------------------------- 1 | from flameshow.pprof_parser.parser import Line, PprofFrame, Frame 2 | 3 | 4 | def test_pile_up(): 5 | root = Frame("root", 0, values=[5]) 6 | s1 = Frame("s1", 1, values=[4], parent=root) 7 | s2 = Frame("s2", 2, values=[4], parent=s1) 8 | 9 | root.children = [s1] 10 | s1.children = [s2] 11 | 12 | s1 = Frame("s1", 3, values=[3], parent=None) 13 | s2 = Frame("s2", 4, values=[2], parent=s1) 14 | s1.children = [s2] 15 | 16 | root.pile_up(s1) 17 | assert root.children[0].values == [7] 18 | assert root.children[0].children[0].values == [6] 19 | 20 | 21 | def test_render_detail_when_parent_zero(): 22 | root = PprofFrame("root", 0, values=[0]) 23 | s1 = PprofFrame("s1", 1, values=[0], parent=root, root=root) 24 | s1.line = Line() 25 | s1.line.function.name = "asdf" 26 | 27 | detail = s1.render_detail(0, "bytes") 28 | assert "asdf: 0.0B" in str(detail) 29 | -------------------------------------------------------------------------------- /tests/test_render/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/test_render/__init__.py -------------------------------------------------------------------------------- /tests/test_render/test_app.py: -------------------------------------------------------------------------------- 1 | from textual.geometry import Size 2 | from flameshow.models import Frame, Profile, SampleType 3 | from flameshow.render.app import FlameGraphScroll, FlameshowApp 4 | from unittest.mock import MagicMock, patch, PropertyMock 5 | 6 | 7 | def test_flamegraph_container_scroll(): 8 | def _test_scroll(line_no, height, expected_center): 9 | fc = FlameGraphScroll() 10 | size = MagicMock() 11 | size.height = height 12 | 13 | with patch( 14 | "flameshow.render.app.FlameGraphScroll.size", 15 | new_callable=PropertyMock, 16 | ) as mock_size: 17 | mock_size.return_value = Size(0, height) 18 | to_line = fc.scroll_to_make_line_center(line_no) 19 | 20 | assert to_line == expected_center 21 | 22 | _test_scroll(line_no=0, height=10, expected_center=0) 23 | _test_scroll(line_no=3, height=10, expected_center=0) 24 | _test_scroll(line_no=4, height=10, expected_center=0) 25 | _test_scroll(line_no=5, height=10, expected_center=0) 26 | _test_scroll(line_no=6, height=10, expected_center=1) 27 | _test_scroll(line_no=10, height=10, expected_center=5) 28 | _test_scroll(line_no=20, height=10, expected_center=15) 29 | 30 | 31 | def test_app_set_title_after_mount(): 32 | r = Frame("root", 0) 33 | p = Profile( 34 | filename="abc", 35 | root_stack=r, 36 | highest_lines=1, 37 | total_sample=2, 38 | sample_types=[SampleType("goroutine", "count")], 39 | id_store={}, 40 | ) 41 | app = FlameshowApp(p) 42 | app.on_mount() 43 | assert app.title == "flameshow" 44 | assert app.sub_title.startswith("v") 45 | assert app.view_frame == r 46 | assert app.sample_unit == "count" 47 | -------------------------------------------------------------------------------- /tests/test_render/test_flamegraph.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | from textual.events import MouseMove 5 | 6 | from flameshow.exceptions import RenderException 7 | from flameshow.models import Frame 8 | from flameshow.pprof_parser.parser import Profile, SampleType 9 | from flameshow.render.flamegraph import FlameGraph, FrameMap, add_array 10 | 11 | from ..utils import create_frame 12 | 13 | 14 | def test_flamegraph_generate_frame_maps_parents_with_only_child(): 15 | root = Frame("root", 0, values=[5]) 16 | s1 = Frame("s1", 1, values=[4], parent=root) 17 | s2 = Frame("s2", 2, values=[2], parent=s1) 18 | 19 | root.children = [s1] 20 | s1.children = [s2] 21 | 22 | p = Profile( 23 | filename="abc", 24 | root_stack=root, 25 | highest_lines=1, 26 | total_sample=2, 27 | sample_types=[SampleType("goroutine", "count")], 28 | id_store={ 29 | 0: root, 30 | 1: s1, 31 | 2: s2, 32 | }, 33 | ) 34 | flamegraph_widget = FlameGraph(p, 0, -1, 0) 35 | 36 | # focus on 0 root 37 | frame_maps = flamegraph_widget.generate_frame_maps(20, 0) 38 | 39 | assert frame_maps == { 40 | 0: [FrameMap(offset=0, width=20)], 41 | 1: [FrameMap(offset=0, width=16)], 42 | 2: [FrameMap(offset=0, width=8)], 43 | } 44 | 45 | 46 | def test_add_array(): 47 | assert add_array([1, 2, 3], [4, 5, 6]) == [5, 7, 9] 48 | 49 | 50 | def test_flamegraph_generate_frame_maps(): 51 | root = Frame("root", 0, values=[5]) 52 | s1 = Frame("s1", 1, values=[4], parent=root) 53 | s2 = Frame("s2", 2, values=[1], parent=s1) 54 | s3 = Frame("s3", 3, values=[2], parent=s1) 55 | 56 | root.children = [s1] 57 | s1.children = [s2, s3] 58 | 59 | p = Profile( 60 | filename="abc", 61 | root_stack=root, 62 | highest_lines=1, 63 | total_sample=2, 64 | sample_types=[SampleType("goroutine", "count")], 65 | id_store={ 66 | 0: root, 67 | 1: s1, 68 | 2: s2, 69 | 3: s3, 70 | }, 71 | ) 72 | flamegraph_widget = FlameGraph(p, 0, -1, 0) 73 | 74 | # focus on 0 root 75 | frame_maps = flamegraph_widget.generate_frame_maps(20, 1) 76 | 77 | assert frame_maps == { 78 | 0: [FrameMap(offset=0, width=20)], 79 | 1: [FrameMap(offset=0, width=20)], 80 | 2: [FrameMap(offset=0, width=5)], 81 | 3: [FrameMap(offset=5, width=10)], 82 | } 83 | 84 | # focus on 1 85 | frame_maps = flamegraph_widget.generate_frame_maps(20, 1) 86 | 87 | assert frame_maps == { 88 | 0: [FrameMap(offset=0, width=20)], 89 | 1: [FrameMap(offset=0, width=20)], 90 | 2: [FrameMap(offset=0, width=5)], 91 | 3: [FrameMap(offset=5, width=10)], 92 | } 93 | 94 | 95 | def test_flamegraph_generate_frame_maps_child_width_0(): 96 | root = Frame("root", 0, values=[5]) 97 | s1 = Frame("s1", 1, values=[4], parent=root) 98 | s2 = Frame("s2", 2, values=[0], parent=s1) 99 | 100 | root.children = [s1] 101 | s1.children = [s2] 102 | 103 | p = Profile( 104 | filename="abc", 105 | root_stack=root, 106 | highest_lines=1, 107 | total_sample=2, 108 | sample_types=[SampleType("goroutine", "count")], 109 | id_store={ 110 | 0: root, 111 | 1: s1, 112 | 2: s2, 113 | }, 114 | ) 115 | flamegraph_widget = FlameGraph(p, 0, -1, 0) 116 | 117 | # focus on 0 root 118 | frame_maps = flamegraph_widget.generate_frame_maps(20, 1) 119 | 120 | assert frame_maps == { 121 | 1: [FrameMap(offset=0, width=20)], 122 | 0: [FrameMap(offset=0, width=20)], 123 | 2: [FrameMap(offset=0, width=00)], 124 | } 125 | 126 | 127 | def test_flamegraph_render_line(): 128 | root = Frame("root", 0, values=[10]) 129 | s1 = Frame("s1", 1, values=[4], parent=root) 130 | s2 = Frame("s2", 2, values=[3], parent=root) 131 | 132 | root.children = [s1, s2] 133 | 134 | p = Profile( 135 | filename="abc", 136 | root_stack=root, 137 | highest_lines=1, 138 | total_sample=2, 139 | sample_types=[SampleType("goroutine", "count")], 140 | id_store={ 141 | 0: root, 142 | 1: s1, 143 | 2: s2, 144 | }, 145 | ) 146 | flamegraph_widget = FlameGraph(p, 0, -1, root) 147 | flamegraph_widget.frame_maps = flamegraph_widget.generate_frame_maps( 148 | 10, focused_stack_id=0 149 | ) 150 | 151 | strip = flamegraph_widget.render_line( 152 | 1, 153 | ) 154 | 155 | line_strings = [seg.text for seg in strip._segments] 156 | 157 | assert line_strings == ["▏", "s1 ", "▏", "s2"] 158 | 159 | 160 | def test_flamegraph_render_line_without_init(): 161 | root = Frame("root", 0, values=[10]) 162 | s1 = Frame("s1", 1, values=[4], parent=root) 163 | s2 = Frame("s2", 2, values=[3], parent=root) 164 | 165 | root.children = [s1, s2] 166 | 167 | p = Profile( 168 | filename="abc", 169 | root_stack=root, 170 | highest_lines=1, 171 | total_sample=2, 172 | sample_types=[SampleType("goroutine", "count")], 173 | id_store={ 174 | 0: root, 175 | 1: s1, 176 | 2: s2, 177 | }, 178 | ) 179 | flamegraph_widget = FlameGraph(p, 0, -1, 0) 180 | 181 | with pytest.raises(RenderException): 182 | flamegraph_widget.render_line( 183 | 1, 184 | ) 185 | 186 | 187 | def test_flamegraph_action_zoom_in_zoom_out(): 188 | root = Frame("root", 123, values=[5]) 189 | s1 = Frame("s1", 42, values=[1]) 190 | 191 | p = Profile( 192 | filename="abc", 193 | root_stack=root, 194 | highest_lines=1, 195 | total_sample=2, 196 | sample_types=[SampleType("goroutine", "count")], 197 | id_store={}, 198 | ) 199 | flamegraph_widget = FlameGraph(p, 0, -1, 0) 200 | flamegraph_widget.focused_stack_id = 333 201 | 202 | flamegraph_widget.action_zoom_out() 203 | 204 | assert flamegraph_widget.focused_stack_id == 123 205 | 206 | flamegraph_widget.view_frame = s1 207 | flamegraph_widget.action_zoom_in() 208 | assert flamegraph_widget.focused_stack_id == 42 209 | 210 | 211 | def test_flamegraph_action_move_down(): 212 | root = create_frame( 213 | { 214 | "id": 0, 215 | "values": [10], 216 | "children": [ 217 | {"id": 1, "values": [3], "children": []}, 218 | {"id": 2, "values": [4], "children": []}, 219 | ], 220 | } 221 | ) 222 | 223 | p = Profile( 224 | filename="abc", 225 | root_stack=root, 226 | highest_lines=10, 227 | total_sample=10, 228 | sample_types=[SampleType("goroutine", "count")], 229 | id_store={}, 230 | ) 231 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=root) 232 | flamegraph_widget.post_message = MagicMock() 233 | flamegraph_widget.action_move_down() 234 | 235 | flamegraph_widget.post_message.assert_called_once() 236 | args = flamegraph_widget.post_message.call_args[0] 237 | message = args[0] 238 | assert message.by_mouse == False 239 | assert message.frame._id == 2 240 | 241 | assert str(message) == "ViewFrameChanged(self.frame=)" 242 | 243 | 244 | def test_flamegraph_action_move_down_no_more_children(): 245 | root = create_frame( 246 | { 247 | "id": 0, 248 | "values": [10], 249 | "children": [], 250 | } 251 | ) 252 | 253 | p = Profile( 254 | filename="abc", 255 | root_stack=root, 256 | highest_lines=10, 257 | total_sample=10, 258 | sample_types=[SampleType("goroutine", "count")], 259 | id_store={}, 260 | ) 261 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=root) 262 | flamegraph_widget.post_message = MagicMock() 263 | flamegraph_widget.action_move_down() 264 | 265 | flamegraph_widget.post_message.assert_not_called() 266 | 267 | 268 | def test_flamegraph_action_move_down_children_is_zero(): 269 | root = create_frame( 270 | { 271 | "id": 0, 272 | "values": [10], 273 | "children": [ 274 | {"id": 1, "values": [0], "children": []}, 275 | ], 276 | } 277 | ) 278 | 279 | p = Profile( 280 | filename="abc", 281 | root_stack=root, 282 | highest_lines=10, 283 | total_sample=10, 284 | sample_types=[SampleType("goroutine", "count")], 285 | id_store={}, 286 | ) 287 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=root) 288 | flamegraph_widget.post_message = MagicMock() 289 | flamegraph_widget.action_move_down() 290 | 291 | flamegraph_widget.post_message.assert_called_once() 292 | args = flamegraph_widget.post_message.call_args[0] 293 | message = args[0] 294 | assert message.by_mouse == False 295 | assert message.frame._id == 1 296 | 297 | 298 | def test_flamegraph_action_move_up(): 299 | id_store = {} 300 | root = create_frame( 301 | { 302 | "id": 0, 303 | "values": [10], 304 | "children": [ 305 | { 306 | "id": 1, 307 | "values": [2], 308 | "children": [ 309 | {"id": 3, "values": [1], "children": []}, 310 | ], 311 | }, 312 | {"id": 2, "values": [3], "children": []}, 313 | ], 314 | }, 315 | id_store, 316 | ) 317 | 318 | p = Profile( 319 | filename="abc", 320 | root_stack=root, 321 | highest_lines=10, 322 | total_sample=10, 323 | sample_types=[SampleType("goroutine", "count")], 324 | id_store=id_store, 325 | ) 326 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[3]) 327 | flamegraph_widget.post_message = MagicMock() 328 | flamegraph_widget.action_move_up() 329 | 330 | flamegraph_widget.post_message.assert_called_once() 331 | args = flamegraph_widget.post_message.call_args[0] 332 | message = args[0] 333 | assert message.by_mouse == False 334 | assert message.frame._id == 1 335 | 336 | # move up but no more parents 337 | flamegraph_widget.post_message = MagicMock() 338 | flamegraph_widget.view_frame = root 339 | flamegraph_widget.action_move_up() 340 | flamegraph_widget.post_message.assert_not_called() 341 | 342 | 343 | def test_flamegraph_action_move_right_sibling_just_here(): 344 | id_store = {} 345 | root = create_frame( 346 | { 347 | "id": 0, 348 | "values": [10], 349 | "children": [ 350 | { 351 | "id": 1, 352 | "values": [2], 353 | "children": [], 354 | }, 355 | {"id": 2, "values": [3], "children": []}, 356 | ], 357 | }, 358 | id_store, 359 | ) 360 | 361 | p = Profile( 362 | filename="abc", 363 | root_stack=root, 364 | highest_lines=10, 365 | total_sample=10, 366 | sample_types=[SampleType("goroutine", "count")], 367 | id_store=id_store, 368 | ) 369 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[1]) 370 | flamegraph_widget.post_message = MagicMock() 371 | flamegraph_widget.action_move_right() 372 | 373 | flamegraph_widget.post_message.assert_called_once() 374 | args = flamegraph_widget.post_message.call_args[0] 375 | message = args[0] 376 | assert message.by_mouse == False 377 | assert message.frame._id == 2 378 | 379 | # no more sibling 380 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[2]) 381 | flamegraph_widget.post_message = MagicMock() 382 | flamegraph_widget.action_move_right() 383 | 384 | flamegraph_widget.post_message.assert_not_called() 385 | 386 | 387 | def test_flamegraph_action_move_right_sibling_goes_to_parent(): 388 | id_store = {} 389 | root = create_frame( 390 | { 391 | "id": 0, 392 | "values": [10], 393 | "children": [ 394 | { 395 | "id": 1, 396 | "values": [2], 397 | "children": [ 398 | {"id": 3, "values": [1], "children": []}, 399 | ], 400 | }, 401 | {"id": 2, "values": [3], "children": []}, 402 | ], 403 | }, 404 | id_store, 405 | ) 406 | 407 | p = Profile( 408 | filename="abc", 409 | root_stack=root, 410 | highest_lines=10, 411 | total_sample=10, 412 | sample_types=[SampleType("goroutine", "count")], 413 | id_store=id_store, 414 | ) 415 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[3]) 416 | flamegraph_widget.post_message = MagicMock() 417 | flamegraph_widget.action_move_right() 418 | 419 | flamegraph_widget.post_message.assert_called_once() 420 | args = flamegraph_widget.post_message.call_args[0] 421 | message = args[0] 422 | assert message.by_mouse == False 423 | assert message.frame._id == 2 424 | 425 | 426 | def test_flamegraph_action_move_right_on_root(): 427 | id_store = {} 428 | root = create_frame( 429 | { 430 | "id": 0, 431 | "values": [10], 432 | "children": [ 433 | { 434 | "id": 1, 435 | "values": [2], 436 | "children": [ 437 | {"id": 3, "values": [1], "children": []}, 438 | ], 439 | }, 440 | {"id": 2, "values": [3], "children": []}, 441 | ], 442 | }, 443 | id_store, 444 | ) 445 | 446 | p = Profile( 447 | filename="abc", 448 | root_stack=root, 449 | highest_lines=10, 450 | total_sample=10, 451 | sample_types=[SampleType("goroutine", "count")], 452 | id_store=id_store, 453 | ) 454 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[0]) 455 | flamegraph_widget.post_message = MagicMock() 456 | flamegraph_widget.action_move_right() 457 | 458 | flamegraph_widget.post_message.assert_not_called() 459 | 460 | 461 | def test_flamegraph_action_move_left_sibling_just_here(): 462 | id_store = {} 463 | root = create_frame( 464 | { 465 | "id": 0, 466 | "values": [10], 467 | "children": [ 468 | {"id": 2, "values": [3], "children": []}, 469 | { 470 | "id": 1, 471 | "values": [2], 472 | "children": [], 473 | }, 474 | ], 475 | }, 476 | id_store, 477 | ) 478 | 479 | p = Profile( 480 | filename="abc", 481 | root_stack=root, 482 | highest_lines=10, 483 | total_sample=10, 484 | sample_types=[SampleType("goroutine", "count")], 485 | id_store=id_store, 486 | ) 487 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[1]) 488 | flamegraph_widget.post_message = MagicMock() 489 | flamegraph_widget.action_move_left() 490 | 491 | flamegraph_widget.post_message.assert_called_once() 492 | args = flamegraph_widget.post_message.call_args[0] 493 | message = args[0] 494 | assert message.by_mouse == False 495 | assert message.frame._id == 2 496 | 497 | # no more sibling 498 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[2]) 499 | flamegraph_widget.post_message = MagicMock() 500 | flamegraph_widget.action_move_left() 501 | 502 | flamegraph_widget.post_message.assert_not_called() 503 | 504 | 505 | def test_flamegraph_action_move_left_sibling_goes_to_parent(): 506 | id_store = {} 507 | root = create_frame( 508 | { 509 | "id": 0, 510 | "values": [10], 511 | "children": [ 512 | {"id": 2, "values": [3], "children": []}, 513 | { 514 | "id": 1, 515 | "values": [2], 516 | "children": [ 517 | {"id": 3, "values": [1], "children": []}, 518 | ], 519 | }, 520 | ], 521 | }, 522 | id_store, 523 | ) 524 | 525 | p = Profile( 526 | filename="abc", 527 | root_stack=root, 528 | highest_lines=10, 529 | total_sample=10, 530 | sample_types=[SampleType("goroutine", "count")], 531 | id_store=id_store, 532 | ) 533 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[3]) 534 | flamegraph_widget.post_message = MagicMock() 535 | flamegraph_widget.action_move_left() 536 | 537 | flamegraph_widget.post_message.assert_called_once() 538 | args = flamegraph_widget.post_message.call_args[0] 539 | message = args[0] 540 | assert message.by_mouse == False 541 | assert message.frame._id == 2 542 | 543 | 544 | def test_flamegraph_action_move_left_on_root(): 545 | id_store = {} 546 | root = create_frame( 547 | { 548 | "id": 0, 549 | "values": [10], 550 | "children": [ 551 | { 552 | "id": 1, 553 | "values": [2], 554 | "children": [ 555 | {"id": 3, "values": [1], "children": []}, 556 | ], 557 | }, 558 | {"id": 2, "values": [3], "children": []}, 559 | ], 560 | }, 561 | id_store, 562 | ) 563 | 564 | p = Profile( 565 | filename="abc", 566 | root_stack=root, 567 | highest_lines=10, 568 | total_sample=10, 569 | sample_types=[SampleType("goroutine", "count")], 570 | id_store=id_store, 571 | ) 572 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[0]) 573 | flamegraph_widget.post_message = MagicMock() 574 | flamegraph_widget.action_move_left() 575 | 576 | flamegraph_widget.post_message.assert_not_called() 577 | 578 | 579 | def test_flamegraph_render_on_mouse_move(): 580 | id_store = {} 581 | # 10 582 | # 3, 2 583 | # , 1 584 | root = create_frame( 585 | { 586 | "id": 0, 587 | "values": [10], 588 | "children": [ 589 | {"id": 2, "values": [3], "children": []}, 590 | { 591 | "id": 1, 592 | "values": [2], 593 | "children": [ 594 | {"id": 3, "values": [1], "children": []}, 595 | ], 596 | }, 597 | ], 598 | }, 599 | id_store, 600 | ) 601 | 602 | p = Profile( 603 | filename="abc", 604 | root_stack=root, 605 | highest_lines=10, 606 | total_sample=10, 607 | sample_types=[SampleType("goroutine", "count")], 608 | id_store=id_store, 609 | ) 610 | flamegraph_widget = FlameGraph(p, 0, -1, view_frame=id_store[3]) 611 | flamegraph_widget.frame_maps = flamegraph_widget.generate_frame_maps(10, 0) 612 | flamegraph_widget.post_message = MagicMock() 613 | 614 | mouse_event = MouseMove( 615 | x=2, 616 | y=1, 617 | delta_x=0, 618 | delta_y=0, 619 | button=False, 620 | shift=False, 621 | meta=False, 622 | ctrl=False, 623 | ) 624 | flamegraph_widget.on_mouse_move(mouse_event) 625 | 626 | flamegraph_widget.post_message.assert_called_once() 627 | args = flamegraph_widget.post_message.call_args[0] 628 | message = args[0] 629 | assert message.by_mouse == True 630 | assert message.frame._id == 2 631 | 632 | assert flamegraph_widget.focused_stack_id == 0 633 | flamegraph_widget.handle_click_frame(mouse_event) 634 | assert flamegraph_widget.focused_stack_id == 2 635 | 636 | # move to lines that empty 637 | flamegraph_widget.post_message = MagicMock() 638 | flamegraph_widget.on_mouse_move( 639 | MouseMove( 640 | x=1, 641 | y=2, 642 | delta_x=0, 643 | delta_y=0, 644 | button=False, 645 | shift=False, 646 | meta=False, 647 | ctrl=False, 648 | ) 649 | ) 650 | args = flamegraph_widget.post_message.assert_not_called() 651 | 652 | # just to move the the exact offset, should still work 653 | # should be hover on next span instead of last 654 | flamegraph_widget.post_message = MagicMock() 655 | flamegraph_widget.on_mouse_move( 656 | MouseMove( 657 | x=3, 658 | y=1, 659 | delta_x=0, 660 | delta_y=0, 661 | button=False, 662 | shift=False, 663 | meta=False, 664 | ctrl=False, 665 | ) 666 | ) 667 | flamegraph_widget.post_message.assert_called_once() 668 | args = flamegraph_widget.post_message.call_args[0] 669 | message = args[0] 670 | assert message.by_mouse == True 671 | assert message.frame._id == 1 672 | 673 | # move down, not hover on any lines 674 | flamegraph_widget.post_message = MagicMock() 675 | flamegraph_widget.on_mouse_move( 676 | MouseMove( 677 | x=0, 678 | y=3, 679 | delta_x=0, 680 | delta_y=0, 681 | button=False, 682 | shift=False, 683 | meta=False, 684 | ctrl=False, 685 | ) 686 | ) 687 | args = flamegraph_widget.post_message.assert_not_called() 688 | 689 | 690 | def test_flamegraph_render_line_with_some_width_is_0(): 691 | id_store = {} 692 | root = create_frame( 693 | { 694 | "id": 0, 695 | "values": [10], 696 | "children": [ 697 | {"id": 2, "values": [3], "children": []}, 698 | { 699 | "id": 1, 700 | "values": [2], 701 | "children": [ 702 | {"id": 3, "values": [1], "children": []}, 703 | ], 704 | }, 705 | {"id": 4, "values": [0.1], "children": []}, 706 | ], 707 | }, 708 | id_store, 709 | ) 710 | 711 | p = Profile( 712 | filename="abc", 713 | root_stack=root, 714 | highest_lines=1, 715 | total_sample=2, 716 | sample_types=[SampleType("samples", "count")], 717 | id_store=id_store, 718 | ) 719 | flamegraph_widget = FlameGraph(p, 0, -1, root) 720 | flamegraph_widget.frame_maps = flamegraph_widget.generate_frame_maps( 721 | 10, focused_stack_id=0 722 | ) 723 | 724 | strip = flamegraph_widget.render_line( 725 | 1, 726 | ) 727 | 728 | line_strings = [seg.text for seg in strip._segments] 729 | 730 | assert line_strings == ["▏", "no", "▏", "n"] 731 | 732 | 733 | def test_flamegraph_render_line_with_focused_frame(): 734 | id_store = {} 735 | root = create_frame( 736 | { 737 | "id": 0, 738 | "values": [10], 739 | "children": [ 740 | {"id": 1, "values": [3], "children": []}, 741 | {"id": 4, "values": [1], "children": []}, 742 | { 743 | "id": 2, 744 | "values": [6], 745 | "children": [ 746 | {"id": 3, "values": [1], "children": []}, 747 | ], 748 | }, 749 | ], 750 | }, 751 | id_store, 752 | ) 753 | 754 | p = Profile( 755 | filename="abc", 756 | root_stack=root, 757 | highest_lines=1, 758 | total_sample=2, 759 | sample_types=[SampleType("samples", "count")], 760 | id_store=id_store, 761 | ) 762 | flamegraph_widget = FlameGraph(p, 2, -1, root) 763 | flamegraph_widget.frame_maps = flamegraph_widget.generate_frame_maps( 764 | 10, focused_stack_id=2 765 | ) 766 | 767 | strip = flamegraph_widget.render_line( 768 | 1, 769 | ) 770 | 771 | line_strings = [seg.text for seg in strip._segments] 772 | 773 | assert line_strings == ["▏", "node-2 "] 774 | 775 | flamegraph_widget.post_message = MagicMock() 776 | 777 | flamegraph_widget.on_mouse_move( 778 | MouseMove( 779 | x=0, 780 | y=1, 781 | delta_x=0, 782 | delta_y=0, 783 | button=False, 784 | shift=False, 785 | meta=False, 786 | ctrl=False, 787 | ) 788 | ) 789 | 790 | flamegraph_widget.post_message.assert_called_once() 791 | args = flamegraph_widget.post_message.call_args[0] 792 | message = args[0] 793 | assert message.by_mouse == True 794 | assert message.frame._id == 2 795 | -------------------------------------------------------------------------------- /tests/test_render/test_render_detail.py: -------------------------------------------------------------------------------- 1 | from flameshow.models import Profile 2 | from flameshow.render.framedetail import FrameStatAll 3 | from ..utils import create_frame 4 | 5 | 6 | def test_render_self_value_all_instance(): 7 | root = create_frame( 8 | { 9 | "id": 0, 10 | "values": [10], 11 | "children": [ 12 | {"id": 1, "values": [4], "children": []}, 13 | ], 14 | } 15 | ) 16 | profile = Profile("asdf", root, 0, 0, [], {}) 17 | widget = FrameStatAll(root, profile, 0) 18 | value = widget.frame_all_self_value 19 | assert value == 6 20 | 21 | child_frame = root.children[0] 22 | 23 | widget = FrameStatAll(child_frame, profile, 0) 24 | value = widget.frame_all_self_value 25 | assert value == 4 26 | -------------------------------------------------------------------------------- /tests/test_render/test_render_header.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text, Span 2 | from flameshow.render.header import ( 3 | HeaderIcon, 4 | HeaderTitle, 5 | HeaderOpenedFilename, 6 | FlameshowHeader, 7 | ) 8 | 9 | 10 | def test_header_icon(): 11 | hi = HeaderIcon() 12 | 13 | assert hi.render() == "🔥" 14 | 15 | 16 | def test_header_title(): 17 | ht = HeaderTitle() 18 | ht.text = "abc" 19 | ht.sub_text = "foo" 20 | 21 | assert ht.render() == Text("abc — foo", spans=[Span(6, 9, "dim")]) 22 | 23 | 24 | def test_header_opened_filename(): 25 | hf = HeaderOpenedFilename("goro.out") 26 | 27 | assert hf.render() == Text("goro.out") 28 | 29 | 30 | def test_flameshow_header(): 31 | fh = FlameshowHeader("foo.out") 32 | result = list(fh.compose()) 33 | 34 | assert len(result) == 2 35 | 36 | header_center_text = result[1]._nodes[1] 37 | 38 | assert isinstance(header_center_text, HeaderOpenedFilename) 39 | -------------------------------------------------------------------------------- /tests/test_stackcollapse_parse/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laixintao/flameshow/400c6eeccf0b6ff24b71978ba5d575fb5cdfe5a3/tests/test_stackcollapse_parse/__init__.py -------------------------------------------------------------------------------- /tests/test_stackcollapse_parse/test_model.py: -------------------------------------------------------------------------------- 1 | from rich.text import Text 2 | from flameshow.parsers.stackcollapse_parser import StackCollapseFrame 3 | 4 | 5 | def test_frame_model_render(): 6 | f = StackCollapseFrame( 7 | "java::abc", 1, parent=None, children=[], values=[13] 8 | ) 9 | render_result = f.render_one_frame_detail(f, 0, "count") 10 | assert len(render_result) == 1 11 | item = render_result[0] 12 | assert item.markup == "java::abc\n" 13 | 14 | 15 | def test_frame_model_render_with_square(): 16 | f = StackCollapseFrame("[abc]", 1, parent=None, children=[], values=[13]) 17 | render_result = f.render_one_frame_detail(f, 0, "count") 18 | assert len(render_result) == 1 19 | item = render_result[0] 20 | assert item.markup == "\\[abc]\n" 21 | 22 | title = f.title 23 | assert isinstance(title, Text) 24 | assert title.plain == "[abc]" 25 | -------------------------------------------------------------------------------- /tests/test_stackcollapse_parse/test_parser.py: -------------------------------------------------------------------------------- 1 | from flameshow.parsers.stackcollapse_parser import StackCollapseParser 2 | 3 | from ..utils import frame2json 4 | 5 | 6 | def test_parse_simple_text_data(simple_collapse_data): 7 | parser = StackCollapseParser("a.txt") 8 | profile = parser.parse(simple_collapse_data) 9 | assert profile.highest_lines == 3 10 | 11 | assert frame2json(profile.root_stack) == { 12 | "root": { 13 | "children": [ 14 | { 15 | "a": { 16 | "children": [ 17 | { 18 | "b": { 19 | "children": [ 20 | {"c": {"children": [], "values": [5]}}, 21 | {"d": {"children": [], "values": [4]}}, 22 | ], 23 | "values": [14], 24 | } 25 | } 26 | ], 27 | "values": [14], 28 | } 29 | } 30 | ], 31 | "values": [14], 32 | }, 33 | } 34 | 35 | assert [f.name for f in profile.id_store.values()] == [ 36 | "root", 37 | "a", 38 | "b", 39 | "c", 40 | "a", 41 | "b", 42 | "c", 43 | "a", 44 | "b", 45 | "d", 46 | "a", 47 | "b", 48 | "c", 49 | "a", 50 | "b", 51 | ] 52 | 53 | assert profile.root_stack.children[0].parent.name == "root" 54 | 55 | 56 | def test_validate_simple_text_data(simple_collapse_data): 57 | assert StackCollapseParser.validate(simple_collapse_data) 58 | 59 | 60 | def test_validate_text_data_with_comment(collapse_data_with_comment): 61 | assert StackCollapseParser.validate(collapse_data_with_comment) 62 | 63 | 64 | def test_validate_simple_text_data_not_utf8(simple_collapse_data): 65 | data = (simple_collapse_data.decode() + "你好").encode("big5") 66 | assert not StackCollapseParser.validate(data) 67 | 68 | 69 | def test_validate_simple_text_contains_empty(simple_collapse_data): 70 | data = (simple_collapse_data.decode() + "\r\n\r\n").encode() 71 | assert StackCollapseParser.validate(data) 72 | 73 | 74 | def test_validate_simple_text_data_contains_numbers_somtimes(): 75 | data = b""" 76 | a;b;c 10 77 | 5 78 | c 4 79 | 10 80 | """ 81 | assert StackCollapseParser.validate(data) 82 | 83 | 84 | def test_validate_simple_text_data_notmatch(): 85 | data = b""" 86 | a;b;c 10 87 | c 88 | """ 89 | assert not StackCollapseParser.validate(data) 90 | 91 | 92 | def test_validate_simple_text_data_contains_numbers_somtimes_parsing(): 93 | data = b""" 94 | a;b;c 10 95 | 5 96 | c 4 97 | 10 98 | """ 99 | profile = StackCollapseParser("a.txt").parse(data) 100 | assert frame2json(profile.root_stack) == { 101 | "root": { 102 | "children": [ 103 | { 104 | "a": { 105 | "children": [ 106 | { 107 | "b": { 108 | "children": [ 109 | {"c": {"children": [], "values": [10]}} 110 | ], 111 | "values": [10], 112 | } 113 | } 114 | ], 115 | "values": [10], 116 | } 117 | }, 118 | {"c": {"children": [], "values": [4]}}, 119 | ], 120 | "values": [14], 121 | } 122 | } 123 | 124 | 125 | def test_space_and_numbers_in_stackcollapse_symbols(): 126 | data = b""" 127 | a 1;b 2;c 3 10 128 | """ 129 | profile = StackCollapseParser("a.txt").parse(data) 130 | assert frame2json(profile.root_stack) == { 131 | "root": { 132 | "children": [ 133 | { 134 | "a 1": { 135 | "children": [ 136 | { 137 | "b 2": { 138 | "children": [ 139 | { 140 | "c 3": { 141 | "children": [], 142 | "values": [10], 143 | } 144 | } 145 | ], 146 | "values": [10], 147 | } 148 | } 149 | ], 150 | "values": [10], 151 | } 152 | } 153 | ], 154 | "values": [10], 155 | }, 156 | } 157 | 158 | 159 | def test_validate_simple_text_data_contains_comments(): 160 | data = b""" 161 | # austin: 3.7.0 162 | # interval: 100 163 | # mode: wall 164 | a;b;c 10 165 | 5 166 | c 4 167 | 10 168 | """ 169 | profile = StackCollapseParser("a.txt").parse(data) 170 | assert frame2json(profile.root_stack) == { 171 | "root": { 172 | "children": [ 173 | { 174 | "a": { 175 | "children": [ 176 | { 177 | "b": { 178 | "children": [ 179 | {"c": {"children": [], "values": [10]}} 180 | ], 181 | "values": [10], 182 | } 183 | } 184 | ], 185 | "values": [10], 186 | } 187 | }, 188 | {"c": {"children": [], "values": [4]}}, 189 | ], 190 | "values": [14], 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from flameshow.utils import sizeof 2 | 3 | 4 | def test_sizeof(): 5 | assert sizeof(1) == "1.0B" 6 | assert sizeof(2048) == "2.0KiB" 7 | assert ( 8 | sizeof(5.83 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024 * 1024) 9 | == "5.8YiB" 10 | ) 11 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from flameshow.models import Frame 2 | 3 | 4 | def create_frame(data, id_store=None): 5 | name = "node-{}".format(data["id"]) 6 | if "name" in data: 7 | name = data["name"] 8 | root = Frame( 9 | name=name, 10 | _id=data["id"], 11 | values=data["values"], 12 | ) 13 | root.children = [] 14 | for child in data["children"]: 15 | cf = create_frame(child, id_store) 16 | root.children.append(cf) 17 | cf.parent = root 18 | 19 | if id_store is not None: 20 | id_store[root._id] = root 21 | return root 22 | 23 | 24 | def frame2json(frame): 25 | data = { 26 | frame.name: { 27 | "values": frame.values, 28 | "children": [frame2json(c) for c in frame.children], 29 | } 30 | } 31 | return data 32 | --------------------------------------------------------------------------------