├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── brunch-config.js ├── config └── config.exs ├── lib ├── ex_debug_toolbar.ex ├── ex_debug_toolbar │ ├── application.ex │ ├── breakpoint.ex │ ├── breakpoint │ │ ├── client_node.ex │ │ ├── iex │ │ │ ├── server.ex │ │ │ └── shell.ex │ │ ├── pry.ex │ │ └── uuid.ex │ ├── collector │ │ ├── conn_collector.ex │ │ ├── ecto_collector.ex │ │ ├── instrumentation_collector.ex │ │ ├── logger_collector.ex │ │ └── template_collector.ex │ ├── config.ex │ ├── data │ │ ├── breakpoint_collection.ex │ │ ├── collection.ex │ │ ├── conn.ex │ │ ├── list.ex │ │ ├── log_entry.ex │ │ ├── map.ex │ │ └── timeline.ex │ ├── database.ex │ ├── database │ │ ├── request_repo.ex │ │ └── supervisor.ex │ ├── decorator │ │ └── noop.ex │ ├── docs.ex │ ├── endpoint.ex │ ├── logger.ex │ ├── phoenix.ex │ ├── plug │ │ ├── close_conn.ex │ │ ├── code_injector.ex │ │ ├── ignore_path_match.ex │ │ ├── pipeline.ex │ │ ├── remove_glob_params.ex │ │ ├── request_id.ex │ │ └── router.ex │ ├── poison │ │ └── encoder.ex │ ├── request.ex │ └── template │ │ ├── eex_engine.ex │ │ ├── exs_engine.ex │ │ └── slim_engine.ex └── mix │ └── tasks │ ├── breakpoint.client.ex │ └── compile.ex_debug_toolbar.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── static │ ├── css │ └── toolbar.css │ ├── fonts │ └── bootstrap │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── images │ └── logo.svg │ └── js │ └── toolbar.js ├── screenshots ├── breakpoint_session.png ├── breakpoints.png ├── conn_details.png ├── ecto_queries.png ├── history.png ├── history_loaded.png ├── logs.png ├── timings.png ├── toolbar.gif └── toolbar.png ├── test ├── channels │ ├── breakpoint_channel_test.exs │ └── toolbar_channel_test.exs ├── fixtures │ ├── endpoint.ex │ └── templates │ │ ├── eex_template.html.eex │ │ ├── exs_template.html.exs │ │ └── slim_template.html.slim ├── lib │ ├── ex_debug_toolbar │ │ ├── breakpoint │ │ │ └── uuid_test.exs │ │ ├── collector │ │ │ ├── conn_collector_test.exs │ │ │ ├── ecto_collector_test.exs │ │ │ ├── instrumentation_collector_test.exs │ │ │ ├── logger_collector_test.exs │ │ │ └── template_collector_test.exs │ │ ├── data │ │ │ ├── breakpoints_test.exs │ │ │ ├── conn_test.exs │ │ │ ├── list_test.exs │ │ │ ├── map_test.exs │ │ │ └── timeline_test.exs │ │ ├── database │ │ │ └── request_repo_test.exs │ │ ├── decorator │ │ │ └── noop_test.exs │ │ ├── logger_test.exs │ │ ├── phoenix_test.exs │ │ ├── plug │ │ │ ├── close_conn_test.exs │ │ │ ├── code_injector_test.exs │ │ │ ├── ignore_path_match_test.exs │ │ │ ├── remove_glob_params_test.exs │ │ │ └── request_id_test.exs │ │ └── poison │ │ │ └── encoder_test.exs │ └── ex_debug_toolbar_test.exs ├── support │ ├── channel_case.ex │ ├── collector_case.ex │ ├── conn_case.ex │ ├── data │ │ └── timeline_helpers.ex │ └── request_helpers.ex ├── test_helper.exs └── views │ ├── helpers │ └── time_helpers_test.exs │ └── toolbar_view_test.exs ├── web ├── channels │ ├── breakpoint_channel.ex │ ├── toolbar_channel.ex │ └── user_socket.ex ├── gettext.ex ├── router.ex ├── static │ ├── assets │ │ └── images │ │ │ └── logo.svg │ ├── css │ │ ├── _reset.scss │ │ ├── _text_emphasis.scss │ │ └── toolbar.scss │ └── js │ │ ├── toolbar.js │ │ └── toolbar │ │ ├── breakpoints_panel.js │ │ ├── history_panel.js │ │ ├── jquery.js │ │ └── logger.js ├── templates │ └── toolbar │ │ ├── show.html.eex │ │ └── show │ │ ├── _breakpoints.html.eex │ │ ├── _conn.html.eex │ │ ├── _ecto.html.eex │ │ ├── _history.html.eex │ │ ├── _logs.html.eex │ │ ├── _timeline.html.eex │ │ └── ecto │ │ └── _queries.html.eex ├── views │ ├── error_view.ex │ ├── helpers │ │ ├── error_helpers.ex │ │ └── time_helpers.ex │ └── toolbar_view.ex └── web.ex └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Static artifacts 11 | /node_modules 12 | 13 | # Since we are building assets from web/static, 14 | # we ignore priv/static. You may want to comment 15 | # this depending on your deployment strategy. 16 | /priv/static/**/*.map 17 | 18 | # The config/prod.secret.exs file by default contains sensitive 19 | # data and you should not commit it into version control. 20 | # 21 | # Alternatively, you may comment the line below and commit the 22 | # secrets file as long as you replace its contents by environment 23 | # variables. 24 | /config/prod.secret.exs 25 | /tmp 26 | /doc 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.6.4 4 | - 1.5.3 5 | - 1.4.5 6 | otp_release: 7 | - 20.0 8 | - 19.3.6 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Version 0.4.0 2 | * _(new)_ requests history panel 3 | * _(new)_ support Slim templates (`phoenix_slim` package) 4 | * _(new)_ `debug` config key to enable to verbose logs 5 | * _(new)_ `ignore_paths` config key to skip tracking certain requests by paths 6 | * _(improved)_ breakpoints no longer use distribution code 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis](https://img.shields.io/travis/kagux/ex_debug_toolbar.svg)](https://travis-ci.org/kagux/ex_debug_toolbar) 2 | [![Hex.pm](https://img.shields.io/hexpm/v/ex_debug_toolbar.svg)](https://hex.pm/packages/ex_debug_toolbar) 3 | 4 | A toolbar for Phoenix projects to display all sorts of information 5 | about current and previous requests: logs, timelines, database queries etc. 6 | 7 | Project is in its early stages and under active development. 8 | Contributions to code, feedback and suggestions will be much appreciated! 9 | 10 | 11 | ![Screencapture](screenshots/toolbar.gif) 12 | 13 | # Recent changes 14 | ### Version 0.5.0 15 | * _(new)_ phoenix 1.3 16 | * _(new)_ support bootstrap 4 17 | ### Version 0.4.0 18 | * _(new)_ requests history panel 19 | * _(new)_ support Slim templates (`phoenix_slim` package) 20 | * _(new)_ `debug` config key to enable to verbose logs 21 | * _(new)_ `ignore_paths` config key to skip tracking certain requests by paths 22 | * _(improved)_ breakpoints no longer use distribution code 23 | 24 | 25 | # Features 26 | Toolbar is built with development environment in mind. It's up to you to enable or disable it in configuration. 27 | Calls to toolbar functions such as `Toolbar.pry` are no-op when it is disabled. 28 | 29 | After enabling the toolbar, it automatically injects itself at the bottom of html pages. 30 | Some panels on the toolbar are optional and only appear when relevant data is available (ecto queries, for example). 31 | ![Toolbar](screenshots/toolbar.png) 32 | 33 | Let's take a look at available panels: 34 | 35 | ### Timings 36 | It shows overall time spent rendering current controller as reported by Phoenix instrumentation. 37 | In addition, it provides aggregated stats for each template. 38 | ![Timings](screenshots/timings.png) 39 | 40 | ### History 41 | A list of previous request. 42 | ![History](screenshots/history.png) 43 | Clicking on historical request loads it into toolbar so you can inspect it closer. 44 | ![History Loaded](screenshots/history_loaded.png) 45 | 46 | ### Connection details 47 | Surfaces information from `conn` struct of current request. 48 | ![Connection Details](screenshots/conn_details.png) 49 | 50 | ### Logs 51 | Log entries relevant to current request only 52 | ![Logs](screenshots/logs.png) 53 | 54 | ### Ecto queries 55 | A list of executed ecto queries including parallel preloads when possible. 56 | ![Ecto Queries](screenshots/ecto_queries.png) 57 | 58 | ### Breakpoints 59 | Think of having multiply `IEx.pry` breakpoints available on demand right from the toolbar. 60 | Note, unlike `IEx.pry`, this does not interfere with execution flow of phoenix server. 61 | 62 | Usage is similar to `IEx`. 63 | Drop `require ExDebugToolbar; ExDebugToolbar.pry` in a file you'd like to debug 64 | and breakpoint will appear in this panel. Breakpoints are capped at configurable number per 65 | request (10 by default). 66 | ![Breakpoints](screenshots/breakpoints.png) 67 | 68 | A click on any breakpoint will take you to familiar `iex` session with context as it was at execution time. 69 | ![Breakpoint Sesssion](screenshots/breakpoint_session.png) 70 | 71 | 72 | # Installation 73 | 1. Add `ex_debug_toolbar` to your list of dependencies in `mix.exs`: 74 | 75 | ```elixir 76 | def deps do 77 | [{:ex_debug_toolbar, "~> 0.4.0"}] 78 | end 79 | ``` 80 | 81 | 2. Ensure `:ex_debug_toolbar` is started before your application: 82 | 83 | ```elixir 84 | def application do 85 | [applications: [:ex_debug_toolbar, :logger]] 86 | end 87 | ``` 88 | 89 | 2. Add `ExDebugToolbar.Phoenix` to your endpoint in `lib/my_app/endpoint.ex` 90 | 91 | ```elixir 92 | defmodule MyApp.Endpoint do 93 | use Phoenix.Endpoint, otp_app: :my_app 94 | use ExDebugToolbar.Phoenix 95 | ... 96 | end 97 | ``` 98 | 99 | 3. Enable toolbar in config `config/dev.exs` and setup collectors. Replace `:my_app` and `MyApp` with your application name 100 | 101 | _Note_: Slim templates support requires [phoenix_slime](https://github.com/slime-lang/phoenix_slime) package 102 | 103 | ```elixir 104 | # ExDebugToolbar config 105 | config :ex_debug_toolbar, 106 | enable: true 107 | 108 | config :my_app, MyApp.Endpoint, 109 | instrumenters: [ExDebugToolbar.Collector.InstrumentationCollector] 110 | 111 | config :my_app, MyApp.Repo, 112 | loggers: [ExDebugToolbar.Collector.EctoCollector, Ecto.LogEntry] 113 | 114 | config :phoenix, :template_engines, 115 | eex: ExDebugToolbar.Template.EExEngine, 116 | exs: ExDebugToolbar.Template.ExsEngine, 117 | #slim: ExDebugToolbar.Template.SlimEngine, 118 | #slime: ExDebugToolbar.Template.SlimEngine 119 | 120 | ``` 121 | 122 | 4. To display parallel Ecto preloads you have to use `master` branch 123 | ```elixir 124 | defp deps do 125 | [ 126 | {:ecto, github: "elixir-ecto/ecto", branch: "master", override: true} 127 | ] 128 | end 129 | ``` 130 | 131 | # Configuration 132 | 133 | To change configuration, update `:ex_debug_toolbar` config key in your `config/dev.exs`. For example: 134 | ```elixir 135 | config :ex_debug_toolbar, 136 | enable: true 137 | ``` 138 | 139 | ### Available options: 140 | 141 | | Option | Values | Default | Description | 142 | |--------------------|---------|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------| 143 | | enable | boolean | false | Enable/disable toolbar. When disabled, toolbar code is not injected in page and toolbar functions are mostly no-op. | 144 | | iex_shell | string | "/bin/sh" | Shell executable to be used for breakpoint session | 145 | | iex_shell_cmd | string | "stty echo; clear; iex -S mix breakpoint.client --breakpoint-file %{breakpoint_file}" | Shell command to launch breakpoint iex session. `%{breakpoint_file}` is a placeholder for tmp file with data | 146 | | breakpoints_limit | integer | 10 | Maximum number of breakpoints per request. After reaching this cap, new breakpoints will be ignored | 147 | | remove_glob_params | boolean | true | `Plug.Router` adds `glob` params to `conn.params` and `conn.path_params` on `forward`. This option removes them | 148 | | ignore_paths | list | [~r{^/images/}, ~r{^/css/}, ~r{^/js/}, ~r{^/phoenix/live_reload/}] | A list of paths that should not be recorded by toolbar. Each item can be either string for exact match or a Regex. | 149 | | debug | boolean | false | Toggles debug logs. Requires recompilation for some logs | 150 | 151 | 152 | # Troubleshooting 153 | 154 | ### Poison encode issues 155 | 156 | If you see `poison` encode related errors in your logs: 157 | * update to latest `poison` package version 158 | * enable debug mode and open an issue with detailed logs 159 | 160 | ### Debug mode 161 | 162 | When enabled, toolbar prints debug logs. 163 | This information is very helpful for issues with encoding, missing requests, etc. 164 | 165 | Turn on debug mode 166 | 167 | ```elixir 168 | config :ex_debug_toolbar, 169 | enable: true, 170 | debug: true 171 | ``` 172 | 173 | Turn off `Logger` log truncation and put it into `debug` level 174 | 175 | ```elixir 176 | config :logger, 177 | level: :debug, 178 | truncate: :infinity 179 | ``` 180 | 181 | Recompile toolbar to see channel logs 182 | 183 | ``` 184 | mix deps.compile ex_debug_toolbar --force 185 | ``` 186 | 187 | 188 | # Contributors 189 | Special thanks goes to [Juan Peri](https://github.com/epilgrim)! 190 | 191 | # Contribution 192 | Contributions in the form of bug reports, pull requests, or thoughtful discussions in the GitHub issue tracker are welcome! 193 | 194 | # TODO 195 | - [ ] Toolbar panels 196 | - [ ] Messages output panel (Toolbar.inspect and Toolbar.puts) 197 | - [ ] System info panel (versions, vm info, etc) 198 | - [ ] Help/Docs Panel (links to dev resources) 199 | - [ ] Request time panel 200 | - [ ] Request history (historical graphs?) 201 | - [ ] Visualize timeline 202 | - [ ] Ajax requests panel 203 | - [ ] Channels info panel 204 | - [ ] Visualize gettext 205 | - [ ] Toolbar API 206 | - [ ] Decorator for functions to time them 207 | - [ ] Add metadata to events and use groupable names (template.render, controller.render etc) 208 | - [ ] Tests 209 | - [ ] breakpoints 210 | - [ ] client test 211 | - [ ] terminal test 212 | - [ ] Simple installer mix task 213 | - [ ] Upgrade to Phoenix 1.3 214 | - [ ] Elm/React instead of jquery? 215 | 216 | ## Demo App 217 | Use [demo app](https://github.com/kagux/ex_debug_toolbar_demo) to simplify development process. 218 | -------------------------------------------------------------------------------- /brunch-config.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | // See http://brunch.io/#documentation for docs. 3 | files: { 4 | javascripts: { 5 | joinTo: { 6 | "js/toolbar.js": /^(web\/static\/js\/toolbar)|^node_modules/, 7 | } 8 | }, 9 | stylesheets: { 10 | joinTo: { 11 | "css/toolbar.css": /^(web\/static\/css\/toolbar)|^node_modules/, 12 | }, 13 | order: { 14 | after: /prism/ 15 | } 16 | }, 17 | templates: { 18 | joinTo: "js/app.js" 19 | } 20 | }, 21 | 22 | conventions: { 23 | // This option sets where we should place non-css and non-js assets in. 24 | // By default, we set this to "/web/static/assets". Files in this directory 25 | // will be copied to `paths.public`, which is "priv/static" by default. 26 | assets: /^(web\/static\/assets)/ 27 | }, 28 | 29 | // Phoenix paths configuration 30 | paths: { 31 | // Dependencies and current project directories to watch 32 | watched: [ 33 | "web/static", 34 | "test/static" 35 | ], 36 | 37 | // Where to compile files to 38 | public: "priv/static" 39 | }, 40 | 41 | // Configure your plugins 42 | plugins: { 43 | babel: { 44 | // Do not use ES6 compiler in vendor code 45 | ignore: [/web\/static\/vendor/] 46 | }, 47 | copycat:{ 48 | "fonts" : ["node_modules/bootstrap-sass/assets/fonts"], 49 | verbose : true, //shows each file that is copied to the destination directory 50 | onlyChanged: true //only copy a file if it's modified time has changed (only effective when using brunch watch) 51 | }, 52 | sass: { 53 | options: { 54 | mode: 'ruby', 55 | includePaths: [ 56 | 'node_modules/bootstrap-sass/assets/stylesheets/', 57 | 'node_modules/css-reset-and-normalize-sass/scss/', 58 | 'node_modules/' 59 | ], 60 | } 61 | }, 62 | cleancss: { 63 | advanced: false, 64 | } 65 | }, 66 | 67 | modules: { 68 | autoRequire: { 69 | "js/toolbar.js": ["web/static/js/toolbar"] 70 | } 71 | }, 72 | 73 | npm: { 74 | styles: { 75 | }, 76 | enabled: true 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ex_debug_toolbar, 4 | enable: true, 5 | iex_shell: "/bin/sh", 6 | iex_shell_cmd: "stty echo\n", 7 | breakpoints_limit: 3, 8 | remove_glob_params: true, 9 | debug: true 10 | 11 | config :ex_debug_toolbar, ExDebugToolbar.Fixtures.Endpoint, 12 | instrumenters: [ExDebugToolbar.Collector.InstrumentationCollector], 13 | debug_errors: true 14 | 15 | config :ex_debug_toolbar, ExDebugToolbar.Endpoint, 16 | url: [host: "localhost"], 17 | secret_key_base: "v6SG14aYQCvYyk4rRq4HYYJ1GGXUIf23oWS5kmy0MngyWPTrlQAGnl1mvKkGy/Tj", 18 | render_errors: [view: ExDebugToolbar.ErrorView, accepts: ~w(html json)] 19 | 20 | config :logger, :console, 21 | format: "$time $metadata[$level] $message\n", 22 | metadata: [:request_id], 23 | level: :warn 24 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar do 2 | @moduledoc ExDebugToolbar.Docs.load!("README.md") 3 | 4 | alias ExDebugToolbar.Database.RequestRepo 5 | alias ExDebugToolbar.Data.{Collection, BreakpointCollection} 6 | alias ExDebugToolbar.{Breakpoint, Request} 7 | use ExDebugToolbar.Decorator.Noop 8 | 9 | @type uuid :: String.t 10 | @type id :: uuid | pid() 11 | @type ok :: :ok 12 | @type options :: Keyword.t() 13 | @type breakpoint_uuid :: Breakpoint.UUID.t() 14 | 15 | @doc """ 16 | Creates a new request record with to provided `uuid` and current process pid. 17 | 18 | Request is required to be present before adding new timeline events. By default 19 | request is started on `:ex_debug_toolbar` `:start` intrumentation event. 20 | """ 21 | @spec start_request(uuid) :: ok 22 | @decorate noop_when_toolbar_disabled() 23 | def start_request(uuid) do 24 | :ok = RequestRepo.insert(%Request{ 25 | pid: self(), 26 | uuid: uuid, 27 | created_at: NaiveDateTime.utc_now() 28 | }) 29 | end 30 | 31 | @doc """ 32 | Stops request. Toolbar waits for request to stop before rendering. 33 | 34 | By default request is stopped on `:ex_debug_toolbar` `:stop` instrumentation event. 35 | """ 36 | @spec stop_request(id) :: ok 37 | @decorate noop_when_toolbar_disabled() 38 | def stop_request(id) do 39 | :ok = RequestRepo.update(id, &(%{&1 | stopped?: true}), async: false) 40 | end 41 | 42 | @doc """ 43 | Deletes request from repository 44 | """ 45 | @spec delete_request(uuid) :: ok 46 | @decorate noop_when_toolbar_disabled() 47 | def delete_request(uuid) do 48 | RequestRepo.delete(uuid) 49 | end 50 | 51 | @doc """ 52 | Returns request matching provided `id`, which defaults to `self()` 53 | """ 54 | @spec get_request(id) :: Request.t() 55 | @decorate noop_when_toolbar_disabled() 56 | def get_request(id \\ self()) do 57 | RequestRepo.get(id) 58 | end 59 | 60 | @doc """ 61 | Returns the breakpoint 62 | """ 63 | @spec get_breakpoint(breakpoint_uuid) :: Breakpoint.t() 64 | @decorate noop_when_toolbar_disabled() 65 | def get_breakpoint(breakpoint_uuid) do 66 | case ExDebugToolbar.get_request(breakpoint_uuid.request_id) do 67 | {:ok, request} -> BreakpointCollection.find(request.breakpoints, breakpoint_uuid.breakpoint_id) 68 | {:error, reason} -> {:error, reason} 69 | end 70 | end 71 | 72 | @doc """ 73 | Returns all requests from repository 74 | """ 75 | @spec get_all_requests() :: [Request.t()] 76 | @decorate noop_when_toolbar_disabled([]) 77 | def get_all_requests do 78 | RequestRepo.all 79 | end 80 | 81 | @doc """ 82 | Starts a timeline event `name` in request identified by `id`, which defaults to `self()` 83 | """ 84 | @decorate noop_when_toolbar_disabled() 85 | @spec start_event(id, String.t()) :: ok 86 | def start_event(id \\ self(), name) do 87 | add_data(id, :timeline, {:start_event, name, System.monotonic_time}) 88 | end 89 | 90 | @doc """ 91 | Finishes event `name` in request with pid `self()` 92 | 93 | See `finish_event/3` for more details. 94 | """ 95 | @decorate noop_when_toolbar_disabled() 96 | @spec finish_event(String.t()) :: ok 97 | def finish_event(name), do: finish_event(self(), name, []) 98 | 99 | @decorate noop_when_toolbar_disabled() 100 | def finish_event(name, opts) when is_list(opts), do: finish_event(self(), name, opts) 101 | 102 | @decorate noop_when_toolbar_disabled() 103 | def finish_event(id, name) when is_bitstring(name), do: finish_event(id, name, []) 104 | 105 | @doc """ 106 | Finishes event `name` for request with id `id` 107 | 108 | Event duration is calculated as a difference between call to `start_event/2` and `finish_event/3` with 109 | matching `name` and request `id`. 110 | 111 | Available options: 112 | 113 | * `:duration` - overrides event duration, should be in `:native` time units 114 | """ 115 | @decorate noop_when_toolbar_disabled() 116 | @spec finish_event(id, String.t(), options) :: ok 117 | def finish_event(id, name, opts) do 118 | add_data(id, :timeline, {:finish_event, name, System.monotonic_time, opts[:duration]}) 119 | end 120 | 121 | @doc """ 122 | Creates a timeline event for provided function `func` execution. 123 | 124 | Returns `func` return value. 125 | """ 126 | @spec record_event(id, String.t(), function()) :: any() 127 | def record_event(id \\ self(), name, func) do 128 | start_event(id, name) 129 | result = func.() 130 | finish_event(id, name) 131 | result 132 | end 133 | 134 | @doc """ 135 | Adds timeline event `name` with provided `duration` without explicitly starting it. 136 | """ 137 | @spec add_finished_event(id, String.t(), Integer.t()) :: ok 138 | @decorate noop_when_toolbar_disabled() 139 | def add_finished_event(id \\ self(), name, duration) do 140 | add_data(id, :timeline, {:add_finished_event, name, duration}) 141 | end 142 | 143 | @doc """ 144 | Adds a breakpoint 145 | """ 146 | @spec add_breakpoint(id, Breakpoint.t()) :: ok 147 | @decorate noop_when_toolbar_disabled() 148 | def add_breakpoint(id \\ self(), breakpoint) do 149 | add_data(id, :breakpoints, breakpoint) 150 | end 151 | 152 | @doc """ 153 | Adds data to request with id `id` 154 | """ 155 | @spec add_data(id, atom(), any()) :: ok 156 | @decorate noop_when_toolbar_disabled() 157 | def add_data(id \\ self(), key, data) do 158 | do_add_data(id, key, data) 159 | end 160 | 161 | @doc """ 162 | Adds a breakpoint that can be interacted with using Breakpoints Panel on toolbar. 163 | """ 164 | @spec pry() :: nil 165 | @decorate noop_when_toolbar_disabled(nil) 166 | defmacro pry do 167 | code_snippet = Breakpoint.code_snippet(__CALLER__) 168 | quote do 169 | file = __ENV__.file |> String.trim_leading(File.cwd!) |> Path.relative 170 | ExDebugToolbar.add_breakpoint(%Breakpoint{ 171 | file: file, 172 | line: __ENV__.line, 173 | env: __ENV__, 174 | binding: binding(), 175 | code_snippet: unquote(code_snippet), 176 | inserted_at: NaiveDateTime.utc_now() 177 | }) 178 | end 179 | end 180 | 181 | defp do_add_data(id, key, data) do 182 | if Map.has_key?(%Request{}, key) do 183 | :ok = RequestRepo.update(id, &update_request(&1, key, data)) 184 | else 185 | {:error, :undefined_collection} 186 | end 187 | end 188 | 189 | defp update_request(%Request{} = request, key, data) do 190 | request |> Map.get(key) |> Collection.add(data) |> (&Map.put(request, key, &1)).() 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | alias ExDebugToolbar.{Logger, Config} 6 | 7 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 8 | # for more information on OTP Applications 9 | def start(_type, _args) do 10 | if Config.enabled?() and Config.phoenix_server?() do 11 | Logger.debug("Starting") 12 | do_start() 13 | else 14 | Logger.debug("DISABLED") 15 | {:ok, self()} 16 | end 17 | end 18 | 19 | defp do_start do 20 | import Supervisor.Spec 21 | # Define workers and child supervisors to be supervised 22 | children = [ 23 | # Start the endpoint when the application starts 24 | supervisor(ExDebugToolbar.Endpoint, []), 25 | supervisor(ExDebugToolbar.Database.Supervisor, []), 26 | worker(:exec, [[env: [{'SHELL', Config.get_iex_shell()}, {'MIX_ENV', to_charlist(Mix.env)}]]]), 27 | ] 28 | 29 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 30 | # for other strategies and supported options 31 | opts = [strategy: :one_for_one, name: ExDebugToolbar.Supervisor] 32 | ExDebugToolbar.Config.update() 33 | Supervisor.start_link(children, opts) 34 | end 35 | 36 | # Tell Phoenix to update the endpoint configuration 37 | # whenever the application is updated. 38 | def config_change(changed, _new, removed) do 39 | ExDebugToolbar.Endpoint.config_change(changed, removed) 40 | :ok 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Breakpoint.{IEx.Server, Pry} 5 | 6 | defstruct [ 7 | :id, 8 | :file, 9 | :line, 10 | :env, 11 | :binding, 12 | :code_snippet, 13 | :inserted_at 14 | ] 15 | 16 | defdelegate start_iex(breakpoint, output_pid), to: Server, as: :start_link 17 | defdelegate send_input_to_iex(pid, input), to: Server, as: :send_input 18 | defdelegate stop_iex(pid), to: Server, as: :stop 19 | defdelegate code_snippet(env), to: Pry 20 | 21 | def serialize!(%__MODULE__{} = breakpoint) do 22 | breakpoint 23 | |> :erlang.term_to_binary 24 | |> Base.encode64 25 | end 26 | 27 | def unserialize!(string) do 28 | breakpoint = string 29 | |> Base.decode64! 30 | |> :erlang.binary_to_term 31 | 32 | case breakpoint do 33 | %__MODULE__{} -> breakpoint 34 | term -> raise ArgumentError, "Expected string to be base64 encoded %Breakpoint{}, but got #{inspect(term)}" 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint/client_node.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint.ClientNode do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Breakpoint 5 | 6 | def run(%Breakpoint{binding: binding, env: env}) do 7 | if old_iex_version?() do 8 | apply(IEx, :pry, [binding, env, 5000]) 9 | else 10 | apply(IEx.Pry, :pry, [binding, env]) 11 | end 12 | end 13 | 14 | defp old_iex_version? do 15 | :functions 16 | |> IEx.__info__ 17 | |> Keyword.has_key?(:pry) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint/iex/server.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint.IEx.Server do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Breakpoint.IEx.Shell 5 | use GenServer 6 | 7 | def start_link(breakpoint_id, output_pid) do 8 | GenServer.start_link(__MODULE__, {breakpoint_id, output_pid}) 9 | end 10 | 11 | def stop(iex) do 12 | GenServer.stop(iex) 13 | end 14 | 15 | def send_input(iex, input) do 16 | GenServer.cast(iex, {:input, input}) 17 | end 18 | 19 | def init({breakpoint, output_pid}) do 20 | {:ok, iex} = Shell.start(breakpoint) 21 | {:ok, %{output_pid: output_pid, iex: iex}} 22 | end 23 | 24 | def handle_cast({:input, input}, %{iex: iex} = state) do 25 | Shell.send_input(iex, input) 26 | {:noreply, state} 27 | end 28 | 29 | def handle_info({:stdout, _os_pid, output}, %{output_pid: output_pid} = state) do 30 | send(output_pid, {:output, output}) 31 | {:noreply, state} 32 | end 33 | 34 | def handle_info({:stderr, _os_pir, output}, %{output_pid: output_pid} = state) do 35 | send(output_pid, {:output, output}) 36 | {:noreply, state} 37 | end 38 | 39 | def terminate(reason, %{iex: iex}) do 40 | Shell.stop(iex) 41 | reason 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint/iex/shell.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint.IEx.Shell do 2 | @moduledoc false 3 | 4 | @default_cmd """ 5 | stty echo 6 | clear 7 | iex -S mix breakpoint.client --breakpoint-file %{breakpoint_file} 8 | """ 9 | 10 | alias ExDebugToolbar.Breakpoint 11 | 12 | def start(breakpoint) do 13 | with {:ok, _} <- Temp.track, 14 | breakpoint_file <- breakpoint_file(breakpoint), 15 | {:ok, pid, _os_pid} <- start_shell_process(), 16 | :ok <- start_iex_process(pid, breakpoint_file), 17 | do: {:ok, pid} 18 | else error -> error 19 | end 20 | 21 | def stop(pid), do: :exec.stop(pid) 22 | 23 | def send_input(pid, input), do: :exec.send(pid, input) 24 | 25 | defp breakpoint_file(breakpoint) do 26 | serialized_breakpoint = breakpoint |> Breakpoint.serialize! 27 | Temp.open! "breakpoint", &IO.write(&1, serialized_breakpoint) 28 | end 29 | 30 | defp start_shell_process do 31 | :exec.run('$SHELL', [:stdin, :stdout, :stderr, :pty]) 32 | end 33 | 34 | defp start_iex_process(pid, breakpoint_file) do 35 | :ex_debug_toolbar 36 | |> Application.get_env(:iex_shell_cmd, @default_cmd) 37 | |> String.replace("%{breakpoint_file}", breakpoint_file) 38 | |> (&:exec.send(pid, &1)).() 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint/pry.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint.Pry do 2 | @moduledoc false 3 | 4 | def code_snippet(%Macro.Env{file: file, line: line}) do 5 | case whereami(file, line, 2) do 6 | {:ok, lines} -> lines 7 | :error -> [] 8 | end 9 | end 10 | 11 | defp whereami(file, line, radius) 12 | when is_binary(file) and is_integer(line) and is_integer(radius) and radius > 0 do 13 | with true <- File.regular?(file), 14 | [_ | _] = lines <- whereami_lines(file, line, radius) do 15 | {:ok, lines} 16 | else 17 | _ -> :error 18 | end 19 | end 20 | 21 | defp whereami_lines(file, line, radius) do 22 | min = max(line - radius - 1, 0) 23 | max = line + radius - 1 24 | 25 | file 26 | |> File.stream! 27 | |> Enum.slice(min..max) 28 | |> Enum.with_index(min + 1) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/breakpoint/uuid.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoint.UUID do 2 | @moduledoc false 3 | 4 | defstruct [:request_id, :breakpoint_id] 5 | 6 | def from_string(uuid) do 7 | case String.split(uuid, "-") do 8 | [request_id, breakpoint_id] -> 9 | {:ok, %__MODULE__{request_id: request_id, breakpoint_id: breakpoint_id}} 10 | _ -> {:error, "cannot parse uuid #{uuid}"} 11 | end 12 | end 13 | end 14 | 15 | defimpl String.Chars, for: ExDebugToolbar.Breakpoint.UUID do 16 | def to_string(%{request_id: request_id, breakpoint_id: breakpoint_id}) do 17 | "#{request_id}-#{breakpoint_id}" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/collector/conn_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.ConnCollector do 2 | @moduledoc false 3 | 4 | @behaviour Plug 5 | 6 | def init(opts), do: opts 7 | 8 | def call(%Plug.Conn{} = conn, _opts) do 9 | Plug.Conn.register_before_send(conn, fn conn -> 10 | ExDebugToolbar.add_data(:conn, conn) 11 | conn 12 | end) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/collector/ecto_collector.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_compiled?(Ecto) do 2 | defmodule ExDebugToolbar.Collector.EctoCollector do 3 | @moduledoc false 4 | 5 | alias Ecto.LogEntry 6 | 7 | def log(%LogEntry{} = original_entry) do 8 | entry = original_entry |> remove_result_rows |> cast_params 9 | {id, duration, type} = parse_entry(entry) 10 | ExDebugToolbar.add_finished_event(id, "ecto.query", duration) 11 | ExDebugToolbar.add_data(id, :ecto, {entry, duration, type}) 12 | original_entry 13 | end 14 | 15 | defp parse_entry(entry) do 16 | duration = (entry.queue_time || 0) + (entry.query_time || 0) + (entry.decode_time || 0) 17 | case entry do 18 | %{caller_pid: pid} when not is_nil(pid) -> 19 | type = if self() == pid, do: :inline, else: :parallel 20 | {pid, duration, type} 21 | _ -> 22 | {self(), duration, :inline} 23 | end 24 | end 25 | 26 | defp remove_result_rows(%{result: {:ok, %Postgrex.Cursor{} = result}} = entry) do 27 | %{entry | result: {:ok, %{result | ref: nil}}} 28 | end 29 | defp remove_result_rows(%{result: {:ok, %{rows: rows} = result}} = entry) when is_list(rows) do 30 | %{entry | result: {:ok, %{result | rows: []}}} 31 | end 32 | defp remove_result_rows(entry), do: entry 33 | 34 | defp cast_params(%{params: params} = entry) do 35 | %{entry | params: Enum.map(params, &cast_param/1)} 36 | end 37 | defp cast_param(value) when is_bitstring(value) do 38 | case Ecto.UUID.cast(value) do 39 | {:ok, uuid} -> uuid 40 | :error -> "__BINARY__" 41 | end 42 | end 43 | defp cast_param(value), do: value 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/collector/instrumentation_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.InstrumentationCollector do 2 | @moduledoc false 3 | 4 | def ex_debug_toolbar(:start, _, %{conn: conn}) do 5 | conn.private.request_id |> ExDebugToolbar.start_request 6 | end 7 | def ex_debug_toolbar(:stop, _, _) do 8 | ExDebugToolbar.stop_request(self()) 9 | ExDebugToolbar.ToolbarChannel.broadcast_request 10 | end 11 | 12 | def phoenix_controller_call(:start, _, _) do 13 | ExDebugToolbar.start_event("controller.call") 14 | end 15 | def phoenix_controller_call(:stop, time_diff, _) do 16 | ExDebugToolbar.finish_event("controller.call", duration: time_diff) 17 | end 18 | 19 | def phoenix_controller_render(:start, _, _) do 20 | ExDebugToolbar.start_event("controller.render") 21 | end 22 | def phoenix_controller_render(:stop, time_diff, _) do 23 | ExDebugToolbar.finish_event("controller.render", duration: time_diff) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/collector/logger_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.LoggerCollector do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Data.LogEntry 5 | @behaviour :gen_event 6 | 7 | def init(_), do: {:ok, nil} 8 | 9 | def handle_call({:configure, _options}, state) do 10 | {:ok, :ok, state} 11 | end 12 | 13 | def handle_event({_level, gl, _event}, state) when node(gl) != node() do 14 | {:ok, state} 15 | end 16 | 17 | def handle_event({level, _gl, {_, _, _, metadata} = event}, state) do 18 | if metadata[:request_id], do: add_log_to_toolbar(level, event) 19 | {:ok, state} 20 | end 21 | 22 | def handle_event(:flush, state) do 23 | {:ok, state} 24 | end 25 | 26 | def handle_info(_, state) do 27 | {:ok, state} 28 | end 29 | 30 | def code_change(_old_vsn, state, _extra) do 31 | {:ok, state} 32 | end 33 | 34 | def terminate(_reason, _state) do 35 | :ok 36 | end 37 | 38 | defp add_log_to_toolbar(level, event) do 39 | {Logger, message, timestamp, metadata} = event 40 | log_entry = %LogEntry{ 41 | level: level, 42 | message: message, 43 | timestamp: timestamp 44 | } 45 | ExDebugToolbar.add_data metadata[:request_id], :logs, log_entry 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/collector/template_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.TemplateCollector do 2 | @moduledoc false 3 | 4 | defmacro __using__(opts) do 5 | quote do 6 | @behaviour Phoenix.Template.Engine 7 | 8 | def compile(path, name) do 9 | compiled_template = unquote(opts[:engine]).compile(path, name) 10 | quote do 11 | ExDebugToolbar.record_event("template##{unquote(path)}", fn -> 12 | unquote(compiled_template) 13 | end) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/config.ex: -------------------------------------------------------------------------------- 1 | Code.compiler_options(ignore_module_conflict: true) 2 | 3 | defmodule ExDebugToolbar.Config do 4 | @breakpoints_limit 10 5 | 6 | def get(key, default) do 7 | Application.get_env(:ex_debug_toolbar, key, default) 8 | end 9 | 10 | def enabled? do 11 | Application.get_env(:ex_debug_toolbar, :enable, false) 12 | end 13 | 14 | def debug? do 15 | Application.get_env(:ex_debug_toolbar, :debug, false) 16 | end 17 | 18 | def remove_glob_params? do 19 | Application.get_env(:ex_debug_toolbar, :remove_glob_params, true) 20 | end 21 | 22 | def get_iex_shell do 23 | default = (System.get_env("SHELL") || "/bin/bash") 24 | Application.get_env(:ex_debug_toolbar, :iex_shell, default) |> String.to_charlist 25 | end 26 | 27 | def phoenix_server? do 28 | Application.get_env(:phoenix, :serve_endpoints, false) 29 | end 30 | 31 | def get_breakpoints_limit do 32 | Application.get_env(:ex_debug_toolbar, :breakpoints_limit, @breakpoints_limit) 33 | end 34 | 35 | def update do 36 | config = Application.get_env(:ex_debug_toolbar, ExDebugToolbar.Endpoint, []) 37 | |> Keyword.put(:pubsub, [name: ExDebugToolbar.PubSub, adapter: Phoenix.PubSub.PG2]) 38 | |> Keyword.put(:url, [host: "localhost", path: "/__ex_debug_toolbar__"]) 39 | Application.put_env(:ex_debug_toolbar, ExDebugToolbar.Endpoint, config, persistent: true) 40 | end 41 | end 42 | 43 | Code.compiler_options(ignore_module_conflict: false) 44 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/breakpoint_collection.ex: -------------------------------------------------------------------------------- 1 | alias ExDebugToolbar.Data.Collection 2 | alias ExDebugToolbar.Breakpoint 3 | 4 | defmodule ExDebugToolbar.Data.BreakpointCollection do 5 | @moduledoc false 6 | 7 | defstruct [count: 0, entries: %{}] 8 | 9 | def find(breakpoints, id) do 10 | case Map.fetch(breakpoints.entries, id) do 11 | :error -> {:error, :not_found} 12 | {:ok, breakpoint} -> {:ok, breakpoint} 13 | end 14 | end 15 | end 16 | 17 | alias ExDebugToolbar.Data.BreakpointCollection 18 | 19 | defimpl Collection, for: BreakpointCollection do 20 | @breakpoints_limit ExDebugToolbar.Config.get_breakpoints_limit() 21 | 22 | def add(%{count: @breakpoints_limit} = breakpoints, %Breakpoint{}), do: breakpoints 23 | def add(%{count: count} = breakpoints, %Breakpoint{id: nil} = breakpoint) do 24 | add(breakpoints, %{breakpoint | id: to_string(count)}) 25 | end 26 | def add(breakpoints, %Breakpoint{} = breakpoint) do 27 | %{ 28 | breakpoints | 29 | entries: Map.put(breakpoints.entries, breakpoint.id, breakpoint), 30 | count: breakpoints.count + 1 31 | } 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/collection.ex: -------------------------------------------------------------------------------- 1 | defprotocol ExDebugToolbar.Data.Collection do 2 | @moduledoc false 3 | 4 | @doc "adds item to collection" 5 | def add(collection, item) 6 | end 7 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/conn.ex: -------------------------------------------------------------------------------- 1 | alias ExDebugToolbar.Data.Collection 2 | 3 | defimpl Collection, for: Plug.Conn do 4 | def add(_, %Plug.Conn{} = conn), do: %{conn | resp_body: nil} 5 | end 6 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/list.ex: -------------------------------------------------------------------------------- 1 | alias ExDebugToolbar.Data.Collection 2 | 3 | defimpl Collection, for: List do 4 | def add(collection, item) do 5 | [item | collection] 6 | end 7 | 8 | def format_item(_list, item), do: item 9 | end 10 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/log_entry.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.LogEntry do 2 | @moduledoc false 3 | 4 | defstruct ~w(level message timestamp)a 5 | end 6 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/map.ex: -------------------------------------------------------------------------------- 1 | alias ExDebugToolbar.Data.Collection 2 | defimpl Collection, for: Map do 3 | def format_item(_map, item) when is_map(item), do: item 4 | 5 | def add(collection, item) when is_map(item) do 6 | Map.merge(collection, item) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/data/timeline.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.Timeline do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Data.Timeline 5 | 6 | defmodule Event do 7 | @moduledoc false 8 | 9 | defstruct [ 10 | name: nil, 11 | duration: 0, 12 | started_at: nil, 13 | events: [], 14 | ] 15 | end 16 | 17 | defstruct [ 18 | events: [], 19 | duration: 0, 20 | queue: [] 21 | ] 22 | 23 | def add_finished_event(%Timeline{} = timeline, name, duration) do 24 | start_event(timeline, name) |> finish_event(name, duration: duration) 25 | end 26 | 27 | def start_event(%Timeline{} = timeline, name, opts \\ []) do 28 | event = %Timeline.Event{name: name, started_at: opts[:timestamp]} 29 | %{timeline | queue: [event | timeline.queue]} 30 | end 31 | 32 | def finish_event(timeline, name, opts \\ []) 33 | def finish_event(%Timeline{queue: [%{name: name} = event]} = timeline, name, opts) do 34 | events = timeline.events 35 | finished_event = set_duration(event, opts) 36 | %{timeline | 37 | queue: [], 38 | events: [finished_event | events], 39 | duration: finished_event.duration + timeline.duration 40 | } 41 | end 42 | def finish_event(%Timeline{queue: [%{name: name} = event | [parent | rest]]} = timeline, name, opts) do 43 | finished_event = set_duration(event, opts) 44 | new_parent = %{parent | events: [finished_event | parent.events]} 45 | %{timeline | queue: [new_parent | rest]} 46 | end 47 | def finish_event(_timeline, name, _opts), do: raise "the event #{name} is not open" 48 | 49 | def empty?(%Timeline{events: []}), do: true 50 | def empty?(%Timeline{}), do: false 51 | 52 | def get_all_events(%Timeline{events: events}), do: get_all_events(events) 53 | def get_all_events(%Event{events: events}), do: get_all_events(events) 54 | def get_all_events(events) when is_list(events) do 55 | Enum.flat_map(events, &([&1 | get_all_events(&1)])) 56 | end 57 | 58 | defp set_duration(event, opts) do 59 | duration = case {opts[:duration], opts[:timestamp]} do 60 | {nil, nil} -> 0 61 | {nil, timestamp} -> timestamp - event.started_at 62 | {duration, _} -> duration 63 | end 64 | %{event | duration: duration} 65 | end 66 | end 67 | 68 | alias ExDebugToolbar.Data.{Collection, Timeline} 69 | 70 | defimpl Collection, for: Timeline do 71 | def add(timeline, {:start_event, name, timestamp}) do 72 | Timeline.start_event(timeline, name, timestamp: timestamp) 73 | end 74 | 75 | def add(timeline, {:finish_event, name, timestamp, duration}) do 76 | Timeline.finish_event(timeline, name, duration: duration, timestamp: timestamp) 77 | end 78 | 79 | def add(timeline, {:add_finished_event, name, duration}) do 80 | Timeline.add_finished_event(timeline, name, duration) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/database.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Database do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Request 5 | use GenServer 6 | 7 | @request_attributes [:pid, :uuid, :request] 8 | @tables [Request] 9 | 10 | def start_link do 11 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 12 | end 13 | 14 | def init(_) do 15 | Process.flag(:trap_exit, true) 16 | :mnesia.start() 17 | create_tables() 18 | {:ok, nil} 19 | end 20 | 21 | def terminate(reason, _state) do 22 | destroy_tables() 23 | :mnesia.stop() 24 | reason 25 | end 26 | 27 | defp destroy_tables do 28 | @tables |> Enum.each(&:mnesia.delete_table/1) 29 | end 30 | 31 | defp create_tables do 32 | {:atomic, :ok} = :mnesia.create_table( 33 | Request, 34 | type: :set, 35 | attributes: @request_attributes, 36 | index: [:uuid] 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/database/request_repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Database.RequestRepo do 2 | @moduledoc false 3 | 4 | use GenServer 5 | alias ExDebugToolbar.Request 6 | 7 | def insert(%Request{} = request) do 8 | :mnesia.dirty_write({Request, request.pid, request.uuid, request}) |> result 9 | end 10 | 11 | def update(id, changes, opts \\ []) do 12 | if Keyword.get(opts, :async, true) do 13 | GenServer.cast(__MODULE__, {:update, id, changes}) 14 | :ok 15 | else 16 | GenServer.call(__MODULE__, {:update, id, changes}, :infinity) 17 | end 18 | end 19 | 20 | def all do 21 | :mnesia.dirty_select(Request, [{{Request, :"_", :"_", :"$1"},[],[:"$1"]}]) 22 | end 23 | 24 | def purge do 25 | :mnesia.clear_table(Request) |> result 26 | end 27 | 28 | def delete(id) do 29 | GenServer.call(__MODULE__, {:delete, id}) 30 | end 31 | 32 | def get(pid) when is_pid(pid) do 33 | do_get fn -> 34 | :mnesia.dirty_read(Request, pid) 35 | end 36 | end 37 | def get(uuid) do 38 | do_get fn -> 39 | :mnesia.dirty_index_read(Request, uuid, :uuid) 40 | end 41 | end 42 | 43 | defp do_get(func) do 44 | case func.() do 45 | [{Request, _, _, request}] -> {:ok, request} 46 | [] -> {:error, :not_found} 47 | end 48 | end 49 | 50 | def start_link do 51 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 52 | end 53 | 54 | def init(opts), do: {:ok, opts} 55 | 56 | def handle_cast({:update, id, changes}, _state) do 57 | 58 | do_update(id, changes) 59 | {:noreply, nil} 60 | end 61 | 62 | def handle_call({:update, id, changes}, _from, _state) do 63 | {:reply, do_update(id, changes), nil} 64 | end 65 | 66 | def handle_call({:delete, id}, _from, _state) do 67 | reply = case get(id) do 68 | {:ok, request} -> :mnesia.dirty_delete({Request, request.pid}) 69 | _ -> :error 70 | end 71 | 72 | {:reply, reply, nil} 73 | end 74 | 75 | defp do_update(id, changes) do 76 | case get(id) do 77 | {:ok, request} -> request |> apply_changes(changes) |> insert 78 | _ -> :error 79 | end 80 | end 81 | 82 | defp apply_changes(request, changes) when is_map(changes) do 83 | Map.merge(request, changes) 84 | end 85 | defp apply_changes(request, changes) when is_function(changes) do 86 | changes.(request) 87 | end 88 | 89 | defp result({:atomic, result}), do: result 90 | defp result({:aborted, reason}), do: {:error, reason} 91 | defp result(result), do: result 92 | end 93 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/database/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Database.Supervisor do 2 | @moduledoc false 3 | 4 | use Supervisor 5 | 6 | def start_link do 7 | Supervisor.start_link(__MODULE__, [], name: __MODULE__) 8 | end 9 | 10 | def init([]) do 11 | children = [ 12 | worker(ExDebugToolbar.Database, []), 13 | worker(ExDebugToolbar.Database.RequestRepo, []), 14 | ] 15 | 16 | supervise(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/decorator/noop.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Decorator.Noop do 2 | @moduledoc false 3 | 4 | use Decorator.Define, [ 5 | noop_when_toolbar_disabled: 1, 6 | noop_when_toolbar_disabled: 0, 7 | noop_when_debug_mode_disabled: 1, 8 | noop_when_debug_mode_disabled: 0 9 | ] 10 | 11 | @toolbar_noop_result {:error, :toolbar_disabled} 12 | @debug_noop_result {:error, :debug_mode_disabled} 13 | 14 | @doc """ 15 | Decorator that turns function into a no-op when toolbar is disabled. 16 | """ 17 | def noop_when_toolbar_disabled(noop_result \\ @toolbar_noop_result, body, _context) do 18 | toggle_function(:enable, noop_result, body) 19 | end 20 | 21 | @doc """ 22 | Decorator that turns function into a no-op when debug mode is disabled. 23 | """ 24 | def noop_when_debug_mode_disabled(noop_result \\ @debug_noop_result, body, _context) do 25 | toggle_function(:debug, noop_result, body) 26 | end 27 | 28 | defp toggle_function(flag, noop_result, body) do 29 | quote do 30 | execute? = ExDebugToolbar.Config.get(unquote(flag), false) 31 | if execute? do 32 | unquote(body) 33 | else 34 | unquote(noop_result) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/docs.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Docs do 2 | @moduledoc false 3 | 4 | def load!(name) do 5 | name 6 | |> File.read! 7 | |> strip_images 8 | end 9 | 10 | defp strip_images(str) do 11 | Regex.replace(~r/!\[.*?\]\(.*?\)/, str, "") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Endpoint do 2 | @moduledoc false 3 | 4 | use Phoenix.Endpoint, otp_app: :ex_debug_toolbar 5 | 6 | socket "/socket", ExDebugToolbar.UserSocket 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phoenix.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", from: :ex_debug_toolbar, gzip: false, 14 | only: ~w(css fonts images js) 15 | 16 | # Code reloading can be explicitly enabled under the 17 | # :code_reloader configuration of your endpoint. 18 | if code_reloading? do 19 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 20 | plug Phoenix.LiveReloader 21 | plug Phoenix.CodeReloader 22 | end 23 | 24 | plug Plug.Parsers, 25 | parsers: [:urlencoded, :multipart, :json], 26 | pass: ["*/*"], 27 | json_decoder: Poison 28 | 29 | plug Plug.MethodOverride 30 | plug Plug.Head 31 | 32 | # The session will be stored in the cookie and signed, 33 | # this means its contents can be read but not tampered with. 34 | # Set :encryption_salt if you would also like to encrypt it. 35 | plug Plug.Session, 36 | store: :cookie, 37 | key: "_ex_debug_toolbar_key", 38 | signing_salt: "BTxbJiTG" 39 | 40 | plug ExDebugToolbar.Router 41 | end 42 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Logger do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Decorator.Noop 5 | require Logger 6 | 7 | @prefix "[ExDebugToolbar] " 8 | 9 | @decorate noop_when_debug_mode_disabled(:ok) 10 | def debug(chardata_or_fun, metadata \\ []) do 11 | case chardata_or_fun do 12 | chardata when is_bitstring(chardata) -> 13 | Logger.debug @prefix <> chardata, metadata 14 | fun when is_function(fun) -> 15 | Logger.debug fn -> @prefix <> fun.() end, metadata 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Phoenix do 2 | @moduledoc false 3 | 4 | defmacro __using__(_) do 5 | if ExDebugToolbar.Config.enabled?(), do: build_plug_ast() 6 | end 7 | 8 | def build_plug_ast do 9 | quote location: :keep do 10 | require Phoenix.Endpoint 11 | alias Phoenix.Endpoint 12 | alias ExDebugToolbar.Plug.Router 13 | 14 | Logger.add_backend(ExDebugToolbar.Collector.LoggerCollector) 15 | 16 | Endpoint.socket "/__ex_debug_toolbar__/socket", ExDebugToolbar.UserSocket 17 | 18 | @before_compile unquote(__MODULE__) 19 | end 20 | end 21 | 22 | defmacro __before_compile__(_) do 23 | quote location: :keep do 24 | require Phoenix.Endpoint 25 | alias Phoenix.Endpoint 26 | alias ExDebugToolbar.Plug.Router 27 | alias ExDebugToolbar.Logger 28 | 29 | defoverridable [call: 2] 30 | 31 | @doc """ 32 | Wrapper around app endpoint. After passing connection through 33 | toolbar's router we make a decision how to further process it. 34 | Ignoring request in a toolbar works by not emiting `:ex_debug_toolbar` event 35 | which creates new request in toolbar. The rest of data collection functions 36 | become effectively no-op for ignored requests. 37 | """ 38 | def call(conn, opts) do 39 | case dispatch_router(conn, opts) do 40 | # request to Toolbar's internal routes, it's been already 41 | # processed and we leave it untouched 42 | %{private: %{phoenix_endpoint: ExDebugToolbar.Endpoint}} = conn -> 43 | Logger.debug("Request to #{conn.request_path} was processed by internal endpoint") 44 | conn 45 | # app request that should be ignored according to configuration, 46 | # we pass it to app endpoint, but don't register in toolbar 47 | %{private: %{ex_debug_toolbar_ignore?: true}} = conn -> 48 | Logger.debug("Request to #{conn.request_path} will be ignored") 49 | super(conn, opts) 50 | # otherwise it's an app request we want to register in toolbar and 51 | # processed by app's endpoint 52 | conn -> 53 | Logger.debug("Request to #{conn.request_path} will be tracked") 54 | Endpoint.instrument(__MODULE__, :ex_debug_toolbar, %{conn: conn}, fn -> 55 | super(conn, opts) 56 | end) 57 | end 58 | end 59 | 60 | defp dispatch_router(conn, opts) do 61 | opts = Router.init(opts) 62 | Router.call(conn, opts) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/close_conn.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.CloseConn do 2 | @moduledoc false 3 | 4 | import Plug.Conn 5 | 6 | def init(opts), do: opts 7 | 8 | def call(conn, _opts) do 9 | conn |> put_resp_header("connection", "close") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/code_injector.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.CodeInjector do 2 | @moduledoc false 3 | 4 | import Plug.Conn 5 | alias Plug.Conn 6 | alias ExDebugToolbar.Router.Helpers, as: RouterHelpers 7 | alias ExDebugToolbar.Logger 8 | 9 | @behaviour Plug 10 | 11 | def init(opts), do: opts 12 | 13 | def call(conn, _opts) do 14 | register_before_send conn, &inject_debug_toolbar_code/1 15 | end 16 | 17 | defp inject_debug_toolbar_code(conn) do 18 | if inject?(conn) do 19 | Logger.debug("Injecting toolbar html into #{conn.request_path}") 20 | conn |> inject_css |> inject_js 21 | else 22 | Logger.debug("Skipping toolbar html injection into #{conn.request_path}") 23 | conn 24 | end 25 | end 26 | 27 | defp inject_js(conn) do 28 | static_path("/js/toolbar.js") |> js_code(conn) |> inject_code(conn, "") 29 | end 30 | 31 | defp inject_css(conn) do 32 | css_path = static_path("/css/toolbar.css") 33 | "\n" |> inject_code(conn, "") 34 | end 35 | 36 | defp static_path(path) do 37 | RouterHelpers.static_path(ExDebugToolbar.Endpoint, path) 38 | end 39 | 40 | defp inject_code(code, %{resp_body: body} = conn, tag) do 41 | body = body |> to_string |> String.replace(tag, code <> tag) 42 | put_in conn.resp_body, body 43 | end 44 | 45 | defp js_code(path, conn) do 46 | debug = ExDebugToolbar.Config.debug?() 47 | """ 48 | 54 | 55 | """ 56 | end 57 | 58 | defp inject?(%Conn{private: %{ex_debug_toolbar_ignore?: true}}), do: false 59 | defp inject?(%Conn{status: status}) when status >= 300 and status < 400, do: false 60 | defp inject?(%Conn{} = conn), do: html_content_type?(conn) 61 | 62 | defp html_content_type?(%Conn{} = conn) do 63 | conn |> get_resp_header("content-type") |> html_content_type? 64 | end 65 | defp html_content_type?([]), do: false 66 | defp html_content_type?([type | _]), do: String.starts_with?(type, "text/html") 67 | end 68 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/ignore_path_match.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.IgnorePathMatch do 2 | @moduledoc false 3 | @behaviour Plug 4 | 5 | import Plug.Conn 6 | 7 | def init(opts), do: opts 8 | 9 | def call(conn, opts \\ []) do 10 | default_paths = Keyword.get(opts, :ignore_paths, []) 11 | conn |> put_private(:ex_debug_toolbar_ignore?, ignore?(conn, default_paths)) 12 | end 13 | 14 | defp ignore?(conn, default_paths) do 15 | :ex_debug_toolbar 16 | |> Application.get_env(:ignore_paths, default_paths) 17 | |> Enum.any?(&ignore_path?(&1, conn)) 18 | end 19 | 20 | defp ignore_path?(path, conn) when is_bitstring(path) do 21 | path == conn.request_path 22 | end 23 | 24 | defp ignore_path?(%Regex{} = path, conn) do 25 | Regex.match?(path, conn.request_path) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.Pipeline do 2 | @moduledoc false 3 | 4 | use Plug.Builder 5 | 6 | plug ExDebugToolbar.Plug.RequestId 7 | plug ExDebugToolbar.Plug.CodeInjector 8 | plug ExDebugToolbar.Collector.ConnCollector 9 | plug ExDebugToolbar.Plug.CloseConn 10 | plug ExDebugToolbar.Plug.RemoveGlobParams 11 | plug ExDebugToolbar.Plug.IgnorePathMatch, ignore_paths: [ 12 | ~r{^/images/}, 13 | ~r{^/css/}, 14 | ~r{^/js/}, 15 | ~r{^/phoenix/live_reload/} 16 | ] 17 | end 18 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/remove_glob_params.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.RemoveGlobParams do 2 | @moduledoc false 3 | @behaviour Plug 4 | 5 | def init(opts), do: opts 6 | 7 | def call(conn, _opts) do 8 | if ExDebugToolbar.Config.remove_glob_params?() do 9 | remove_glob_params(conn) 10 | else 11 | conn 12 | end 13 | end 14 | 15 | defp remove_glob_params(conn) do 16 | conn 17 | |> Map.update!(:params, &delete_glob_key/1) 18 | |> Map.update!(:path_params, &delete_glob_key/1) 19 | end 20 | 21 | defp delete_glob_key(conn) do 22 | Map.delete(conn, "glob") 23 | end 24 | end 25 | 26 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/request_id.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.RequestId do 2 | @moduledoc false 3 | @behaviour Plug 4 | 5 | @impl Plug 6 | def init(opts), do: opts 7 | 8 | @impl Plug 9 | def call(conn, opts) do 10 | header = Plug.RequestId.init(opts) 11 | conn 12 | |> Plug.RequestId.call(header) 13 | |> put_request_id_in_private(header) 14 | |> put_request_id_in_req_headers(header) 15 | end 16 | 17 | defp put_request_id_in_private(conn, header) do 18 | request_id = Plug.Conn.get_resp_header(conn, header) |> List.first 19 | Plug.Conn.put_private(conn, :request_id, request_id) 20 | end 21 | 22 | defp put_request_id_in_req_headers(conn, header) do 23 | conn |> Plug.Conn.put_req_header(header, conn.private.request_id) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/plug/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.Router do 2 | @moduledoc false 3 | 4 | use Plug.Router 5 | 6 | plug :match 7 | plug :dispatch 8 | 9 | forward "/__ex_debug_toolbar__", to: ExDebugToolbar.Endpoint 10 | forward "/", to: ExDebugToolbar.Plug.Pipeline 11 | end 12 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/poison/encoder.ex: -------------------------------------------------------------------------------- 1 | Code.compiler_options(ignore_module_conflict: true) 2 | 3 | alias ExDebugToolbar.Config 4 | 5 | if Config.enabled?() and Config.debug?() do 6 | defmodule ExDebugToolbar.Poison.Encoder do 7 | def encode_inspect(term, options) do 8 | term |> inspect |> Poison.Encoder.encode(options) 9 | end 10 | end 11 | 12 | defimpl Poison.Encoder, for: Tuple do 13 | def encode(tuple, options) do 14 | tuple |> Tuple.to_list |> Poison.Encoder.encode(options) 15 | end 16 | end 17 | 18 | # redefine not to raise error 19 | defimpl Poison.Encoder, for: Ecto.Association.NotLoaded do 20 | def encode(_assoc, _options), do: "null" 21 | end 22 | 23 | defimpl Poison.Encoder, for: Regex do 24 | def encode(regex, _options), do: Regex.source(regex) 25 | end 26 | 27 | defimpl Poison.Encoder, for: Port do 28 | defdelegate encode(port, options), to: ExDebugToolbar.Poison.Encoder, as: :encode_inspect 29 | end 30 | 31 | defimpl Poison.Encoder, for: PID do 32 | defdelegate encode(pid, options), to: ExDebugToolbar.Poison.Encoder, as: :encode_inspect 33 | end 34 | 35 | defimpl Poison.Encoder, for: Function do 36 | defdelegate encode(func, options), to: ExDebugToolbar.Poison.Encoder, as: :encode_inspect 37 | end 38 | end 39 | 40 | Code.compiler_options(ignore_module_conflict: false) 41 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/request.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Request do 2 | @moduledoc false 3 | 4 | alias ExDebugToolbar.Data.{BreakpointCollection, Timeline} 5 | 6 | defstruct [ 7 | pid: nil, 8 | uuid: nil, 9 | created_at: nil, 10 | conn: %Plug.Conn{}, 11 | ecto: [], 12 | logs: [], 13 | breakpoints: %BreakpointCollection{}, 14 | timeline: %Timeline{}, 15 | stopped?: false 16 | ] 17 | end 18 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/template/eex_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Template.EExEngine do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Collector.TemplateCollector, engine: Phoenix.Template.EExEngine 5 | end 6 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/template/exs_engine.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Template.ExsEngine do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Collector.TemplateCollector, engine: Phoenix.Template.ExsEngine 5 | end 6 | -------------------------------------------------------------------------------- /lib/ex_debug_toolbar/template/slim_engine.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_compiled?(PhoenixSlime.Engine) do 2 | defmodule ExDebugToolbar.Template.SlimEngine do 3 | @moduledoc false 4 | 5 | use ExDebugToolbar.Collector.TemplateCollector, engine: PhoenixSlime.Engine 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/mix/tasks/breakpoint.client.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Breakpoint.Client do 2 | @moduledoc false 3 | 4 | use Mix.Task 5 | alias ExDebugToolbar.Breakpoint 6 | 7 | def run(args) do 8 | {options, _, _} = OptionParser.parse(args, switches: [breakpoint: :string]) 9 | breakpoint = options |> Keyword.fetch!(:breakpoint_file) |> File.read! |> Breakpoint.unserialize! 10 | ExDebugToolbar.Breakpoint.ClientNode.run(breakpoint) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/mix/tasks/compile.ex_debug_toolbar.ex: -------------------------------------------------------------------------------- 1 | Code.compiler_options(ignore_module_conflict: true) 2 | 3 | defmodule Mix.Tasks.Compile.ExDebugToolbar do 4 | @moduledoc false 5 | 6 | use Mix.Task 7 | 8 | def run(_) do 9 | Code.require_file "lib/ex_debug_toolbar/config.ex" 10 | ExDebugToolbar.Config.update() 11 | end 12 | end 13 | 14 | Code.compiler_options(ignore_module_conflict: false) 15 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | Code.require_file "lib/mix/tasks/compile.ex_debug_toolbar.ex" 2 | defmodule ExDebugToolbar.Mixfile do 3 | use Mix.Project 4 | 5 | def project do 6 | [app: :ex_debug_toolbar, 7 | version: "0.5.0", 8 | elixir: "~> 1.4", 9 | elixirc_paths: elixirc_paths(Mix.env), 10 | compilers: [:phoenix, :gettext, :ex_debug_toolbar] ++ Mix.compilers, 11 | build_embedded: Mix.env == :prod, 12 | start_permanent: Mix.env == :prod, 13 | name: "ExDebugToolbar", 14 | source_url: "https://github.com/kagux/ex_debug_toolbar", 15 | description: description(), 16 | package: package(), 17 | deps: deps()] 18 | end 19 | 20 | # Configuration for the OTP application. 21 | # 22 | # Type `mix help compile.app` for more information. 23 | def application do 24 | [mod: {ExDebugToolbar.Application, []}, 25 | extra_applications: [:logger]] 26 | end 27 | 28 | # Specifies which paths to compile per environment. 29 | defp elixirc_paths(:test), do: ["lib", "web", "test/support", "test/fixtures"] 30 | defp elixirc_paths(_), do: ["lib", "web"] 31 | 32 | defp description do 33 | """ 34 | A debug web toolbar for Phoenix projects to display all sorts of information about request 35 | """ 36 | end 37 | 38 | defp package do 39 | [ 40 | maintainers: ["Juan Peri", "Boris Mikhaylov"], 41 | licenses: ["Apache 2.0"], 42 | links: %{"GitHub" => "https://github.com/kagux/ex_debug_toolbar"}, 43 | files: ~w(mix.exs README.md lib web priv/static) 44 | ] 45 | end 46 | 47 | # Specifies your project dependencies. 48 | # 49 | # Type `mix help deps` for examples and options. 50 | defp deps do 51 | [ 52 | {:phoenix, "~> 1.3"}, 53 | {:phoenix_pubsub, "~> 1.0"}, 54 | {:phoenix_html, "~> 2.6"}, 55 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 56 | {:phoenix_slime, "~> 0.8", optional: true}, 57 | {:ecto, "~> 2.1", optional: true}, 58 | {:postgrex, "~> 0.13", optional: true}, 59 | {:gettext, "~> 0.11"}, 60 | {:cowboy, ">= 1.0.0"}, 61 | {:erlexec, "~> 1.7", runtime: false}, 62 | {:decorator, "~> 1.2"}, 63 | {:temp, "~> 0.4"}, 64 | {:ex_doc, ">= 0.0.0", only: :dev} 65 | ] 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 3 | "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, optional: false]}]}, 4 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 5 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, 6 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], []}, 7 | "decorator": {:hex, :decorator, "1.2.3", "258681ae943e57bd92d821ea995e3994b4e0b62ae8404b5d892cb8b23b55b050", [:mix], []}, 8 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], []}, 9 | "ecto": {:hex, :ecto, "2.2.9", "031d55df9bb430cb118e6f3026a87408d9ce9638737bda3871e5d727a3594aae", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, 10 | "erlexec": {:hex, :erlexec, "1.7.4", "f69091121625bc93e6dad4bb1b131e3e15bcb717ec40ec77c1de77b6823cc909", [:rebar3], []}, 11 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}]}, 12 | "file_system": {:hex, :file_system, "0.2.4", "f0bdda195c0e46e987333e986452ec523aed21d784189144f647c43eaf307064", [:mix], []}, 13 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 14 | "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], []}, 15 | "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], []}, 16 | "phoenix": {:hex, :phoenix, "1.3.2", "2a00d751f51670ea6bc3f2ba4e6eb27ecb8a2c71e7978d9cd3e5de5ccf7378bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, optional: false]}]}, 17 | "phoenix_html": {:hex, :phoenix_html, "2.11.1", "77b6f7fbd252168c6ec4f573de648d37cc5258cda13266ef001fbf99267eb6f3", [:mix], [{:plug, "~> 1.5", [hex: :plug, optional: false]}]}, 18 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.3", "1d178429fc8950b12457d09c6afec247bfe1fcb6f36209e18fbb0221bdfe4d41", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, optional: false]}]}, 19 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [:mix], []}, 20 | "phoenix_slime": {:hex, :phoenix_slime, "0.9.0", "1dbebe18757d57cfd2e62314c04c17d9acc7d31e2ca9387dd3fdeebe52fbd4bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: false]}, {:phoenix, "~> 1.3-rc", [hex: :phoenix, optional: false]}, {:phoenix_html, "~> 2.6", [hex: :phoenix_html, optional: false]}, {:slime, "~> 0.16", [hex: :slime, optional: false]}]}, 21 | "plug": {:hex, :plug, "1.5.0", "224b25b4039bedc1eac149fb52ed456770b9678bbf0349cdd810460e1e09195b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 22 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, 23 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 24 | "postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, 25 | "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], []}, 26 | "slime": {:hex, :slime, "0.16.0", "4f9c677ca37b2817cd10422ecb42c524fe904d3630acf242b81dfe189900272a", [:mix], []}, 27 | "temp": {:hex, :temp, "0.4.4", "da4524a102db9431f96b2b244777017eb077df3db344957cd1d10705feb25337", [:mix], []}, 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "brunch build --production", 6 | "watch": "brunch watch --stdin" 7 | }, 8 | "dependencies": { 9 | "bootstrap-sass": "^3.3.7", 10 | "copycat-brunch": "^1.1.0", 11 | "css-reset-and-normalize-sass": "^0.1.2", 12 | "jquery": "^3.2.1", 13 | "phoenix": "file:deps/phoenix", 14 | "prismjs": "^1.6.0", 15 | "sass-brunch": "^2.10.4", 16 | "xterm": "^2.8.0" 17 | }, 18 | "devDependencies": { 19 | "babel-brunch": "~6.0.0", 20 | "brunch": "2.7.4", 21 | "clean-css-brunch": "~2.0.0", 22 | "css-brunch": "~2.0.0", 23 | "javascript-brunch": "~2.0.0", 24 | "uglify-js-brunch": "~2.0.1" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here as no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /priv/static/fonts/bootstrap/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/priv/static/fonts/bootstrap/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /priv/static/fonts/bootstrap/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/priv/static/fonts/bootstrap/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /priv/static/fonts/bootstrap/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/priv/static/fonts/bootstrap/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /priv/static/fonts/bootstrap/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/priv/static/fonts/bootstrap/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /screenshots/breakpoint_session.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/breakpoint_session.png -------------------------------------------------------------------------------- /screenshots/breakpoints.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/breakpoints.png -------------------------------------------------------------------------------- /screenshots/conn_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/conn_details.png -------------------------------------------------------------------------------- /screenshots/ecto_queries.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/ecto_queries.png -------------------------------------------------------------------------------- /screenshots/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/history.png -------------------------------------------------------------------------------- /screenshots/history_loaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/history_loaded.png -------------------------------------------------------------------------------- /screenshots/logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/logs.png -------------------------------------------------------------------------------- /screenshots/timings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/timings.png -------------------------------------------------------------------------------- /screenshots/toolbar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/toolbar.gif -------------------------------------------------------------------------------- /screenshots/toolbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kagux/ex_debug_toolbar/d3ba4a25a873b03883a5ff18bc50205bf53bc5fd/screenshots/toolbar.png -------------------------------------------------------------------------------- /test/channels/breakpoint_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.BreakpointChannelTest do 2 | use ExDebugToolbar.ChannelCase, async: true 3 | 4 | alias ExDebugToolbar.BreakpointChannel 5 | alias ExDebugToolbar.ToolbarView 6 | require ExDebugToolbar 7 | 8 | setup :start_request 9 | 10 | test "joining and interacting with breakpoint" do 11 | ExDebugToolbar.pry 12 | :timer.sleep 500 13 | {:ok, request} = get_request() 14 | breakpoint = request.breakpoints.entries |> Map.values |> hd 15 | topic = "breakpoint:#{ToolbarView.breakpoint_uuid(request, breakpoint)}" 16 | 17 | # initial output 18 | {:ok, _, socket} = socket() |> subscribe_and_join(BreakpointChannel, topic, %{}) 19 | 20 | # echo input 21 | push socket, "input", %{"input" => "€"} 22 | # can't figure out how to assert echo, as it's different every time 23 | # but it still goes through the stack to ensure it works 24 | end 25 | 26 | test "it returns error on join if breakpoint doesn't exist" do 27 | topic = "breakpoint:invalid_id" 28 | assert {:error, %{reason: _}} = socket() |> subscribe_and_join(BreakpointChannel, topic, %{}) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/channels/toolbar_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.ToolbarChannelTest do 2 | use ExDebugToolbar.ChannelCase, async: true 3 | alias ExDebugToolbar.ToolbarChannel 4 | 5 | setup :start_request 6 | 7 | describe "join" do 8 | test "it returns stopped request upon joining toolbar channel" do 9 | stop_request(@request_id) 10 | {:ok, payload, _} = socket() |> join(ToolbarChannel, "toolbar:request:#{@request_id}", %{}) 11 | assert %{} = payload 12 | assert payload |> Map.has_key?(:html) 13 | assert payload.uuid == @request_id 14 | end 15 | 16 | test "it returns pending message if request is still running" do 17 | {:ok, payload, _} = socket() |> join(ToolbarChannel, "toolbar:request:#{@request_id}", %{}) 18 | assert :pending == payload 19 | end 20 | 21 | test "it returns an error if request could not be retrieved" do 22 | {:error, error} = socket() |> join(ToolbarChannel, "toolbar:request:wrong_id", %{}) 23 | assert %{reason: :not_found} = error 24 | end 25 | end 26 | 27 | describe "broadcast_request/1" do 28 | setup do 29 | @endpoint.subscribe("toolbar:request:#{@request_id}") 30 | end 31 | 32 | test "it broadcasts request" do 33 | ToolbarChannel.broadcast_request() 34 | assert_broadcast "request:ready", %{id: @request_id} 35 | end 36 | 37 | test "it does nothing when request does not exist" do 38 | delete_request(@request_id) 39 | ToolbarChannel.broadcast_request() 40 | refute_broadcast "request:ready", %{} 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/fixtures/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Fixtures.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :ex_debug_toolbar 3 | use ExDebugToolbar.Phoenix 4 | require Logger 5 | import Plug.Conn 6 | 7 | plug Plug.RequestId 8 | 9 | 10 | plug :tracked_plug 11 | 12 | def tracked_plug(conn, _) do 13 | ExDebugToolbar.record_event "test_request", fn -> 14 | Logger.debug "log entry" 15 | if timeout = conn.assigns[:timeout], do: :timer.sleep timeout 16 | conn 17 | |> Plug.Conn.assign(:called?, true) 18 | |> send_response 19 | end 20 | end 21 | 22 | defp send_response(%{assigns: %{error: :no_route}} = conn) do 23 | raise Phoenix.Router.NoRouteError, conn: conn, router: __MODULE__ 24 | end 25 | 26 | defp send_response(%{assigns: %{error: :exception}}) do 27 | raise RuntimeError, "just some runtime error" 28 | end 29 | 30 | defp send_response(conn) do 31 | conn 32 | |> put_resp_content_type("text/html") 33 | |> send_resp(200, "") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/fixtures/templates/eex_template.html.eex: -------------------------------------------------------------------------------- 1 |
Hello world!
2 | -------------------------------------------------------------------------------- /test/fixtures/templates/exs_template.html.exs: -------------------------------------------------------------------------------- 1 | "
Hello world!
" 2 | -------------------------------------------------------------------------------- /test/fixtures/templates/slim_template.html.slim: -------------------------------------------------------------------------------- 1 | . Hello world! 2 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/breakpoint/uuid_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Breakpoints.UUIDTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Breakpoint.UUID 4 | 5 | test "serializing and unserializing uuid returns the same value" do 6 | uuid = %UUID{request_id: "asdaf", breakpoint_id: "5"} 7 | {:ok, unserialized} = UUID.from_string(to_string(uuid)) 8 | assert uuid == unserialized 9 | end 10 | 11 | test "returns error when it cannot be unserialized" do 12 | assert {:error, _} = UUID.from_string("invalid") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/collector/conn_collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.ConnCollectorTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | alias ExDebugToolbar.Collector.ConnCollector, as: Collector 4 | use Plug.Test 5 | import Plug.Conn 6 | 7 | setup :start_request 8 | 9 | test "it collects conn data on response without body" do 10 | conn = conn(:get, "/path") 11 | |> Collector.call(%{}) 12 | |> send_resp(200, "body") 13 | 14 | sent_conn = %{conn | state: :set, resp_body: nil} 15 | 16 | assert {:ok, request} = get_request() 17 | assert sent_conn == request.conn 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/collector/ecto_collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.EctoCollectorTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | alias ExDebugToolbar.Collector.EctoCollector, as: Collector 4 | alias ExDebugToolbar.Data.Timeline.Event 5 | 6 | setup :start_request 7 | 8 | test "adds a query execution event" do 9 | %Ecto.LogEntry{decode_time: 5,query_time: 10, queue_time: 15} |> Collector.log 10 | assert {:ok, request} = get_request() 11 | assert request.timeline.events |> Enum.any? 12 | assert request.timeline.duration == 30 13 | assert %Event{name: "ecto.query"} = request.timeline.events |> hd 14 | end 15 | 16 | test "adds query to ecto queries collection" do 17 | %Ecto.LogEntry{query: "query"} |> Collector.log 18 | assert {:ok, request} = get_request() 19 | assert request.ecto |> length == 1 20 | assert {%{query: "query"}, _, _} = request.ecto |> hd 21 | end 22 | 23 | test "it marks query as inline when there is no caller_pid" do 24 | %Ecto.LogEntry{query: "query"} |> Collector.log 25 | assert {:ok, request} = get_request() 26 | assert {_, _, :inline} = request.ecto |> hd 27 | end 28 | 29 | test "it marks query as inline when caller_pid is same process" do 30 | %Ecto.LogEntry{query: "query"} |> Map.put(:caller_pid, self()) |> Collector.log 31 | assert {:ok, request} = get_request() 32 | assert {_, _, :inline} = request.ecto |> hd 33 | end 34 | 35 | test "it does not keep query result rows" do 36 | entry = %Ecto.LogEntry{ 37 | query: "query", 38 | result: {:ok, %Postgrex.Result{ rows: [[:user]]}} 39 | } 40 | Collector.log entry 41 | assert {:ok, request} = get_request() 42 | {saved_entry, _, _} = request.ecto |> hd 43 | assert {:ok, result} = saved_entry.result 44 | assert result.rows == [] 45 | end 46 | 47 | test "it does not change result when it is nil" do 48 | %Ecto.LogEntry{query: "query", result: nil} |> Collector.log 49 | assert {:ok, request} = get_request() 50 | assert {%{result: nil}, _, _} = request.ecto |> hd 51 | end 52 | 53 | test "it handles query with an error" do 54 | entry = %Ecto.LogEntry{ 55 | query: "query", 56 | result: {:error, %Postgrex.Error{}} 57 | } 58 | Collector.log entry 59 | assert {:ok, request} = get_request() 60 | assert {%{result: {:error, _}}, _, _} = request.ecto |> hd 61 | end 62 | 63 | test "it handles streamed query" do 64 | entry = %Ecto.LogEntry{ 65 | query: "query", 66 | result: {:ok, %Postgrex.Cursor{ 67 | connection_id: 6837, 68 | max_rows: 500, 69 | portal: "NMR", 70 | ref: :erlang.monitor(:process ,self()) 71 | }} 72 | } 73 | Collector.log entry 74 | assert {:ok, request} = get_request() 75 | {saved_entry, _, _} = request.ecto |> hd 76 | assert {:ok, result} = saved_entry.result 77 | assert result.max_rows == 500 78 | end 79 | 80 | test "it converts binary ecto uuid to a string" do 81 | uuid = <<134, 8, 204, 149, 179, 187, 75, 177, 186, 76, 144, 162, 54, 243, 218, 130>> 82 | %Ecto.LogEntry{query: "query", params: [uuid]} |> Collector.log 83 | assert {:ok, request} = get_request() 84 | assert {%{params: ["8608cc95-b3bb-4bb1-ba4c-90a236f3da82"]}, _, _} = request.ecto |> hd 85 | end 86 | 87 | test "it replaces binary with a placeholder when it's not a uuid" do 88 | uuid = <<1, 0>> 89 | %Ecto.LogEntry{query: "query", params: [uuid]} |> Collector.log 90 | assert {:ok, request} = get_request() 91 | assert {%{params: ["__BINARY__"]}, _, _} = request.ecto |> hd 92 | end 93 | 94 | test "the order of params in a query is preserved" do 95 | %Ecto.LogEntry{query: "query", params: [1, 2, 3]} |> Collector.log 96 | assert {:ok, request} = get_request() 97 | assert {%{params: [1, 2, 3]}, _, _} = request.ecto |> hd 98 | end 99 | 100 | test "it returns unmodified entry" do 101 | entry = %Ecto.LogEntry{ 102 | query: "query", 103 | result: {:ok, %Postgrex.Result{ rows: [[:user]]}} 104 | } 105 | assert entry == Collector.log(entry) 106 | end 107 | 108 | describe "parallel preload" do 109 | setup do 110 | pid = self() 111 | spawn fn -> 112 | %Ecto.LogEntry{query: "query", query_time: 10} 113 | |> Map.put(:caller_pid, pid) 114 | |> Collector.log 115 | send pid, :done 116 | end 117 | result = receive do 118 | :done -> :ok 119 | after 120 | 200 -> :error 121 | end 122 | {:ok, request} = get_request() 123 | 124 | {result, request: request} 125 | end 126 | 127 | test "adds query to correct request when it's has caller_pid", context do 128 | assert context.request.ecto |> length > 0 129 | end 130 | 131 | test "it adds this query to timeline without duration", context do 132 | assert context.request.timeline.events |> length == 1 133 | assert context.request.timeline.duration == 10 134 | end 135 | 136 | test "it marks query as parallel", context do 137 | assert {_, _, :parallel} = context.request.ecto |> hd 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/collector/instrumentation_collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.InstrumentationCollectorTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | alias ExDebugToolbar.Collector.InstrumentationCollector, as: Collector 4 | 5 | describe "ex_debug_toolbar" do 6 | setup do 7 | conn = %Plug.Conn{} |> Plug.Conn.put_private(:request_id, @request_id) 8 | on_exit fn -> delete_request(@request_id) end 9 | 10 | {:ok, %{conn: conn}} 11 | end 12 | 13 | test "it starts request on start", context do 14 | Collector.ex_debug_toolbar(:start, %{}, %{conn: context.conn}) 15 | assert {:ok, request} = get_request(@request_id) 16 | assert request.uuid == @request_id 17 | assert request.pid == self() 18 | assert %NaiveDateTime{} = request.created_at 19 | end 20 | 21 | test "it stops request on stop", context do 22 | call_collector(&Collector.ex_debug_toolbar/3, context: %{conn: context.conn}) 23 | assert {:ok, request} = get_request(@request_id) 24 | assert request.stopped? == true 25 | end 26 | end 27 | 28 | describe "phoenix_controller_call" do 29 | setup :start_request 30 | 31 | test "it records a phoenix controller event" do 32 | call_collector(&Collector.phoenix_controller_call/3, duration: 19) 33 | assert {:ok, request} = get_request(@request_id) 34 | event = find_event(request.timeline, "controller.call") 35 | assert event 36 | assert event.duration == 19 37 | end 38 | end 39 | 40 | describe "phoenix_controller_render" do 41 | setup :start_request 42 | 43 | test "it records a phoenix render event" do 44 | call_collector(&Collector.phoenix_controller_render/3, duration: 7) 45 | assert {:ok, request} = get_request(@request_id) 46 | event = find_event(request.timeline, "controller.render") 47 | assert event 48 | assert event.duration == 7 49 | end 50 | end 51 | 52 | defp call_collector(function, opts) do 53 | opts = [context: %{}, duration: 0] |> Keyword.merge(opts) 54 | function.(:start, %{}, opts[:context]) 55 | |> (&function.(:stop, opts[:duration], &1)).() 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/collector/logger_collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.LoggerCollectorTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | alias ExDebugToolbar.Collector.LoggerCollector, as: Collector 4 | require Logger 5 | 6 | setup_all do 7 | Logger.add_backend(Collector) 8 | on_exit fn -> 9 | Logger.remove_backend(Collector) 10 | end 11 | end 12 | 13 | setup :start_request 14 | 15 | test "it collects logs from logger" do 16 | Logger.metadata(request_id: @request_id) 17 | Logger.debug "log entry" 18 | {:ok, request} = get_request() 19 | assert request.logs |> length > 0 20 | assert request.logs |> Enum.find(&(&1.message) == "log entry") 21 | end 22 | 23 | test "it does nothing when request_id is missing" do 24 | Logger.debug "log entry" 25 | {:ok, request} = get_request() 26 | assert request.logs == [] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/collector/template_collector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Collector.TemplateTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | alias ExDebugToolbar.Template.{EExEngine, ExsEngine, SlimEngine} 4 | alias ExDebugToolbar.Data.Timeline.Event 5 | 6 | setup :start_request 7 | 8 | test "it compiles eex template" do 9 | compiled_template = compile_template(EExEngine, "eex_template.html.eex") 10 | assert compiled_template == {"
Hello world!
\n", []} 11 | end 12 | 13 | test "it compiles exs template" do 14 | compiled_template = compile_template(ExsEngine, "exs_template.html.exs") 15 | assert compiled_template == {"
Hello world!
", []} 16 | end 17 | 18 | test "it compiles slim template" do 19 | compiled_template = compile_template(SlimEngine, "slim_template.html.slim") 20 | assert compiled_template == {{:safe, ["" | "
Hello world!
"]}, []} 21 | end 22 | 23 | test "it tracks render time" do 24 | compile_template("eex_template.html.eex") 25 | assert {:ok, request} = get_request() 26 | timeline = request.timeline 27 | assert timeline.duration > 0 28 | assert %Event{name: "template#test/fixtures/templates/eex_template.html.eex"} = timeline.events |> hd 29 | end 30 | 31 | def compile_template(engine \\ EExEngine, name) do 32 | "test/fixtures/templates/#{name}" 33 | |> engine.compile(name) 34 | |> Code.eval_quoted 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/data/breakpoints_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.BreakpointsTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Data.{BreakpointCollection, Collection} 4 | alias ExDebugToolbar.Breakpoint 5 | 6 | describe "collection protocol" do 7 | test "adding breakpoints" do 8 | collection = %BreakpointCollection{} 9 | |> Collection.add(%Breakpoint{}) 10 | 11 | assert collection.count == 1 12 | assert collection.entries |> Enum.count == 1 13 | end 14 | 15 | test "it ignores breakpoints above the threshold" do 16 | collection = Enum.reduce( 17 | 1..4, 18 | %BreakpointCollection{}, 19 | fn(id, acc) -> Collection.add(acc, %Breakpoint{id: id}) end 20 | ) 21 | 22 | assert collection.count == 3 23 | assert collection.entries |> Enum.count == 3 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/data/conn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.ConnTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Data.Collection 4 | alias Plug.Conn 5 | 6 | describe "collection protocol" do 7 | setup do 8 | conn = %Plug.Conn{ 9 | } 10 | {:ok, conn: conn} 11 | end 12 | 13 | test "add/2 replaces conn" do 14 | conn = %Conn{request_path: "/"} 15 | assert Collection.add(%Conn{}, conn) == conn 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/data/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.ListTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Data.Collection 4 | 5 | describe "collection protocol" do 6 | test "add/2 prepands values to the collection" do 7 | assert Collection.add([:bar], :foo) == [:foo, :bar] 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/data/map_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.MapTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Data.Collection 4 | 5 | describe "collection protocol" do 6 | test "add/2 sets values in the collection" do 7 | assert Collection.add(%{}, %{foo: :bar}) == %{foo: :bar} 8 | end 9 | 10 | test "add/2 overwrites collection values" do 11 | collection = %{key: "old value", foo: :bar} 12 | updated_collection = Collection.add(collection, %{key: "new value"}) 13 | assert updated_collection == %{key: "new value", foo: :bar} 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/data/timeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Data.TimelineTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Data.Timeline.{Event} 4 | alias ExDebugToolbar.Data.{Timeline, Collection} 5 | 6 | describe "events management" do 7 | test "tracks events" do 8 | timeline = 9 | %Timeline{} 10 | |> Timeline.start_event("name") 11 | |> Timeline.finish_event("name") 12 | 13 | assert timeline.events |> length == 1 14 | event = timeline.events |> List.first 15 | 16 | assert %Event{} = event 17 | assert event.name == "name" 18 | assert event.duration == 0 19 | end 20 | 21 | test "optionally accepts precalculated event duration" do 22 | timeline = %Timeline{} 23 | |> Timeline.start_event("A") 24 | |> Timeline.finish_event("A", duration: 1000) 25 | 26 | assert %{duration: 1000} = timeline.events |> hd 27 | assert timeline.duration == 1000 28 | end 29 | 30 | test "optionally accepts start and finish timestamps" do 31 | timeline = %Timeline{} 32 | |> Timeline.start_event("A", timestamp: 1000) 33 | |> Timeline.finish_event("A", timestamp: 1050) 34 | 35 | assert %{duration: 50} = timeline.events |> hd 36 | assert timeline.duration == 50 37 | end 38 | 39 | test "accepts nested events" do 40 | timeline = 41 | %Timeline{} 42 | |> Timeline.start_event("A") 43 | |> Timeline.start_event("A-B") 44 | |> Timeline.finish_event("A-B") 45 | |> Timeline.start_event("A-B") 46 | |> Timeline.finish_event("A-B") 47 | |> Timeline.finish_event("A") 48 | |> Timeline.start_event("C") 49 | |> Timeline.start_event("C-D") 50 | |> Timeline.finish_event("C-D") 51 | |> Timeline.start_event("C-E") 52 | |> Timeline.finish_event("C-E") 53 | |> Timeline.finish_event("C") 54 | 55 | [first_event, second_event] = timeline.events 56 | assert first_event.name == "C" 57 | assert first_event.events |> Enum.at(0) |> Map.fetch!(:name) == "C-E" 58 | assert first_event.events |> Enum.at(1) |> Map.fetch!(:name) == "C-D" 59 | 60 | assert second_event.name == "A" 61 | assert second_event.events |> Enum.at(0) |> Map.fetch!(:name) == "A-B" 62 | assert second_event.events |> Enum.at(1) |> Map.fetch!(:name) == "A-B" 63 | end 64 | 65 | test "raises an error when closing an event that is not open" do 66 | assert_raise RuntimeError, fn -> 67 | %Timeline{} 68 | |> Timeline.start_event("A") 69 | |> Timeline.finish_event("B") 70 | end 71 | assert_raise RuntimeError, fn -> 72 | %Timeline{} 73 | |> Timeline.start_event("A") 74 | |> Timeline.start_event("B") 75 | |> Timeline.finish_event("C") 76 | end 77 | end 78 | 79 | test "raises an error when closing an event that is not the last one opened" do 80 | assert_raise RuntimeError, fn -> 81 | %Timeline{} 82 | |> Timeline.start_event("A") 83 | |> Timeline.start_event("C") 84 | |> Timeline.finish_event("A") 85 | end 86 | end 87 | 88 | test "adds an event that already happened" do 89 | timeline = %Timeline{} |> Timeline.add_finished_event("A", 5000) 90 | assert timeline.events |> length == 1 91 | assert %Event{name: "A"} = timeline.events |> hd 92 | assert timeline.duration == 5000 93 | end 94 | end 95 | 96 | describe "collection protocol" do 97 | test "passing :start_event action to add/2 starts new event" do 98 | timeline = %Timeline{} 99 | |> Collection.add({:start_event, "event", 12345}) 100 | |> Timeline.finish_event("event") 101 | 102 | assert timeline.events |> length == 1 103 | assert %Event{name: "event", started_at: 12345} = timeline.events |> hd 104 | end 105 | 106 | test "passing :finish_event action to add/2 with duration finishes event with that duration" do 107 | timeline = %Timeline{} 108 | |> Timeline.start_event("event") 109 | |> Collection.add({:finish_event, "event", nil, 5}) 110 | 111 | assert timeline.events |> length == 1 112 | assert %Event{name: "event", duration: 5} = timeline.events |> hd 113 | end 114 | 115 | test "passing :finish_event action to add/2 with timestamp finishes event with relative duration" do 116 | timeline = %Timeline{} 117 | |> Timeline.start_event("event", timestamp: 100) 118 | |> Collection.add({:finish_event, "event", 125, nil}) 119 | 120 | assert timeline.events |> length == 1 121 | assert %Event{name: "event", duration: 25} = timeline.events |> hd 122 | end 123 | 124 | test "passing :add_finished_event action to add/2 adds event with duration" do 125 | timeline = %Timeline{} 126 | |> Collection.add({:add_finished_event, "event", 5}) 127 | 128 | assert timeline.events |> length == 1 129 | assert %Event{name: "event", duration: 5} = timeline.events |> hd 130 | end 131 | end 132 | 133 | describe "get_all_events/1" do 134 | test "returns all events in a list" do 135 | timeline = %Timeline{events: [ 136 | %Event{name: "depth1", events: [ 137 | %Event{name: "depth2"} 138 | ]} 139 | ]} 140 | event_names = Timeline.get_all_events(timeline) |> Enum.map(&(&1.name)) 141 | assert event_names == ["depth1", "depth2"] 142 | end 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/database/request_repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Database.RequestRepoTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExDebugToolbar.Test.Support.RequestHelpers 5 | alias ExDebugToolbar.Database.RequestRepo 6 | alias ExDebugToolbar.Request 7 | 8 | @request_id "request_id" 9 | 10 | setup do 11 | :mnesia.system_info(:tables) |> Enum.each(&:mnesia.clear_table/1) 12 | request = %Request{uuid: @request_id, pid: self(), logs: [:foo]} 13 | {:ok, %{request: request}} 14 | end 15 | 16 | test "insert/1 creates new request record", context do 17 | assert :ok = RequestRepo.insert(context.request) 18 | assert :mnesia.table_info(Request, :size) == 1 19 | end 20 | 21 | describe "get/1" do 22 | test "returns request by id", context do 23 | :ok = RequestRepo.insert(context.request) 24 | assert {:ok, context.request} == RequestRepo.get(@request_id) 25 | end 26 | 27 | test "returns request by pid", context do 28 | self_pid = self() 29 | pid = spawn fn -> 30 | request = %{context.request | pid: self()} 31 | :ok = RequestRepo.insert(request) 32 | send self_pid, :done 33 | end 34 | msg = receive do 35 | :done -> :ok 36 | after 37 | 200 -> :error 38 | end 39 | assert msg == :ok 40 | assert {:ok, request} = RequestRepo.get(pid) 41 | assert request.uuid == @request_id 42 | end 43 | 44 | test "get/1 returns error if request is missing" do 45 | assert {:error, :not_found} == RequestRepo.get(self()) 46 | assert {:error, :not_found} == RequestRepo.get("1") 47 | end 48 | end 49 | 50 | describe "update/3" do 51 | setup context do 52 | RequestRepo.insert(context.request) 53 | end 54 | 55 | test "updates request using map of changes" do 56 | assert :ok = RequestRepo.update(@request_id, %{logs: [:bar]}) 57 | assert {:ok, updated_request} = get_request(@request_id) 58 | assert updated_request.logs == [:bar] 59 | end 60 | 61 | test "updates request using function" do 62 | updater = fn %Request{} = r -> Map.put(r, :logs, [:bar]) end 63 | assert :ok = RequestRepo.update(@request_id, updater) 64 | assert {:ok, updated_request} = get_request(@request_id) 65 | assert updated_request.logs == [:bar] 66 | end 67 | 68 | test "acceps pid instead of id" do 69 | pid = self() 70 | spawn fn -> 71 | assert :ok = RequestRepo.update(pid, %{logs: [:bar]}) 72 | send pid, :done 73 | end 74 | msg = receive do 75 | :done -> :ok 76 | after 77 | 200 -> :error 78 | end 79 | assert msg == :ok 80 | assert {:ok, updated_request} = get_request(@request_id) 81 | assert updated_request.logs == [:bar] 82 | end 83 | 84 | test "it can execute a synchronous update" do 85 | updater = fn %Request{} = r -> 86 | :timer.sleep 10 87 | Map.put(r, :logs, [:bar]) 88 | end 89 | assert :ok = RequestRepo.update(@request_id, updater, async: false) 90 | assert {:ok, updated_request} = RequestRepo.get(@request_id) 91 | assert updated_request.logs == [:bar] 92 | end 93 | 94 | test "does not raise error if request is missing" do 95 | pid = Process.whereis RequestRepo 96 | assert :ok = RequestRepo.update("missing_request", %{logs: [:foo]}) 97 | :timer.sleep 10 98 | assert Process.whereis(RequestRepo) == pid 99 | end 100 | end 101 | 102 | describe "all/0" do 103 | test "returns empty list when there were no requests" do 104 | assert [] == RequestRepo.all 105 | end 106 | 107 | test "returns all requests" do 108 | pid_1 = spawn fn -> :ok end 109 | pid_2 = spawn fn -> :ok end 110 | requests = [%Request{pid: pid_1, uuid: 1}, %Request{pid: pid_2, uuid: 2}] 111 | for request <- requests do 112 | :ok = RequestRepo.insert(request) 113 | end 114 | assert requests == RequestRepo.all |> Enum.sort_by(&(&1.uuid)) 115 | end 116 | end 117 | 118 | describe "delete/1" do 119 | setup do 120 | pid_1 = spawn fn -> :ok end 121 | pid_2 = spawn fn -> :ok end 122 | requests = [%Request{pid: pid_1, uuid: 1}, %Request{pid: pid_2, uuid: 2}] 123 | for request <- requests do 124 | :ok = RequestRepo.insert(request) 125 | end 126 | {:ok, %{requests: requests, pids: [pid_1, pid_2]}} 127 | end 128 | 129 | test "deletes request by id", context do 130 | assert :ok = RequestRepo.delete(1) 131 | assert RequestRepo.all == context.requests |> tl 132 | end 133 | 134 | test "deletes request by pid", context do 135 | assert :ok = context.pids |> List.last |> RequestRepo.delete 136 | assert RequestRepo.all == context.requests |> Enum.reverse |> tl 137 | end 138 | 139 | test "it returns error if request doesn't exist" do 140 | assert :error = RequestRepo.delete("no_such_request") 141 | assert RequestRepo.all |> length == 2 142 | end 143 | end 144 | 145 | test "purge/0 removes all request" do 146 | :ok = RequestRepo.insert(%Request{uuid: 1}) 147 | :ok = RequestRepo.purge() 148 | assert RequestRepo.all == [] 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/decorator/noop_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Decorator.NoopTest do 2 | # disabled async as it interfereces with other tests by changing flags 3 | use ExUnit.Case, async: false 4 | 5 | defmodule Dummy do 6 | use ExDebugToolbar.Decorator.Noop 7 | 8 | @decorate noop_when_toolbar_disabled() 9 | def toolbar_disabled, do: :ok 10 | 11 | @decorate noop_when_toolbar_disabled({:error, :some_other_reason}) 12 | def toolbar_disabled_with_result, do: :ok 13 | 14 | @decorate noop_when_debug_mode_disabled() 15 | def debug_mode_disabled, do: :ok 16 | 17 | @decorate noop_when_debug_mode_disabled({:error, :not_debug}) 18 | def debug_mode_disabled_with_result, do: :ok 19 | end 20 | 21 | setup_all do 22 | on_exit fn -> 23 | Application.put_env(:ex_debug_toolbar, :enable, true) 24 | end 25 | end 26 | 27 | describe "noop_when_toolbar_disabled/1" do 28 | test "does not modify function when toolbar is enalbed" do 29 | Application.put_env(:ex_debug_toolbar, :enable, true) 30 | assert :ok = Dummy.toolbar_disabled() 31 | end 32 | 33 | test "returns error by default when toolbar is disabled" do 34 | Application.put_env(:ex_debug_toolbar, :enable, false) 35 | assert {:error, :toolbar_disabled} = Dummy.toolbar_disabled() 36 | end 37 | 38 | test "returns provided result when toolbar is disabled" do 39 | Application.put_env(:ex_debug_toolbar, :enable, false) 40 | assert {:error, :some_other_reason} = Dummy.toolbar_disabled_with_result() 41 | end 42 | end 43 | 44 | describe "noop_when_debug_mode_disabled/1" do 45 | test "does not modify function when debug mode is enabled" do 46 | Application.put_env(:ex_debug_toolbar, :debug, true) 47 | assert :ok = Dummy.debug_mode_disabled() 48 | end 49 | 50 | test "returns error by default when debug mode is disabled" do 51 | Application.put_env(:ex_debug_toolbar, :debug, false) 52 | assert {:error, :debug_mode_disabled} = Dummy.debug_mode_disabled() 53 | end 54 | 55 | test "returns provided result when debug mode is disabled" do 56 | Application.put_env(:ex_debug_toolbar, :debug, false) 57 | assert {:error, :not_debug} = Dummy.debug_mode_disabled_with_result() 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/logger_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.LoggerTest do 2 | use ExUnit.Case, async: false 3 | import ExUnit.CaptureLog 4 | alias ExDebugToolbar.Logger 5 | 6 | test "it logs when debug mode is enabled" do 7 | Application.put_env(:ex_debug_toolbar, :debug, true) 8 | 9 | assert capture_log(fn -> 10 | Logger.debug("msg") 11 | end) =~ "[ExDebugToolbar] msg" 12 | 13 | assert capture_log(fn -> 14 | Logger.debug(fn -> "fun msg" end) 15 | end) =~ "[ExDebugToolbar] fun msg" 16 | end 17 | 18 | test "it is mute when debug mode is disabled" do 19 | Application.put_env(:ex_debug_toolbar, :debug, false) 20 | 21 | assert capture_log(fn -> 22 | Logger.debug("msg") 23 | end) == "" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/phoenix_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.PhoenixTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | import Phoenix.ConnTest, only: [assert_error_sent: 2] 5 | import Supervisor.Spec 6 | import ExDebugToolbar.Test.Support.RequestHelpers 7 | alias ExDebugToolbar.Fixtures.Endpoint 8 | 9 | setup_all do 10 | children = [supervisor(Endpoint, [])] 11 | opts = [strategy: :one_for_one, name: ExDebugToolbarTest.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | :ok 14 | end 15 | 16 | test "it creates request and injects toolbar" do 17 | conn = make_request("/") 18 | assert {200, _, body} = sent_resp(conn) 19 | assert {:ok, request} = get_request() 20 | assert request.stopped? 21 | assert contains_toolbar?(body) 22 | end 23 | 24 | test "it executes existing plugs" do 25 | conn = make_request("/") 26 | assert conn.assigns[:called?] == true 27 | end 28 | 29 | test "it tracks execution time of all following plugs in pipeline" do 30 | make_request "/", timeout: 100 31 | assert {:ok, request} = get_request() 32 | assert request.timeline.duration > 70 * 1000 # not sure why 33 | end 34 | 35 | test "it creates request and injects toolbar on 404 errors" do 36 | {404, _, body} = assert_error_sent 404, fn -> 37 | make_request "/", error: :no_route 38 | end 39 | assert {:ok, request} = get_request() 40 | assert request.stopped? 41 | assert contains_toolbar?(body) 42 | end 43 | 44 | test "it creates request and injects toolbar on 500 errors" do 45 | {_, _, body} = assert_error_sent 500, fn -> 46 | make_request "/", error: :exception 47 | end 48 | assert {:ok, request} = get_request() 49 | assert request.stopped? 50 | assert contains_toolbar?(body) 51 | end 52 | 53 | test "it closes connection" do 54 | conn = make_request("/") 55 | assert Plug.Conn.get_resp_header(conn, "connection") == ["close"] 56 | end 57 | 58 | test "it removes glob params from connection" do 59 | conn = make_request("/") 60 | refute Map.has_key? conn.params, "glob" 61 | refute Map.has_key? conn.path_params, "glob" 62 | end 63 | 64 | test "it ignores request if it matches ignore_paths option" do 65 | Application.put_env(:ex_debug_toolbar, :ignore_paths, ["/ignore_me"]) 66 | conn = make_request("/ignore_me") 67 | assert {:error, :not_found} = get_request() 68 | assert conn.assigns[:called?] == true 69 | end 70 | 71 | describe "requests to __ex_debug_toolbar__" do 72 | setup do 73 | conn = make_request("/__ex_debug_toolbar__/js/toolbar.js") 74 | {:ok, conn: conn} 75 | end 76 | 77 | test "are not tracked" do 78 | assert {:error, :not_found} = get_request() 79 | end 80 | 81 | test "routed through ExDebugToolbar.Endpoint", context do 82 | assert %{phoenix_endpoint: ExDebugToolbar.Endpoint} = context.conn.private 83 | end 84 | end 85 | 86 | defp make_request(path, assigns \\ %{}) do 87 | conn(:get, path) 88 | |> put_req_header("accept", "text/html") 89 | |> Map.put(:assigns, Map.new(assigns)) 90 | |> Endpoint.call(%{}) 91 | end 92 | 93 | defp contains_toolbar?(html) do 94 | # cannot use simple String.contains/2 as it appears in code snippet and matches 95 | assert Regex.match? ~r/src=['"].*?toolbar.js['"]/, html 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/plug/close_conn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.CloseConnTest do 2 | use ExUnit.Case, async: true 3 | alias Plug.Conn 4 | alias ExDebugToolbar.Plug.CloseConn 5 | 6 | test "it closes the connection" do 7 | conn = %Conn{} |> CloseConn.call(%{}) 8 | assert Conn.get_resp_header(conn, "connection") == ["close"] 9 | end 10 | 11 | test "it closes the connection even if it was keep alive" do 12 | conn = %Conn{} 13 | |> Conn.put_req_header("connection", "keep-alive") 14 | |> CloseConn.call(%{}) 15 | assert Conn.get_resp_header(conn, "connection") == ["close"] 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/plug/code_injector_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.CodeInjectorTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | import Plug.Conn 5 | alias ExDebugToolbar.Plug.CodeInjector 6 | 7 | @default_conn_opts [status: 200, content_type: "text/html", body: "", path: "/", ex_debug_toolbar_ignore?: false] 8 | @js """ 9 | 15 | 16 | """ 17 | @css "\n" 18 | 19 | test "it adds js and css to html" do 20 | html = "" 21 | conn = conn_with_plug(body: html) 22 | expected_html = "#{@css}#{@js}" 23 | 24 | assert conn.resp_body == expected_html 25 | end 26 | 27 | test "it adds js and css to html of error response" do 28 | html = "" 29 | expected_html = "#{@css}#{@js}" 30 | for status <- [400, 404, 406, 500] do 31 | conn = conn_with_plug(body: html, status: status) 32 | assert conn.resp_body == expected_html 33 | end 34 | end 35 | 36 | test "it does nothing if there is no body tag" do 37 | html = "" 38 | conn = conn_with_plug(body: html) 39 | 40 | assert conn.resp_body == html 41 | end 42 | 43 | test "it does nothing if response is a redirect" do 44 | html = "" 45 | for status <- [301, 302] do 46 | conn = conn_with_plug(body: html, status: status) 47 | assert conn.resp_body == html 48 | end 49 | end 50 | 51 | test "it does nothing if response is not html" do 52 | json = "{\"var\": \"\"}" 53 | conn = conn_with_plug(body: json, content_type: "application/json") 54 | 55 | assert conn.resp_body == json 56 | end 57 | 58 | test "it supports html code as charlist" do 59 | html = '' 60 | conn = conn_with_plug(body: html) 61 | 62 | assert conn.resp_body == "#{@js}" 63 | end 64 | 65 | test "it does nothing if request is ignored" do 66 | html = "" 67 | conn = conn_with_plug(body: html, ex_debug_toolbar_ignore?: true) 68 | 69 | assert conn.resp_body == html 70 | end 71 | 72 | defp conn_with_plug(opts) do 73 | opts = Keyword.merge(@default_conn_opts, opts) 74 | conn(:get, opts[:path]) 75 | |> put_private(:request_id, "request_123") 76 | |> put_private(:ex_debug_toolbar_ignore?, opts[:ex_debug_toolbar_ignore?]) 77 | |> CodeInjector.call(%{}) 78 | |> put_resp_content_type(opts[:content_type]) 79 | |> send_resp(opts[:status], opts[:body]) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/plug/ignore_path_match_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.IgnorePathMatchTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias ExDebugToolbar.Plug.IgnorePathMatch 6 | 7 | setup do 8 | Application.delete_env(:ex_debug_toolbar, :ignore_paths) 9 | end 10 | 11 | test "it sets ex_debug_toolbar_ignore? to true if path matches exactly" do 12 | Application.put_env(:ex_debug_toolbar, :ignore_paths, ["/path"]) 13 | conn = make_request("/path") 14 | assert conn.private.ex_debug_toolbar_ignore? 15 | end 16 | 17 | test "it sets ex_debug_toolbar_ignore? to true if path does not match" do 18 | Application.put_env(:ex_debug_toolbar, :ignore_paths, ["/path"]) 19 | conn = make_request("/another_path") 20 | refute conn.private.ex_debug_toolbar_ignore? 21 | end 22 | 23 | test "it supports regular expressions" do 24 | Application.put_env(:ex_debug_toolbar, :ignore_paths, [~r/.*\.js/]) 25 | conn = make_request("/assets/app.js") 26 | assert conn.private.ex_debug_toolbar_ignore? 27 | end 28 | 29 | test "it supports multiple ignore paths" do 30 | Application.put_env(:ex_debug_toolbar, :ignore_paths, [~r/.*\.css/, "/ignore"]) 31 | 32 | conn = make_request("/assets/app.css") 33 | assert conn.private.ex_debug_toolbar_ignore? 34 | 35 | conn = make_request("/ignore") 36 | assert conn.private.ex_debug_toolbar_ignore? 37 | end 38 | 39 | test "it accepts default ignore_paths as options" do 40 | conn = make_request("/ignore", ignore_paths: ["/ignore"]) 41 | assert conn.private.ex_debug_toolbar_ignore? 42 | end 43 | 44 | test "env config takes precedence over default opts" do 45 | Application.put_env(:ex_debug_toolbar, :ignore_paths, []) 46 | conn = make_request("/ignore", ignore_paths: ["/ignore"]) 47 | refute conn.private.ex_debug_toolbar_ignore? 48 | end 49 | 50 | defp make_request(path, opts \\ []) do 51 | :get 52 | |> conn(path) 53 | |> IgnorePathMatch.call(opts) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/plug/remove_glob_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.RemoveGlobParamsTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias ExDebugToolbar.Plug.RemoveGlobParams 6 | 7 | test "removes glob from params" do 8 | conn = %Plug.Conn{params: %{"glob" => [], "foo" => "bar"}} 9 | updated_conn = RemoveGlobParams.call(conn, %{}) 10 | assert updated_conn.params == %{"foo" => "bar"} 11 | end 12 | 13 | test "removes glob from path_params" do 14 | conn = %Plug.Conn{path_params: %{"glob" => [], "foo" => "bar"}} 15 | updated_conn = RemoveGlobParams.call(conn, %{}) 16 | assert updated_conn.path_params == %{"foo" => "bar"} 17 | end 18 | 19 | test "it does not remove glob from anywhere if configured not to" do 20 | Application.put_env(:ex_debug_toolbar, :remove_glob_params, false) 21 | on_exit fn -> 22 | Application.put_env(:ex_debug_toolbar, :remove_glob_params, true) 23 | end 24 | conn = %Plug.Conn{ 25 | params: %{"glob" => [], "foo" => "bar"}, 26 | path_params: %{"glob" => [], "foo" => "bar"} 27 | } 28 | assert conn == RemoveGlobParams.call(conn, %{}) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/plug/request_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Plug.RequestIdTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | import Plug.Conn 5 | 6 | test "it sets request id in response headers" do 7 | [request_id] = build_conn() |> get_resp_header("x-request-id") 8 | assert request_id 9 | end 10 | 11 | test "it sets request id in conn private" do 12 | conn = build_conn() 13 | [request_id] = conn |> get_resp_header("x-request-id") 14 | assert conn.private.request_id == request_id 15 | end 16 | 17 | test "it updates request headers with request id" do 18 | conn = build_conn() 19 | assert conn |> get_req_header("x-request-id") == conn |> get_resp_header("x-request-id") 20 | end 21 | 22 | defp build_conn(opts \\ []) do 23 | conn(:get, "/path") |> ExDebugToolbar.Plug.RequestId.call(opts) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar/poison/encoder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Poison.EncoderTest do 2 | use ExUnit.Case, async: true 3 | alias Poison.Encoder 4 | 5 | test "tuple is encoded as lists" do 6 | assert encode({:foo, :bar}) == ~S(["foo","bar"]) 7 | end 8 | 9 | test "not loaded ecto association is encoded as null" do 10 | assert encode(%Ecto.Association.NotLoaded{}) == "null" 11 | end 12 | 13 | test "PID encoding" do 14 | assert self() |> encode |> is_bitstring 15 | end 16 | 17 | test "function encoding" do 18 | assert fn -> :ok end |> encode |> is_bitstring 19 | end 20 | 21 | test "regexp encoding" do 22 | assert ~r/\d/ |> encode == "\\d" 23 | end 24 | 25 | defp encode(value) do 26 | value |> Encoder.encode([]) |> to_string 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/lib/ex_debug_toolbar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbarTest do 2 | use ExDebugToolbar.CollectorCase, async: true 3 | require ExDebugToolbar 4 | 5 | 6 | describe "add_data/3" do 7 | setup :start_request 8 | 9 | test "it returns error on attempt to add to undefined collection" do 10 | assert {:error, :undefined_collection} = ExDebugToolbar.add_data(@request_id, :whoami, %{foo: :bar}) 11 | end 12 | 13 | test "it adds new data to defined collection" do 14 | ExDebugToolbar.add_data(@request_id, :conn, %Plug.Conn{request_path: "/path"}) 15 | {:ok, request} = get_request() 16 | assert request.conn.request_path == "/path" 17 | end 18 | end 19 | 20 | describe "stop_request/1" do 21 | setup :start_request 22 | 23 | test "marks request as stopped" do 24 | ExDebugToolbar.stop_request(@request_id) 25 | {:ok, request} = get_request() 26 | assert request.stopped? == true 27 | end 28 | end 29 | 30 | describe "pry/0" do 31 | setup :start_request 32 | 33 | test "adds new breakpoint" do 34 | bound_var = :bound_var 35 | # line 1 36 | # line 2 37 | ExDebugToolbar.pry 38 | # line 3 39 | # line 4 40 | 41 | {:ok, request} = get_request() 42 | breakpoints = request.breakpoints.entries |> Map.values 43 | 44 | assert breakpoints |> length == 1 45 | breakpoint = breakpoints |> hd 46 | assert breakpoint.file =~ "test/lib/ex_debug_toolbar_test.exs" 47 | assert breakpoint.line == 37 48 | assert breakpoint.binding[:bound_var] == :bound_var 49 | assert breakpoint.code_snippet == [ 50 | {" # line 1\n", 35}, 51 | {" # line 2\n", 36}, 52 | {" ExDebugToolbar.pry\n", 37}, 53 | {" # line 3\n", 38}, 54 | {" # line 4\n", 39} 55 | ] 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint ExDebugToolbar.Endpoint 25 | use ExDebugToolbar.CollectorCase 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/collector_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.CollectorCase do 2 | use ExUnit.CaseTemplate 3 | import ExDebugToolbar.Test.Support.RequestHelpers 4 | 5 | using do 6 | quote do 7 | @request_id Base.hex_encode32(:crypto.strong_rand_bytes(20), case: :lower) 8 | import ExDebugToolbar.Test.Support.RequestHelpers 9 | import ExDebugToolbar.Test.Support.Data.TimelineHelpers 10 | 11 | def start_request(_context \\ %{}) do 12 | ExDebugToolbar.start_request(@request_id) 13 | on_exit fn -> delete_request(@request_id) end 14 | end 15 | end 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | import ExDebugToolbar.Router.Helpers 24 | 25 | # The default endpoint for testing 26 | @endpoint ExDebugToolbar.Endpoint 27 | end 28 | end 29 | 30 | setup _tags do 31 | 32 | {:ok, conn: Phoenix.ConnTest.build_conn()} 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/data/timeline_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Test.Support.Data.TimelineHelpers do 2 | alias ExDebugToolbar.Data.Timeline 3 | 4 | def find_event(%Timeline{} = timeline, event_name), do: timeline.events |> find_event(event_name) 5 | def find_event([%{name: event_name} = event | _], event_name), do: event 6 | def find_event([event | events], event_name) do 7 | find_event(event.events, event_name) || find_event(events, event_name) 8 | end 9 | def find_event(_, _), do: nil 10 | end 11 | -------------------------------------------------------------------------------- /test/support/request_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Test.Support.RequestHelpers do 2 | alias ExDebugToolbar.Database.RequestRepo 3 | 4 | def wait_for_registry do 5 | :timer.sleep 20 6 | end 7 | 8 | def get_request do 9 | wait_for_registry() 10 | ExDebugToolbar.get_request() 11 | end 12 | 13 | def get_request(id) do 14 | wait_for_registry() 15 | ExDebugToolbar.get_request(id) 16 | end 17 | 18 | def delete_all_requests do 19 | :ok = RequestRepo.purge() 20 | end 21 | 22 | def delete_request(id) do 23 | ExDebugToolbar.delete_request(id) 24 | end 25 | 26 | def stop_request(id) do 27 | ExDebugToolbar.stop_request(id) 28 | wait_for_registry() 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:phoenix, :serve_endpoints, true) 2 | Application.stop(:ex_debug_toolbar) 3 | Application.ensure_started(:ex_debug_toolbar) 4 | ExUnit.start 5 | -------------------------------------------------------------------------------- /test/views/helpers/time_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.View.Helpers.TimeHelpersTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.View.Helpers.TimeHelpers 4 | 5 | @microsecond System.convert_time_unit(1, :micro_seconds, :native) 6 | 7 | describe "native_time_to_string/1" do 8 | test "microseconds" do 9 | assert TimeHelpers.native_time_to_string(10 * @microsecond) == "10µs" 10 | assert TimeHelpers.native_time_to_string(999 * @microsecond) == "999µs" 11 | end 12 | 13 | test "milliseconds" do 14 | assert TimeHelpers.native_time_to_string(1000 * @microsecond) == "1ms" 15 | assert TimeHelpers.native_time_to_string(5499 * @microsecond) == "5ms" 16 | assert TimeHelpers.native_time_to_string(5500 * @microsecond) == "6ms" 17 | end 18 | 19 | test "return N/A for nil value" do 20 | assert TimeHelpers.native_time_to_string(nil) == "N/A" 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/views/toolbar_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.ToolbarViewTest do 2 | use ExUnit.Case, async: true 3 | alias ExDebugToolbar.Request 4 | alias ExDebugToolbar.Data.{LogEntry, Timeline} 5 | alias Phoenix.View 6 | alias ExDebugToolbar.ToolbarView 7 | alias ExDebugToolbar.Breakpoint 8 | alias Plug.Conn 9 | 10 | describe "rendering" do 11 | test "it renders toolbar without errors" do 12 | assert %Request{} |> render |> is_bitstring 13 | end 14 | 15 | test "it renders toolbar with logs without errors" do 16 | request = %Request{logs: [%LogEntry{ 17 | level: :info, 18 | message: ["GET", 32, "/"], 19 | timestamp: {{2017, 6, 1}, {21, 44, 11, 482}} 20 | }]} 21 | assert request |> render |> is_bitstring 22 | end 23 | 24 | test "it renders toolbar with ecto queries without errors" do 25 | log_entry = %Ecto.LogEntry{ 26 | ansi_color: :cyan, 27 | decode_time: 40929, 28 | params: [1], 29 | query: "select * from users", 30 | query_time: 1550583, 31 | queue_time: 205640, 32 | source: "users", 33 | result: {:ok, %Postgrex.Result{ 34 | columns: ["id", "name", "inserted_at", "updated_at"], 35 | command: :select, 36 | connection_id: 2551, 37 | num_rows: 1, 38 | }} 39 | } 40 | duration = 15000 41 | request = %Request{ecto: [{log_entry, duration, :inline}]} 42 | assert request |> render |> is_bitstring 43 | end 44 | 45 | test "it renders toolbar with ecto query with nil queue and decode times" do 46 | log_entry = %Ecto.LogEntry{ 47 | decode_time: nil, 48 | query: "select * from users", 49 | query_time: 1550583, 50 | queue_time: nil, 51 | source: "users", 52 | result: {:ok, %Postgrex.Result{}} 53 | } 54 | duration = 15000 55 | request = %Request{ecto: [{log_entry, duration, :inline}]} 56 | assert request |> render |> is_bitstring 57 | end 58 | 59 | test "it renders toolbar with timeline" do 60 | timeline = %Timeline{ 61 | duration: 50, 62 | events: [%Timeline.Event{ 63 | name: "controller.call", 64 | duration: 5, 65 | events: [%Timeline.Event{ 66 | name: "controller.render", 67 | duration: 7, 68 | events: [%Timeline.Event{ 69 | name: "template#app.html", 70 | duration: 10 71 | }] 72 | }] 73 | }] 74 | } 75 | request = %Request{timeline: timeline} 76 | assert request |> render |> is_bitstring 77 | end 78 | 79 | test "it renders toolbar with timeline that has controller.call event only" do 80 | timeline = %Timeline{ 81 | duration: 50, 82 | events: [%Timeline.Event{ 83 | name: "controller.call", 84 | duration: 5, 85 | }] 86 | } 87 | request = %Request{timeline: timeline} 88 | assert request |> render |> is_bitstring 89 | end 90 | 91 | test "it renders toolbar with breakpoints" do 92 | breakpoint = %Breakpoint{ 93 | id: 1, 94 | line: 5, 95 | file: "test.ex", 96 | code_snippet: [{"a = [1, 2]", 5}], 97 | env: __ENV__, 98 | binding: binding(), 99 | inserted_at: NaiveDateTime.utc_now 100 | } 101 | assert %Request{} |> render(breakpoints: [breakpoint]) |> is_bitstring 102 | end 103 | 104 | test "it renders toolbar when conn has no layout" do 105 | assert %Request{conn: %Conn{assigns: %{layout: false}}} |> render |> is_bitstring 106 | end 107 | 108 | test "it renders toolbar when conn has no assigns" do 109 | assert %Request{conn: %Conn{assigns: nil}} |> render |> is_bitstring 110 | end 111 | end 112 | 113 | describe "#conn_status_color_class/1" do 114 | test "it converts conn status to color label" do 115 | assert ToolbarView.conn_status_color_class(%Conn{status: 101}) == "info" 116 | assert ToolbarView.conn_status_color_class(%Conn{status: 200}) == "success" 117 | assert ToolbarView.conn_status_color_class(%Conn{status: 500}) == "danger" 118 | assert ToolbarView.conn_status_color_class(%Conn{status: nil}) == "danger" 119 | end 120 | end 121 | 122 | describe "#history_row_color/1" do 123 | test "it sets color according to connection status" do 124 | assert ToolbarView.history_row_color(%Conn{status: 200}) == nil 125 | assert ToolbarView.history_row_color(%Conn{status: 101}) == "info" 126 | assert ToolbarView.history_row_color(%Conn{status: nil}) == "danger" 127 | end 128 | end 129 | 130 | describe "#collapse_history/1" do 131 | @conn %Conn{status: 200, method: "get", request_path: "/path"} 132 | @request %Request{uuid: 1, conn: @conn} 133 | 134 | test "groups similar consequent requests by status" do 135 | other_request = %{@request | uuid: 2, conn: %{@conn | status: 404}} 136 | history = [@request, other_request, other_request, @request] 137 | collapsed_history = [[@request], [other_request, other_request], [@request]] |> to_uuid 138 | 139 | assert history |> ToolbarView.collapse_history |> to_uuid == collapsed_history 140 | end 141 | 142 | test "groups similar consequent requests by method" do 143 | other_request = %{@request | uuid: 2, conn: %{@conn | method: "post"}} 144 | history = [@request, other_request, other_request, @request] 145 | collapsed_history = [[@request], [other_request, other_request], [@request]] |> to_uuid 146 | 147 | assert history |> ToolbarView.collapse_history |> to_uuid == collapsed_history 148 | end 149 | 150 | test "it group similar requests by path" do 151 | other_request = %{@request | uuid: 2, conn: %{@conn | request_path: "/other"}} 152 | history = [@request, @request, other_request, other_request] 153 | collapsed_history = [[@request, @request], [other_request, other_request]] |> to_uuid 154 | 155 | assert history |> ToolbarView.collapse_history |> to_uuid == collapsed_history 156 | end 157 | end 158 | 159 | describe "history_row_collapse_class/1" do 160 | test "it is not collapsed for first row" do 161 | assert ToolbarView.history_row_collapse_class(0) == "last-request" 162 | end 163 | 164 | test "it is collapsed and with a group number for other rows" do 165 | assert ToolbarView.history_row_collapse_class(1) == "prev-request" 166 | end 167 | end 168 | 169 | defp to_uuid(requests) when is_list(requests) do 170 | requests |> Enum.map(&to_uuid/1) 171 | end 172 | defp to_uuid(%Request{uuid: uuid}), do: uuid 173 | 174 | defp render(request, opts \\ []) do 175 | assigns = [ 176 | request: request, 177 | history: [request], 178 | breakpoints: Keyword.get(opts, :breakpoints, []) 179 | ] 180 | View.render_to_string ToolbarView, "show.html", assigns 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /web/channels/breakpoint_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.BreakpointChannel do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Web, :channel 5 | alias ExDebugToolbar.Breakpoint 6 | 7 | def join("breakpoint:" <> raw_breakpoint_id, _payload, socket) do 8 | with {:ok, uuid} <- Breakpoint.UUID.from_string(raw_breakpoint_id), 9 | {:ok, breakpoint} <- ExDebugToolbar.get_breakpoint(uuid), 10 | {:ok, iex} <- Breakpoint.start_iex(breakpoint, self()) 11 | do 12 | {:ok, assign(socket, :iex, iex)} 13 | else 14 | {:error, reason} -> {:error, %{reason: reason}} 15 | end 16 | end 17 | 18 | def handle_in("input", %{"input" => input}, socket) do 19 | Breakpoint.send_input_to_iex(socket.assigns[:iex], input) 20 | {:noreply, socket} 21 | end 22 | 23 | def handle_info({:output, output}, socket) do 24 | push(socket, "output", %{output: output}) 25 | {:noreply, socket} 26 | end 27 | 28 | def terminate(msg, socket) do 29 | Breakpoint.stop_iex(socket.assigns[:iex]) 30 | msg 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /web/channels/toolbar_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.ToolbarChannel do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Web, :channel 5 | alias ExDebugToolbar.{ToolbarView, Endpoint, Logger, Config} 6 | alias ExDebugToolbar.View.Helpers.TimeHelpers 7 | alias Phoenix.View 8 | 9 | def join("toolbar:request:" <> request_id = topic, _params, socket) do 10 | ExDebugToolbar.Endpoint.subscribe(topic) 11 | case ExDebugToolbar.get_request(request_id) do 12 | {:ok, %{stopped?: true} = request} -> 13 | Logger.debug("Request is complete, rendering toolbar") 14 | {:ok, build_payload(request), socket} 15 | {:ok, _} -> 16 | Logger.debug("Request is still being processed, pending") 17 | {:ok, :pending, socket} 18 | {:error, reason} -> 19 | Logger.debug("Error getting request: #{reason}") 20 | {:error, %{reason: reason}} 21 | end 22 | end 23 | 24 | def handle_out("request:ready" = event, %{id: request_id}, socket) do 25 | {:ok, request} = ExDebugToolbar.get_request(request_id) 26 | push socket, event, build_payload(request) 27 | {:noreply, socket} 28 | end 29 | 30 | def broadcast_request(id \\ self()) do 31 | case ExDebugToolbar.get_request(id) do 32 | {:ok, request} -> 33 | topic = "toolbar:request:#{request.uuid}" 34 | Logger.debug("Broadcasting that request #{request.uuid} is ready") 35 | Endpoint.broadcast(topic, "request:ready", %{id: request.uuid}) 36 | _ -> :error 37 | end 38 | end 39 | 40 | defp build_payload(request) do 41 | Logger.debug fn -> 42 | dump = inspect(request, pretty: true, safe: true, limit: :infinity) 43 | "Building paylod for request #{dump}" 44 | end 45 | {time, payload} = :timer.tc(fn -> do_build_payload(request) end) 46 | Logger.debug fn -> 47 | "Toolbar rendered in " <> TimeHelpers.native_time_to_string(time) 48 | end 49 | payload 50 | end 51 | 52 | defp do_build_payload(request) do 53 | %{ 54 | html: View.render_to_string(ToolbarView, "show.html", request: request, history: history()), 55 | uuid: request.uuid, 56 | request: (if Config.debug?(), do: request, else: nil) 57 | } 58 | end 59 | 60 | defp history() do 61 | ExDebugToolbar.get_all_requests() 62 | |> Enum.sort(&(NaiveDateTime.compare(&2.created_at, &1.created_at) == :lt)) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.UserSocket do 2 | @moduledoc false 3 | 4 | use Phoenix.Socket 5 | 6 | ## Channels 7 | channel "toolbar:*", ExDebugToolbar.ToolbarChannel 8 | channel "breakpoint:*", ExDebugToolbar.BreakpointChannel 9 | 10 | ## Transports 11 | transport :websocket, Phoenix.Transports.WebSocket 12 | # transport :longpoll, Phoenix.Transports.LongPoll 13 | 14 | # Socket params are passed from the client and can 15 | # be used to verify and authenticate a user. After 16 | # verification, you can put default assigns into 17 | # the socket that will be set for all channels, ie 18 | # 19 | # {:ok, assign(socket, :user_id, verified_user_id)} 20 | # 21 | # To deny connection, return `:error`. 22 | # 23 | # See `Phoenix.Token` documentation for examples in 24 | # performing token verification on connect. 25 | def connect(_params, socket) do 26 | {:ok, socket} 27 | end 28 | 29 | # Socket id's are topics that allow you to identify all sockets for a given user: 30 | # 31 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}" 32 | # 33 | # Would allow you to broadcast a "disconnect" event and terminate 34 | # all active sockets and channels for a given user: 35 | # 36 | # ExDebugToolbar.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) 37 | # 38 | # Returning `nil` makes this socket anonymous. 39 | def id(_socket), do: nil 40 | end 41 | -------------------------------------------------------------------------------- /web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Gettext do 2 | @moduledoc false 3 | 4 | use Gettext, otp_app: :ex_debug_toolbar 5 | end 6 | -------------------------------------------------------------------------------- /web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExDebugToolbar.Router do 2 | @moduledoc false 3 | 4 | use ExDebugToolbar.Web, :router 5 | end 6 | -------------------------------------------------------------------------------- /web/static/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /web/static/css/_reset.scss: -------------------------------------------------------------------------------- 1 | *::before { 2 | all: unset; 3 | } 4 | 5 | *::after { 6 | all: unset; 7 | } 8 | 9 | *::hover { 10 | all: unset; 11 | } 12 | 13 | *::visited { 14 | all: unset; 15 | } 16 | 17 | *::link { 18 | all: unset; 19 | } 20 | 21 | *::active { 22 | all: unset; 23 | } 24 | 25 | * { 26 | max-width: inherit; 27 | max-height: inherit; 28 | } 29 | 30 | .container-fluid * { 31 | background: inherit; 32 | -webkit-box-orient: unset; 33 | -webkit-box-direction: unset; 34 | } 35 | 36 | @import "reset-and-normalize"; 37 | -------------------------------------------------------------------------------- /web/static/css/_text_emphasis.scss: -------------------------------------------------------------------------------- 1 | // Typography 2 | 3 | // [converter] $parent hack 4 | @mixin text-emphasis-variant-bt4($parent, $color) { 5 | #{$parent} { 6 | color: $color !important; 7 | } 8 | a#{$parent}:hover, 9 | a#{$parent}:focus { 10 | color: darken($color, 10%) !important; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /web/static/css/toolbar.scss: -------------------------------------------------------------------------------- 1 | #ex-debug-toolbar { 2 | $panel-max-height: 200px; 3 | 4 | @import "_reset"; 5 | 6 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 7 | font-size: 14px; 8 | line-height: 1.42857; 9 | color: #333333; 10 | background-color: #fff; 11 | 12 | // namespacing to avoid conflicts 13 | @import "bootstrap"; 14 | @import "prismjs/themes/prism"; 15 | @import "prismjs/plugins/line-numbers/prism-line-numbers"; 16 | @import "prismjs/plugins/line-highlight/prism-line-highlight"; 17 | @import "xterm/dist/xterm"; 18 | @import "xterm/dist/addons/fullscreen/fullscreen"; 19 | 20 | // force text classes 21 | @import "_text_emphasis"; 22 | @include text-emphasis-variant-bt4('.text-primary', $brand-primary); 23 | @include text-emphasis-variant-bt4('.text-success', $state-success-text); 24 | @include text-emphasis-variant-bt4('.text-info', $state-info-text); 25 | @include text-emphasis-variant-bt4('.text-warning', $state-warning-text); 26 | @include text-emphasis-variant-bt4('.text-danger', $state-danger-text); 27 | 28 | // TOOLBAR 29 | 30 | .navbar { 31 | min-height: 30px; 32 | height: 30px; 33 | 34 | .container-fluid { 35 | display: block; 36 | } 37 | } 38 | 39 | .navbar-nav { 40 | display: block; 41 | } 42 | 43 | .navbar-nav.nav > li > span { 44 | @extend a; 45 | padding-left: 5px; 46 | padding-right: 5px; 47 | padding-top: 5px; 48 | cursor: pointer; 49 | 50 | &> i { 51 | margin-right: 5px; 52 | } 53 | } 54 | 55 | .navbar-brand { 56 | padding: 2px 2px; 57 | height: 30px; 58 | 59 | img { 60 | height: 100%; 61 | } 62 | } 63 | 64 | .toolbar-item { 65 | margin-right: 5px; 66 | 67 | .label { // FIX ME 68 | position: relative; 69 | top: -2px; 70 | } 71 | } 72 | 73 | // SLIDE UP PANELS 74 | 75 | .panel { 76 | margin-bottom: 0; 77 | bottom: 30px; 78 | display: none; 79 | 80 | .panel-body { 81 | padding: 5px 15px; 82 | max-height: $panel-max-height; 83 | overflow-y: auto; 84 | } 85 | 86 | .panel-heading { 87 | padding: 5px 15px; 88 | } 89 | 90 | .table { 91 | margin-top: 0; 92 | margin-bottom: 0; 93 | 94 | caption { 95 | padding-top: 4px; 96 | padding-bottom: 4px; 97 | } 98 | 99 | td, th { 100 | padding-left: 5px; 101 | padding-right: 5px; 102 | padding-top: 1px; 103 | padding-bottom: 1px; 104 | 105 | p { 106 | margin: 0; 107 | } 108 | } 109 | } 110 | } 111 | 112 | .nowrap { 113 | white-space:nowrap; 114 | } 115 | 116 | table { 117 | .hljs { 118 | background: unset; 119 | padding: unset; 120 | } 121 | } 122 | 123 | .pointer { 124 | cursor: pointer; 125 | } 126 | 127 | #terminal-container { 128 | width: auto; 129 | height: 480px; 130 | } 131 | 132 | .modal-top-action { 133 | margin-right: 10px; 134 | } 135 | 136 | .fullscreen { 137 | .modal-dialog { 138 | width: 100%; 139 | height: 100%; 140 | margin: 0; 141 | padding: 0; 142 | } 143 | 144 | .modal-content { 145 | height: auto; 146 | min-height: 100%; 147 | border-radius: 0; 148 | } 149 | 150 | #terminal-container { 151 | height: 100%; 152 | } 153 | } 154 | 155 | .xterm.fullscreen { 156 | top: 55px; 157 | } 158 | 159 | #code-snippet-container { 160 | margin-top: 40px; 161 | position: fixed; 162 | left: 50%; 163 | } 164 | 165 | #breakpoint .panel-body { 166 | height: $panel-max-height; 167 | } 168 | 169 | // Historic flag 170 | .historic-flag { 171 | display: none; 172 | 173 | .back-to-current-request { 174 | font-weight: bold; 175 | color: black; 176 | font-size: 0.85em; 177 | text-decoration: underline; 178 | } 179 | } 180 | .historic-request { 181 | .historic-flag { 182 | display: block; 183 | } 184 | 185 | .navbar { 186 | background-color: #ececdb; 187 | } 188 | } 189 | .history-point { 190 | cursor: pointer; 191 | 192 | &.active { 193 | cursor: default; 194 | } 195 | 196 | &.prev-request { 197 | display: none; 198 | } 199 | 200 | .history-collapse { 201 | display: none; 202 | } 203 | } 204 | 205 | } 206 | -------------------------------------------------------------------------------- /web/static/js/toolbar.js: -------------------------------------------------------------------------------- 1 | import {Socket} from 'phoenix'; 2 | import $ from './toolbar/jquery'; 3 | import Logger from './toolbar/logger'; 4 | import BreakpointsPanel from './toolbar/breakpoints_panel'; 5 | import HistoryPanel from './toolbar/history_panel'; 6 | import Prism from 'prismjs'; 7 | import 'prismjs/components/prism-elixir'; 8 | import 'prismjs/components/prism-sql'; 9 | import 'prismjs/plugins/normalize-whitespace/prism-normalize-whitespace'; 10 | import 'prismjs/plugins/line-numbers/prism-line-numbers'; 11 | import 'prismjs/plugins/line-highlight/prism-line-highlight'; 12 | 13 | class App { 14 | constructor(opts) { 15 | this.logger = new Logger(opts.debug) 16 | this.originalRequestId = opts.requestId; 17 | this.resetActivePanel(); 18 | this.socket = this.initSocket(); 19 | this.toolbar = $("
", {id: "ex-debug-toolbar"}); 20 | this.breakpointsPanel = new BreakpointsPanel(this.socket, this.toolbar); 21 | this.historyPanel = new HistoryPanel(this.toolbar, this.originalRequestId, this.render.bind(this)); 22 | $("body").append(this.toolbar); 23 | } 24 | 25 | render(requestId) { 26 | this.logger.debug('Rendering request', requestId) 27 | this.joinToolbarChannel(this.socket, requestId); 28 | } 29 | 30 | initSocket() { 31 | const socket = new Socket("/__ex_debug_toolbar__/socket"); 32 | socket.connect(); 33 | return socket; 34 | } 35 | 36 | joinToolbarChannel(socket, requestId) { 37 | this.logger.debug('Joining channel', requestId) 38 | const channel = socket.channel(`toolbar:request:${requestId}`) 39 | channel 40 | .join() 41 | .receive("ok", this.onChannelResponse.bind(this)) 42 | .receive("error", resp => { 43 | this.logger.debug('Unable to join channel', resp) 44 | }); 45 | 46 | channel.on("request:ready", this.onChannelResponse.bind(this)); 47 | } 48 | 49 | onChannelResponse(response){ 50 | if (response.html) { 51 | this.logger.debug('Received response from channel') 52 | this.renderToolbar(response); 53 | } else { 54 | this.logger.debug('Waiting for request to be processed') 55 | } 56 | } 57 | 58 | renderToolbar({html: html, uuid: uuid, request: request}){ 59 | this.logger.debug('Request data', request) 60 | const content = $('
').html(html); 61 | if (this.originalRequestId != uuid) { 62 | this.logger.debug('Historic request'); 63 | content.addClass("historic-request"); 64 | } 65 | this.toolbar.html(content); 66 | this.renderPanels(this.toolbar); 67 | this.renderPopovers(this.toolbar); 68 | this.breakpointsPanel.render(uuid); 69 | this.historyPanel.render(uuid); 70 | this.highlightCode(this.toolbar); 71 | } 72 | 73 | renderPanels(toolbar) { 74 | toolbar 75 | .mouseleave(this.hideActivePanel.bind(this)) 76 | .find('[data-toggle="panel"]') 77 | .hover(this.showPanel.bind(this)); 78 | 79 | toolbar 80 | .find('.panel-body') 81 | .on( 'mousewheel DOMMouseScroll', this.scrollDivOnly); 82 | } 83 | 84 | scrollDivOnly(event) { 85 | event.preventDefault(); 86 | const original = event.originalEvent; 87 | const delta = original.wheelDelta || -original.detail; 88 | this.scrollTop -= delta; 89 | } 90 | 91 | hideActivePanel() { 92 | if(this.activePanel){ 93 | this.activePanel.slideUp(150); 94 | this.resetActivePanel(); 95 | } 96 | } 97 | 98 | showPanel({target: target}) { 99 | const panel = $(target).parent().find('.panel'); 100 | const id = this.getPanelId(panel); 101 | if (this.activePanelId != id) { 102 | panel.slideDown(150); 103 | if(this.activePanel) this.activePanel.slideUp(50); 104 | this.activePanel = panel; 105 | this.activePanelId = id; 106 | } 107 | } 108 | 109 | resetActivePanel() { 110 | this.activePanel = null; 111 | this.activePanelId = null; 112 | } 113 | 114 | getPanelId(panel) { 115 | if (!panel.data('panel-id')) { 116 | const id = Math.round(new Date().getTime() + (Math.random() * 100)); 117 | panel.data('panel-id', id); 118 | } 119 | return panel.data('panel-id'); 120 | } 121 | 122 | highlightCode(toolbar) { 123 | Prism.plugins.NormalizeWhitespace.setDefaults({ 124 | 'remove-trailing': true, 125 | 'remove-indent': true, 126 | 'left-trim': true, 127 | 'right-trim': true, 128 | 'remove-initial-line-feed': true, 129 | }); 130 | toolbar.find(".code").each((i, el) => { 131 | Prism.highlightElement(el, false) 132 | }) 133 | } 134 | 135 | renderPopovers(toolbar) { 136 | $(toolbar).find('[data-toggle="popover"]').popover(); 137 | } 138 | } 139 | const opts = window.ExDebugToolbar; 140 | (new App(opts)).render(opts.requestId); 141 | -------------------------------------------------------------------------------- /web/static/js/toolbar/breakpoints_panel.js: -------------------------------------------------------------------------------- 1 | import $ from './jquery'; 2 | import {default as Term} from 'xterm'; 3 | require('xterm/lib/addons/fit/fit'); 4 | require('xterm/lib/addons/fullscreen/fullscreen'); 5 | 6 | class BreakpointsPanel { 7 | constructor(socket, toolbar) { 8 | this.socket = socket; 9 | this.toolbar = toolbar; 10 | } 11 | 12 | render(request_id) { 13 | this.request_id = request_id 14 | this.appendModalToBody(); 15 | this.renderModal(); 16 | this.renderCodeSnippets(); 17 | } 18 | 19 | appendModalToBody() { 20 | $('#breakpoints-modal', this.toolbar).detach().appendTo(this.toolbar); 21 | } 22 | 23 | renderModal() { 24 | $('#breakpoints-modal', this.toolbar) 25 | .on('shown.bs.modal', this.renderTerm.bind(this)) 26 | .on('hidden.bs.modal', this.destroyTerm.bind(this)) 27 | $('.breakpoint', this.toolbar).click(this.showModal.bind(this)); 28 | $('[data-toggle="fullscreen"]', this.toolbar).click(this.toggleFullscreen.bind(this)); 29 | this.renderBindingPopover(); 30 | $(window).resize(this.resizeTerm.bind(this)); 31 | } 32 | 33 | renderCodeSnippets() { 34 | this.toolbar.find('.breakpoint').each((i, el) => { 35 | const $el = $(el); 36 | $el.hover(() => { 37 | const html = $el.find('.code-snippet').html(); 38 | $('#code-snippet-container').html(html); 39 | }) 40 | }) 41 | } 42 | 43 | renderBindingPopover() { 44 | $('[data-toggle="binding"]', this.toolbar).popover({ 45 | container: 'body', 46 | content: () => $('*[data-breakpoint-id="' + this.breakpoint_id +'"] .binding-popover', this.toolbar).html(), 47 | html: true, 48 | trigger: 'hover', 49 | title: 'Binding' 50 | }); 51 | } 52 | 53 | showModal({target: target}) { 54 | this.breakpoint_id = $(target).closest('tr').data('breakpoint-id'); 55 | $('#breakpoints-modal', this.toolbar).modal(); 56 | } 57 | 58 | toggleFullscreen() { 59 | this.term.toggleFullscreen(); 60 | $('#breakpoints-modal', this.toolbar).toggleClass('fullscreen'); 61 | this.term.focus(); 62 | this.resizeTerm(); 63 | } 64 | 65 | resizeTerm() { 66 | if (! this.term) { 67 | return; 68 | } 69 | 70 | const termEl = $('#breakpoints-modal .terminal.xterm.fullscreen', this.toolbar); 71 | const parentEl = $('#terminal-container', this.toolbar); 72 | if (termEl[0]) { 73 | parentEl.height(termEl.height()); 74 | parentEl.width(termEl.width()); 75 | } else { 76 | parentEl.height(""); 77 | parentEl.width(""); 78 | } 79 | this.term.fit(); 80 | } 81 | 82 | destroyTerm() { 83 | this.channel.leave(); 84 | this.term.destroy(); 85 | $('#breakpoints-modal', this.toolbar).removeClass('fullscreen'); 86 | } 87 | 88 | renderTerm() { 89 | this.channel = this.joinBreakpointChannel(this.socket); 90 | this.term = new Term({ 91 | cursorBlink: true 92 | }); 93 | this.term.open(document.getElementById('terminal-container'), true); 94 | this.term.on('data', (data) => this.channel.push('input', {input: data})); 95 | this.resizeTerm(); 96 | } 97 | 98 | joinBreakpointChannel(socket) { 99 | const channel = socket.channel("breakpoint:" + this.breakpoint_id); 100 | channel.join(); 101 | channel.on('output', ({output}) => this.term.write(output)); 102 | 103 | return channel; 104 | } 105 | } 106 | 107 | export default BreakpointsPanel; 108 | -------------------------------------------------------------------------------- /web/static/js/toolbar/history_panel.js: -------------------------------------------------------------------------------- 1 | import $ from './jquery'; 2 | 3 | class HistoryPanel { 4 | constructor(toolbar, requestId, callback) { 5 | this.toolbar = toolbar; 6 | this.addEventListeners(callback, requestId); 7 | } 8 | 9 | render(requestId) { 10 | this.toolbar.find('.history-point').removeClass('active'); 11 | this.toolbar.find(`.history-point[data-uuid="${requestId}"]`).toggleClass('active'); 12 | } 13 | 14 | addEventListeners(callback, requestId) { 15 | this.toolbar.on("click", ".history-point:not(.active)", function(event) { 16 | console.log('history click'); 17 | event.preventDefault(); 18 | callback($(this).data('uuid')); 19 | }); 20 | this.toolbar.on("click", ".back-to-current-request", function(event) { 21 | event.preventDefault(); 22 | callback(requestId); 23 | }); 24 | this.toolbar.on("click", ".history-expand, .history-collapse", function(event) { 25 | event.preventDefault(); 26 | event.stopPropagation(); 27 | const tr = $(this).closest('tr') 28 | tr.nextUntil('.last-request').fadeToggle(); 29 | tr.find('.history-expand').toggle(); 30 | tr.find('.history-collapse').toggle(); 31 | }); 32 | } 33 | } 34 | 35 | export default HistoryPanel; 36 | -------------------------------------------------------------------------------- /web/static/js/toolbar/jquery.js: -------------------------------------------------------------------------------- 1 | // Bootstrap expects a global jQuery object, which leads to a clash 2 | // between user's app and toolbar's jQuery. 3 | 4 | 5 | // 1. import jQuery from toolbar's package 6 | import $ from 'jquery'; 7 | 8 | // 2. stash global jQuery if present 9 | const _jQuery = window.jQuery; 10 | const _$ = window.$; 11 | 12 | // 3. make toolbar's jQuery global 13 | window.jQuery = $; 14 | window.$ = $; 15 | 16 | // 4. import bootstrap that adds plugins to global jQuery 17 | require('bootstrap-sass'); 18 | 19 | // 5. make stashed jQuery global again 20 | window.jQuery = _jQuery; 21 | window.$ = _$; 22 | 23 | export default $; 24 | -------------------------------------------------------------------------------- /web/static/js/toolbar/logger.js: -------------------------------------------------------------------------------- 1 | class Logger { 2 | constructor(enabled) { 3 | this.enabled = enabled; 4 | } 5 | 6 | debug(...args) { 7 | this.enabled && console.log('[ExDebugToolbar]', ...args) 8 | } 9 | } 10 | 11 | export default Logger; 12 | -------------------------------------------------------------------------------- /web/templates/toolbar/show.html.eex: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /web/templates/toolbar/show/_breakpoints.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= Enum.count(@breakpoints.entries) %> 4 | 5 | 58 | 59 | 77 | -------------------------------------------------------------------------------- /web/templates/toolbar/show/_conn.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= @request_conn.status %> 4 | <%= controller_action(@request_conn) %> 5 | 6 | 32 | -------------------------------------------------------------------------------- /web/templates/toolbar/show/_ecto.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= length @queries %> 4 | 5 |